Our Blog

Building an MCP-Powered Stock Analysis Workflow with Rust, GCP, and PDF Reporting
Building an MCP-Powered Stock Analysis Workflow with Rust, GCP, and PDF Reporting

Building an MCP-Powered Stock Analysis Workflow with Rust, GCP, and PDF Reporting

Modern AI systems are most useful when they can do more than answer questions. They need to retrieve live data, call external services, coordinate multiple steps, and return outputs in a format the business can actually use. That is exactly where MCP servers, agents, and a clean API layer become valuable.

In this guide, we will build a practical architecture that combines:

  - an MCP-compatible tool layer

  - a Rust REST API for market data retrieval,

  - Google Cloud Platform for deployment and automation,

  - an analysis agent that generates PDF reports, and

The working example focuses on stock quotes for Microsoft, Apple, and NVIDIA using Alpha Vantage. The same architecture can later expand to more symbols, more sophisticated analysis, and more delivery channels.
Complete code is available at Github Repository: GitHub Repository Link

Understanding MCP Servers and Agents

MCP, short for Model Context Protocol, is an open standard for connecting AI applications to external systems. In practice, MCP gives you a structured way to expose tools, resources, and prompts so an AI client or agent can interact with services without ad hoc glue code.

At a high level, the architecture usually looks like this:

  1. An MCP client initiates requests.

  2. An MCP server exposes capabilities such as tools and resources.

  3. Agents decide which tool to call, how to pass arguments, and what to do with results.

  4. External services such as APIs, databases, or file systems perform the underlying work.

This separation matters because it keeps the workflow modular.

  - One agent can fetch live stock data.

  - Another agent can analyze it.

  - Another step can format the result into a PDF.

  - A notification component can email the final report link. (Note: This is simulated)

Instead of one giant application doing everything, MCP lets you build loosely coupled components that work together through typed interfaces.

MCP Server Responsibilities

An MCP server usually exposes three categories of capability:

  Tools: executable functions an agent can call.

  Resources: retrievable data, similar to files or structured documents.

  Prompts: reusable prompt templates for guided interactions.

For this blog, the most important concept is the tool. We will expose a Rust stock API as a callable capability. Then a separate analysis flow will consume that output.

Reference Architecture for This Tutorial
We will implement the following pipeline:

Reference architecture for this tutorial

  1. A Rust REST API fetches stock quotes from Alpha Vantage.

  2. An MCP server exposes that API as an agent-callable tool.

  3. A stock analysis agent calls the tool and interprets the results

  4. A PDF generator creates a report.

  5. The PDF is stored in Storage.

3. Introduction to REST API Development Using Rust

Rust is a strong fit for API services because it combines performance, memory safety, and predictable concurrency. For backend teams, that means fewer runtime surprises and efficient services that can scale without excessive resource usage.

A typical Rust REST API contains:

  + routing to map URLs to handlers,

  + request handling to validate inputs,

  + serialization to convert Rust structs to and from JSON,

  + HTTP client logic to call external APIs,

  + configuration management using environment variables

  + error handling for upstream failures and malformed requests.

In our example, the Rust API will do one thing well: fetch current stock quotes from Alpha Vantage and expose them through JSON endpoints that an MCP agent can consume cleanly.

Project Structure:
Review the following screenshot to understand the RUST based REST API's project structure

RUST API Project's structure

Key files include:

1. Cargo.toml
Cargo.toml defines a Rust project’s metadata and dependencies, telling Cargo how to build, manage, and run the application. Key components include:

  + package: Project metadata section containing package identity and compiler settings. [package] name = "stock-api" version = "0.1.0" edition = "2021"

  + name="stock-api": Sets the project and crate name used by Cargo during build and package management.

  + version = "0.1.0": Defines the current package version, useful for releases and dependency tracking.

  + edition = "2021": Specifies the Rust language edition, enabling syntax and compiler behavior for 2021 features.

dependencies: same promotion mechanism every time, regardless of team [dependencies] axum = "0.8" tokio = { version = "1", features = ["full"] } reqwest = { version = "0.12", features = ["json", "rustls-tls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" dotenvy = "0.15" tower-http = { version = "0.6", features = ["cors", "trace"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] }

  + axum = "0.8"
Web framework for building HTTP APIs, routing requests, and returning structured responses.

  + tokio = { version = "1", features = ["full"] }
Async runtime powering concurrent tasks, networking, timers, and background operations.

  + reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
LHTTP client for calling external APIs with JSON support and TLS encryption.

  + serde = { version = "1", features = ["derive"] }
Serialization library for converting Rust structs to and from JSON and other formats.

  + serde_json = "1"
Adds JSON-specific support on top of Serde for encoding and decoding JSON data.

  + dotenvy = "0.15"
Loads environment variables from a .env file for local development configuration.

  + tower-http = { version = "0.6", features = ["cors", "trace"] }
HTTP middleware utilities for CORS handling and request tracing in web applications.

  + tracing = "0.1"
Structured logging framework for recording application events, diagnostics, and runtime behavior.

  + tracing-subscriber = { version = "0.3", features = ["env-filter"] }
Configures how tracing logs are collected, filtered, and displayed based on environment settings.

2. .env
Environment variables file for storing your credentials:

ALPHAVANTAGE_API_KEY=your_real_key_here BIND_ADDRESS=0.0.0.0:8080

3. README.md
This file contains the instructions for how to setup your environment for running code RUST based REST API. Once you have cloned the repository (https://github.com/ronin1770/mcp-sample-with-stock-analysis), you can review it. It is located under repo_root/RUST/new-stock-api/README.md>

ALPHAVANTAGE_API_KEY=your_real_key_here BIND_ADDRESS=0.0.0.0:8080

4. Main Code file (main.rs): This is the main code file for creating REST API. It is located at repo_root/RUST/new-stock-api/src/main.rs use axum::{ extract::{Query, State}, http::StatusCode, response::{IntoResponse, Response}, routing::get, Json, Router, }; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, env, io, net::SocketAddr, sync::Arc}; use tokio::net::TcpListener; use tower_http::{cors::CorsLayer, trace::TraceLayer}; use tracing::info; use tracing_subscriber::EnvFilter; #[derive(Clone)] struct AppState { http_client: Client, alpha_key: String, } #[derive(Debug, Deserialize)] struct QuotesQuery { symbols: String, } #[derive(Debug, Serialize)] struct QuotesResponse { requested_symbols: Vec, quotes: Vec, } #[derive(Debug, Serialize)] struct QuoteDto { symbol: String, price: f64, previous_close: f64, change: f64, change_percent: String, latest_trading_day: String, } #[derive(Debug, Deserialize)] struct AlphaQuoteResponse { #[serde(rename = "Global Quote")] global_quote: Option, #[serde(rename = "Note")] note: Option, #[serde(rename = "Error Message")] error_message: Option, } #[derive(Debug, Deserialize)] struct AlphaGlobalQuote { #[serde(rename = "01. symbol")] symbol: Option, #[serde(rename = "05. price")] price: Option, #[serde(rename = "08. previous close")] previous_close: Option, #[serde(rename = "09. change")] change: Option, #[serde(rename = "10. change percent")] change_percent: Option, #[serde(rename = "07. latest trading day")] latest_trading_day: Option, } #[derive(Debug)] struct ApiError { status: StatusCode, message: String, } #[derive(Serialize)] struct ErrorBody { error: String, } impl IntoResponse for ApiError { fn into_response(self) -> Response { (self.status, Json(ErrorBody { error: self.message })).into_response() } } #[derive(Serialize)] struct HealthResponse { status: &'static str, } #[tokio::main] async fn main() -> Result<(), Box> { dotenvy::dotenv().ok(); let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,tower_http=info")); tracing_subscriber::fmt().with_env_filter(env_filter).init(); let alpha_key = env::var("ALPHAVANTAGE_API_KEY").map_err(|_| { io::Error::new( io::ErrorKind::InvalidInput, "Missing ALPHAVANTAGE_API_KEY in environment", ) })?; let bind_address = env::var("BIND_ADDRESS").unwrap_or_else(|_| "0.0.0.0:8080".to_string()); let addr: SocketAddr = bind_address.parse().map_err(|e| { io::Error::new( io::ErrorKind::InvalidInput, format!("Invalid BIND_ADDRESS '{}': {}", bind_address, e), ) })?; let state = Arc::new(AppState { http_client: Client::new(), alpha_key, }); let app = Router::new() .route("/health", get(health)) .route("/quotes", get(get_quotes)) .with_state(state) .layer(CorsLayer::permissive()) .layer(TraceLayer::new_for_http()); let listener = TcpListener::bind(addr).await?; info!("stock-api listening on {}", addr); axum::serve(listener, app).await?; Ok(()) } async fn health() -> Json { Json(HealthResponse { status: "ok" }) } async fn get_quotes( State(state): State>, Query(params): Query, ) -> Result, ApiError> { let symbols: Vec = params .symbols .split(',') .map(|s| s.trim().to_uppercase()) .filter(|s| !s.is_empty()) .collect(); if symbols.is_empty() { return Err(ApiError { status: StatusCode::BAD_REQUEST, message: "Provide at least one symbol, e.g. ?symbols=MSFT,AAPL,NVDA".to_string(), }); } let mut quotes = Vec::with_capacity(symbols.len()); for symbol in &symbols { let quote = fetch_quote(&state.http_client, &state.alpha_key, symbol).await?; quotes.push(quote); } Ok(Json(QuotesResponse { requested_symbols: symbols, quotes, })) } async fn fetch_quote(client: &Client, api_key: &str, symbol: &str) -> Result { let mut query = HashMap::new(); query.insert("function", "GLOBAL_QUOTE"); query.insert("symbol", symbol); query.insert("apikey", api_key); let response = client .get("https://www.alphavantage.co/query") .query(&query) .send() .await .map_err(|e| ApiError { status: StatusCode::BAD_GATEWAY, message: format!("Failed to call Alpha Vantage: {}", e), })?; if !response.status().is_success() { return Err(ApiError { status: StatusCode::BAD_GATEWAY, message: format!("Alpha Vantage returned status {}", response.status()), }); } let parsed: AlphaQuoteResponse = response.json().await.map_err(|e| ApiError { status: StatusCode::BAD_GATEWAY, message: format!("Invalid Alpha Vantage JSON response: {}", e), })?; if let Some(msg) = parsed.error_message.or(parsed.note) { return Err(ApiError { status: StatusCode::BAD_GATEWAY, message: format!("Alpha Vantage error for {}: {}", symbol, msg), }); } let q = parsed.global_quote.ok_or_else(|| ApiError { status: StatusCode::BAD_GATEWAY, message: format!("No quote returned for {}", symbol), })?; let response_symbol = required_text(q.symbol, "01. symbol", symbol)?; let price = parse_f64(q.price, "05. price", symbol)?; let previous_close = parse_f64(q.previous_close, "08. previous close", symbol)?; let change = parse_f64(q.change, "09. change", symbol)?; let change_percent = required_text(q.change_percent, "10. change percent", symbol)?; let latest_trading_day = required_text(q.latest_trading_day, "07. latest trading day", symbol)?; Ok(QuoteDto { symbol: response_symbol, price, previous_close, change, change_percent, latest_trading_day, }) } fn required_text(value: Option, field: &str, symbol: &str) -> Result { value .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .ok_or_else(|| ApiError { status: StatusCode::BAD_GATEWAY, message: format!("Missing {} for {}", field, symbol), }) } fn parse_f64(value: Option, field: &str, symbol: &str) -> Result { let raw = required_text(value, field, symbol)?; raw.parse::().map_err(|_| ApiError { status: StatusCode::BAD_GATEWAY, message: format!("Could not parse {}='{}' for {}", field, raw, symbol), }) }

Key components of this file include:

Imports: Libaries / modules imported by main.rs. use axum::{ extract::{Query, State}, http::StatusCode, response::{IntoResponse, Response}, routing::get, Json, Router, }; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, env, io, net::SocketAddr, sync::Arc}; use tokio::net::TcpListener; use tower_http::{cors::CorsLayer, trace::TraceLayer}; use tracing::info; use tracing_subscriber::EnvFilter;

  + axum
Provides web server features like routing, request extraction, responses, JSON handling, and application state management.

  + reqwest::Client
HTTP client used to call the Alpha Vantage external API asynchronously.

  + axum
Provides web server features like routing, request extraction, responses, JSON handling, and application state management.

  + serde::{Deserialize, Serialize}
Enables conversion between Rust structs and JSON request or response data.

  + std::{collections::HashMap, env, io, net::SocketAddr, sync::Arc}
Provides maps, environment access, error handling, socket binding, and thread-safe shared state.

  + tokio::net::TcpListener
Creates the async TCP listener that binds the server to a network address.

  + axum
Provides web server features like routing, request extraction, responses, JSON handling, and application state management.

  + tower_http::{cors::CorsLayer, trace::TraceLayer}
Adds middleware for CORS support and HTTP request tracing.

  + tracing::info
Logs informational runtime messages, such as server startup details.

  + tracing_subscriber::EnvFilter
Configures log filtering based on environment variables or fallback settings.

Key Methods

  + main()
Initializes environment, logging, configuration, shared state, routes, and starts the Axum HTTP server.

  + health()
Returns a simple JSON health check response showing the service is running.

  + get_quotes()
Reads stock symbols from query parameters, validates them, fetches quotes, and returns structured JSON results.

  + fetch_quote()
Calls Alpha Vantage API, validates response, parses fields, and converts them into internal quote data.

  + required_text()
Ensures a required string field exists and is not empty.

  + required_text()
ializes environment, logging, configuration, shared state, routes, and starts the Axum HTTP server.

  + parse_f64()
Validates a text field and parses it into a floating-point number.

  + into_response()
Converts custom API errors into HTTP responses with status code and JSON error body.

Main execution block or method
ializes environment, logging, configuration, shared state, routes, and starts the Axum HTTP server.

  + #[tokio::main] async fn main()
Program entry point using Tokio runtime for async execution.

Build and Run the Code

You can build the code for RUST using the following command: cargo run How to run RUST Application

Next, we can test it using the following code (on the same development server) curl "http://127.0.0.1:8080/health" Output is: {"status":"ok"} Let's try to get quote for MSFT: curl "http://127.0.0.1:8080/quotes?symbols=MSFT" Output is: {"requested_symbols":["MSFT"],"quotes":[{"symbol":"MSFT","price":395.55,"previous_close":401.86,"change":-6.31,"change_percent":"-1.5702%","latest_trading_day":"2026-03-13"}]}

Setting Up MCP Servers and Agents

Now that the Rust API is live, the next step is to make it accessible to agents through MCP.

In order to install prerequisites, please refer to README.md file in the Repo root server/MCP_SERVER/mcp_server.py. Let's create a very simple MCP Server using Python and we call it: mcp_server.py. import os import re from argparse import ArgumentParser from pathlib import Path from typing import Any import httpx from dotenv import load_dotenv from starlette.middleware.cors import CORSMiddleware from mcp.server.fastmcp import FastMCP from mcp.server.transport_security import TransportSecuritySettings # Load .env from the same directory as this file load_dotenv(Path(__file__).with_name(".env")) RUST_API_BASE = os.getenv("RUST_API_BASE", "http://127.0.0.1:8080") PUBLIC_IP = os.getenv("MCP_PUBLIC_IP", "136.114.37.41") mcp = FastMCP( "stock-tools", stateless_http=True, json_response=True, transport_security=TransportSecuritySettings( enable_dns_rebinding_protection=True, allowed_hosts=[ "127.0.0.1:*", "localhost:*", f"{PUBLIC_IP}:*", ], allowed_origins=[ "http://127.0.0.1:*", "http://localhost:*", f"http://{PUBLIC_IP}:*", "https://127.0.0.1:*", "https://localhost:*", f"https://{PUBLIC_IP}:*", ], ), ) @mcp.tool() async def get_stock_quotes(symbols: list[str]) -> dict[str, Any]: """Fetch latest stock quotes from the Rust API.""" joined = ",".join(symbols) async with httpx.AsyncClient(timeout=20.0) as client: response = await client.get( f"{RUST_API_BASE}/quotes", params={"symbols": joined}, ) response.raise_for_status() return response.json() # Build MCP ASGI app (includes /mcp route and required lifespan handlers) starlette_app = mcp.streamable_http_app() # Browser-based MCP clients need CORS + exposed Mcp-Session-Id header app = CORSMiddleware( starlette_app, allow_origin_regex=rf"^https?://(127\.0\.0\.1|localhost|{re.escape(PUBLIC_IP)})(:\d+)?$", allow_methods=["GET", "POST", "DELETE", "OPTIONS"], allow_headers=[ "Content-Type", "Authorization", "mcp-protocol-version", "mcp-session-id", ], expose_headers=["Mcp-Session-Id", "mcp-session-id"], allow_credentials=False, ) def main() -> None: """Run the server for local development and MCP Inspector.""" parser = ArgumentParser(description="Run the stock MCP server") parser.add_argument( "--transport", choices=["streamable-http", "stdio"], default=os.getenv("MCP_TRANSPORT", "streamable-http"), help="Use streamable-http for browser Inspector URL mode or stdio for command mode", ) parser.add_argument("--host", default=os.getenv("MCP_HOST", "0.0.0.0")) parser.add_argument("--port", type=int, default=int(os.getenv("MCP_PORT", "8000"))) args = parser.parse_args() if args.transport == "stdio": mcp.run(transport="stdio") return import uvicorn uvicorn.run(app, host=args.host, port=args.port) if __name__ == "__main__": main()

Key parts of the above code is:

Imports import os import re from argparse import ArgumentParser from pathlib import Path from typing import Any import httpx from dotenv import load_dotenv from starlette.middleware.cors import CORSMiddleware from mcp.server.fastmcp import FastMCP from mcp.server.transport_security import TransportSecuritySettings

  + os
Provides access to environment variables and operating system utilities.

  + re
Used to safely build a regex pattern for allowed CORS origins.

  + from argparse import ArgumentParser
Parses command-line arguments such as transport type, host, and port.

  + from pathlib import Path
Provides clean filesystem path handling, used here to locate the .env file.

  + from typing import Any
Used for flexible type hints when returned JSON structure may contain mixed value types.

  + httpx
Async HTTP client used to call the Rust API.

  + from dotenv import load_dotenv
Loads environment variables from a .env file into the runtime environment.

  + from starlette.middleware.cors import CORSMiddleware
Adds CORS support so browser-based MCP clients can access the server safely.

  + from mcp.server.fastmcp import FastMCP
Creates the MCP server and registers tools exposed to MCP clients.

  + from mcp.server.transport_security import TransportSecuritySettings
Configures allowed hosts/origins and protects against DNS rebinding attacks.

Key methods

  + get_stock_quotes
Fetches stock quotes asynchronously from Rust API and returns parsed JSON response.

  + starlette_app = mcp.streamable_http_app()
Creates an ASGI app from the MCP server.
app = CORSMiddleware( starlette_app, ... )Wraps the MCP app with browser access controls. Browser-based clients like MCP Inspector need CORS permissions to call the server.

  + main
Starts server using selected transport mode and runtime host-port configuration.def main() -> None:

  + Entry-point block
Runs main() only when this file is executed directly.if __name__ == "__main__": main()

You can start the MCP Stock server mcp_server.py using the following command: python mcp_server.py

How To Test It
Use MCP Inspector to inspect tools, test parameters, and view responses during development. Note: Review the README.md file for installation instructions MCP Inspector connected to MCP Server

Connecting MCP Agents to the Rust API

At this point, we have:

  + a Rust REST API that provides stock quotes,

  + an MCP server that wraps the API as a tool.

Now we need the integration layer that lets an agent use that tool inside a workflow.

Request Flow

The flow looks like this:

  + 1. Agent receives a task such as “analyze MSFT, AAPL, and NVDA.”

  + 2. Agent calls the MCP tool get_stock_quotes.

  + 3. MCP server sends an HTTP request to the Rust API.

  + 4. Rust API fetches live data from Alpha Vantage.

  + 5. Rust API returns normalized JSON.

  + 6. MCP server returns the result to the agent.

  + 7. The agent continues with analysis or reporting.

Stock Analysis Agent Code: analysis_pdf_agent.py
This agent will:

  + 1. call the stock quote tool,

  + 2. compute basic analysis.

  + 3. rank the requested symbols by daily change.

  + 4. create a PDF report.

  + 5. store the pdf.

Here is the complete code for analysis agent: import asyncio import os from datetime import datetime, timezone from pathlib import Path from typing import Any from dotenv import load_dotenv from fastmcp import Client from fastmcp.client.transports import StreamableHttpTransport from reportlab.lib.pagesizes import A4 from reportlab.pdfgen import canvas load_dotenv() MCP_SERVER_URL = os.getenv("MCP_SERVER_URL", "http://127.0.0.1:8000/mcp") REPORT_DIR = Path(os.getenv("REPORT_DIR", "./reports")) REPORT_DIR.mkdir(parents=True, exist_ok=True) async def fetch_quotes_via_mcp(symbols: list[str]) -> dict[str, Any]: """ Fetch normalized quotes by calling the MCP tool instead of the Rust API directly. """ transport = StreamableHttpTransport(url=MCP_SERVER_URL) client = Client(transport) async with client: result = await client.call_tool("get_stock_quotes", {"symbols": symbols}) if hasattr(result, "data"): data = result.data elif isinstance(result, dict): data = result else: raise RuntimeError(f"Unexpected MCP tool result: {result}") if not isinstance(data, dict): raise RuntimeError("MCP tool returned non-dict data") return data def enrich_analysis(report_data: dict[str, Any]) -> dict[str, Any]: """ Add ranking and summary fields for PDF generation. """ quotes = report_data.get("quotes", []) if not quotes: raise ValueError("No quotes found in report data") sorted_quotes = sorted(quotes, key=lambda q: float(q.get("change", 0)), reverse=True) best = sorted_quotes[0] worst = sorted_quotes[-1] return { "requested_symbols": report_data.get("requested_symbols", []), "quotes": sorted_quotes, "summary": { "highest_performer": best["symbol"], "highest_change": best["change"], "highest_change_percent": best["change_percent"], "lowest_performer": worst["symbol"], "lowest_change": worst["change"], "lowest_change_percent": worst["change_percent"], }, } def _draw_line(c: canvas.Canvas, text: str, x: int, y: int, max_chars: int = 100) -> int: c.drawString(x, y, text[:max_chars]) return y - 16 def generate_pdf(report_data: dict[str, Any], filename: Path) -> None: """ Render the analysis result into a simple PDF report. """ c = canvas.Canvas(str(filename), pagesize=A4) width, height = A4 y = height - 50 c.setFont("Helvetica-Bold", 16) c.drawString(50, y, "Stock Analysis Report") y -= 25 c.setFont("Helvetica", 10) c.drawString(50, y, f"Generated at: {datetime.now(timezone.utc).isoformat()}") y -= 30 c.setFont("Helvetica-Bold", 12) c.drawString(50, y, "Requested Symbols") y -= 20 c.setFont("Helvetica", 10) symbols_text = ", ".join(report_data.get("requested_symbols", [])) y = _draw_line(c, symbols_text, 50, y, max_chars=110) y -= 10 c.setFont("Helvetica-Bold", 12) c.drawString(50, y, "Ranked Quote Summary") y -= 20 c.setFont("Helvetica", 10) for idx, quote in enumerate(report_data["quotes"], start=1): line = ( f"{idx}. {quote['symbol']} | price={quote['price']} | prev_close={quote['previous_close']} | " f"change={quote['change']} ({quote['change_percent']}) | day={quote['latest_trading_day']}" ) y = _draw_line(c, line, 50, y, max_chars=115) if y < 80: c.showPage() y = height - 50 c.setFont("Helvetica", 10) y -= 10 c.setFont("Helvetica-Bold", 12) c.drawString(50, y, "Highlights") y -= 20 c.setFont("Helvetica", 10) summary = report_data["summary"] y = _draw_line( c, f"Top performer: {summary['highest_performer']} with change " f"{summary['highest_change']} ({summary['highest_change_percent']})", 50, y, max_chars=110, ) y = _draw_line( c, f"Weakest performer: {summary['lowest_performer']} with change " f"{summary['lowest_change']} ({summary['lowest_change_percent']})", 50, y, max_chars=110, ) c.save() async def main() -> None: symbols = ["MSFT", "AAPL", "NVDA"] raw_data = await fetch_quotes_via_mcp(symbols) analyzed = enrich_analysis(raw_data) ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") output = REPORT_DIR / f"stock-report-{ts}.pdf" generate_pdf(analyzed, output) print(f"Report written to {output}") if __name__ == "__main__": asyncio.run(main())

Key parts of Stock Analysis Tool
Imports import asyncio import os from datetime import datetime, timezone from pathlib import Path from typing import Any from dotenv import load_dotenv from fastmcp import Client from fastmcp.client.transports import StreamableHttpTransport from reportlab.lib.pagesizes import A4 from reportlab.pdfgen import canvas

  + os
Used to read environment variables like MCP server URL and report directory.

  + asyncio
Runs async functions and manages the event loop for non-blocking operations.

  + from datetime import datetime, timezone
Creates timezone-aware timestamps for report metadata and unique PDF output filenames.

  + from pathlib import Path
Handles file and directory paths cleanly across operating systems and environments.

  + from typing import Any
Allows flexible type annotations for dictionary values returned from external services.

  + from dotenv import load_dotenv
Loads local configuration values from a .env file before execution starts.

  + from fastmcp import Client
Creates a client object for communicating with MCP tools exposed by the server.

  + from reportlab.pdfgen import canvas
eates and writes PDF pages by drawing text and layout elements programmatically.

  + from reportlab.lib.pagesizes import A4
Defines standard A4 page dimensions used while generating the PDF document.

Key Functions

  + fetch_quotes_via_mcp
Fetches normalized stock quote data by calling the MCP tool over HTTP. async def fetch_quotes_via_mcp(symbols: list[str]) -> dict[str, Any]:

  + enrich_analysis
Processes raw quote data into ranked results and summary highlights. def enrich_analysis(report_data: dict[str, Any]) -> dict[str, Any]:

  + _draw_line
Writes a single text line to PDF and returns the next vertical position. def _draw_line(c: canvas.Canvas, text: str, x: int, y: int, max_chars: int = 100) -> int:

  + generate_pdf
Builds the final PDF report from ranked quotes and summary highlights. def generate_pdf(report_data: dict[str, Any], filename: Path) -> None:

  + main
Coordinates data retrieval, analysis, filename generation, and PDF creation workflow. async def main() -> None:

Main execution code
Defines the stock symbols to analyze. symbols = ["MSFT"]

Calls the MCP tool and gets stock quote data. raw_data = await fetch_quotes_via_mcp(symbols)

Processes and ranks the returned data. analyzed = enrich_analysis(raw_data)

Creates a UTC timestamp and builds a unique PDF filename. ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") output = REPORT_DIR / f"stock-report-{ts}.pdf"

Writes the PDF report to disk. generate_pdf(analyzed, output)

Shows the output path in the terminal. print(f"Report written to {output}")

Entry-point block
It starts the async workflow properly using Python’s event loop. if __name__ == "__main__": asyncio.run(main())

Run the analysis agent python analysis_pdf_agent.py

Expected result Report written to reports/stock-report-20260316T051500Z.pdf You can download the report from our test run at: Download Sample Report

Conclusion

This architecture shows how MCP agents, a Rust API, and Google Cloud can work together as a modular data-processing system. The reason this system works well is that we have separated responsibilities clearly:

  + Rust API handles external market data retrieval.

  + MCP server exposes the retrieval logic as structured tools.

  + Analysis agent interprets the data.

  + GCP VM for hosting these tools on internet accessible medium

This separation gives you real engineering advantages

  + easier maintenance

  + safer upgrades

  + clearer ownership boundaries

  + better testability

  + simpler scaling of specific components

  + smoother adoption of new models, tools, or data providers

As requirements evolve, you can extend the system in multiple directions

  + add historical time series analysis

  + compare portfolio groups

  + support sector-level reporting

  + integrate with additional MCP tools

  + switch from a VM to containers or Kubernetes

  + add approval workflows before sending final reports

In other words, this is not just a tutorial about stock quotes. It is a practical pattern for building modern agent-enabled backend systems with a clean separation between retrieval, orchestration, analysis, and delivery.

This is where FAMRO can help. With strong infrastructure and programming expertise, FAMRO helps organizations design, build, and operationalize scalable backend systems that are ready for real-world business use. From cloud architecture and API engineering to automation workflows, secure integrations, and production deployment, our teams turn technical patterns like this into reliable enterprise solutions.

Whether you are building an MCP-enabled reporting workflow, modernizing a data-processing backend, or preparing an AI-assisted system for production, FAMRO can support the full journey—from architecture design and implementation to hardening, scaling, and long-term operational support.

If you are planning to apply this pattern in production, start with the simplest version, validate the workflow end to end, and then strengthen each layer step by step. With the right engineering foundation, what begins as a focused automation use case can evolve into a scalable platform for intelligent business operations.

To help teams get started, we offer a free initial consultation focused on your current specific use case.
🌐 Learn more: Visit Our Homepage
💬 WhatsApp: +971-505-208-240

Our solutions for your business growth

Our services enable clients to grow their business by providing customized technical solutions that improve infrastructure, streamline software development, and enhance project management.

Our technical consultancy and project management services ensure successful project outcomes by reviewing project requirements, gathering business requirements, designing solutions, and managing project plans with resource augmentation for business analyst and project management roles.

Read More
2
Infrastructure / DevOps
3
Project Management
4
Technical Consulting