Free lesson · GenAI Application Engineering

Build an MCP server exposing business logic as tools

You will build an MCP server in mcp_servers/business_tools.py using the mcp Python SDK. You create a Server via mcp.server.Server('business-tools') and register tools with @server.tool(). You implement three tools: query_database(sql: str, params: dict) -> QueryResult for safe read-only SQL against PostgreSQL, call_external_api(url: str, method: str, body: dict) -> APIResponse for HTTP requests via httpx with retries, and search_knowledge_base(query: str, top_k: int) -> SearchResults for text search. Each tool includes a docstring as JSON schema description. You configure stdio transport via mcp.server.stdio.stdio_server() and SSE transport via SseServerTransport for HTTP connections. A FastAPI wrapper at POST /mcp exposes the SSE endpoint. Parameters use Pydantic models for validation.

Course: Full-Stack GenAI Applications · Chapter 8 · MCP, Tool Execution & Agentic Backends

Free to read — no subscription required.

Introduction

When you hardcode tool definitions into each agent framework — Pydantic AI in one service, Google ADK in another, a custom loop in a third — every business-logic change forces parallel edits across all of them, and the copies silently drift until an agent calls a tool with a signature that no longer exists. By the end of this lesson, you'll be able to develop an MCP server using the mcp Python SDK that exposes business logic functions as discoverable tools with JSON-Schema parameter descriptions, so a single server definition serves every compliant client.

Key Terminology

  • MCP (Model Context Protocol): a JSON-RPC 2.0 protocol that lets an LLM client discover and invoke tools, resources, and prompts exposed by an independent server process, decoupling tool definitions from any specific agent framework.
  • FastMCP: the high-level Python class in the mcp SDK that registers functions as tools via the @mcp.tool() decorator and auto-generates JSON Schema for each parameter from Annotated type hints.
  • Transport: the wire mechanism a client uses to talk to an MCP server — stdio (subprocess over stdin/stdout) for local single-machine setups, or sse (HTTP Server-Sent Events) for distributed deployments where server and client run in separate containers.

Concepts

Practical Guidance for Tool Design

Effective MCP tool design follows a set of principles that differ from regular API design because your consumer is an LLM, not a human developer.

  • Tool granularity: Each tool should perform one discrete action. An LLM calling lookup_order followed by calculate_shipping is more reliable than calling a combined lookup_order_and_calculate_shipping tool. Fine-grained tools give the LLM compositional power while reducing the chance of parameter confusion.

  • Return format: Always return JSON-serialized strings rather than plain text. When the LLM receives structured data, it can extract specific fields (like tracking from an order lookup) and incorporate them into its response. Returning "Your order ORD-001 is shipped" forces the LLM to parse natural language, which is brittle and loses precision.

  • Error handling: Return errors as structured JSON with an "error" key rather than raising exceptions. An unhandled exception in an MCP tool causes the server to return a JSON-RPC error response, which many LLM frameworks interpret as a fatal failure and stop the agentic loop. A structured error like {"error": "Order not found"} lets the LLM recover gracefully—it can ask the user for a corrected order ID or try an alternative approach.

  • Idempotency: Tools that modify state (creating orders, updating records) should be idempotent whenever possible. Agentic loops may retry tool calls due to network failures or LLM re-planning, and a non-idempotent tool risks creating duplicate orders or charging a customer twice.

  • Description engineering: The tool description and parameter descriptions are your interface contract with the LLM. Include explicit guidance on when to use the tool ("Use this when the user asks about an existing order") and what format the parameters expect ("ISO 3166-1 alpha-2 country code"). Vague descriptions like "Looks up stuff" result in the LLM misusing the tool or hallucinating parameter values.

Transport Selection: stdio vs SSE

MCP supports two transport mechanisms, and the choice affects how your server integrates with the broader agentic backend:

  • stdio transport: The client launches the MCP server as a subprocess and communicates via stdin/stdout. This is the simplest deployment model—no network configuration, no port management—and is ideal for local development and single-machine deployments. When your FastAPI application starts, it spawns the MCP server process and holds a reference to it for the lifetime of the application. The mcp.run(transport="stdio") call in the server script enables this mode.

  • SSE (Server-Sent Events) transport: The MCP server runs as an independent HTTP service, and clients connect via an SSE endpoint. This is the production transport for distributed systems where the MCP server runs on a different machine or container than the client. It enables horizontal scaling (multiple MCP server replicas behind a load balancer) and independent deployment cycles. To use SSE transport, replace mcp.run(transport="stdio") with mcp.run(transport="sse", host="0.0.0.0", port=8001).

For the agentic backend architecture in this course—where MCP servers, Pydantic AI agents, and the agentic loop executor all run within or alongside a single FastAPI application—stdio transport is appropriate during development. In the lab, you will use stdio transport to keep the infrastructure simple while focusing on the tool registration and invocation patterns. When deploying to Cloud Run with Google ADK agents, the SSE transport becomes necessary because the MCP server and the ADK agent run in separate containers.

Code Walkthrough

Building on the design principles and transport choice from the previous section, the walkthrough below traces the MCP handshake end-to-end and then shows the exact FastMCP server code that registers business-logic functions as schema-described tools — the two pieces you need to develop an MCP server with the mcp Python SDK.

MCP Server Architecture

An MCP server is a long-running process that responds to JSON-RPC requests from clients. When a client connects, it sends an initialize request, and the server responds with its capabilities—including a list of available tools. Each tool declaration includes a name, a human-readable description (which the LLM uses to decide when to call the tool), and a JSON Schema object describing the expected input parameters. The LLM never sees your Python source code; it only sees these schema descriptions, so the quality of your descriptions directly determines whether the LLM calls the right tool with correct arguments.

The following diagram illustrates the message flow between an LLM client application and an MCP server during tool discovery and invocation:

This sequence diagram traces the full MCP (Model Context Protocol) handshake between a FastAPI-based MCP Client and a business-tools MCP Server. After the JSON-RPC initialize exchange confirms capabilities {tools: true}, the client calls tools/list to discover available tools and their inputSchema. The LLM then autonomously selects lookup_order, passing order_id: "ORD-123" via tools/call. The server delegates to business logic and returns structured content—illustrating how MCP decouples tool discovery from execution in agentic backends.

Code snippet mermaid
Loading diagram...
  • Line 1: Declares this as a Mermaid sequence diagram, which visualizes interactions between components over time.
  • Lines 2-4: Defines three participants (actors) in the diagram: Client (a FastAPI-based MCP Client), Server (an MCP Server named "business-tools"), and Logic (the Business Logic layer that performs actual operations).
  • Line 6: The Client sends an initialize JSON-RPC request to the Server, initiating the MCP handshake to establish a session.
  • Line 7: The Server responds (dashed arrow indicates a reply) with its capabilities, indicating it supports tool execution (tools: true).
  • Line 8: The Client requests the full list of available tools from the Server via the tools/list method.
  • Line 9: The Server returns an array of tool definitions, each containing a name, human-readable description, and a JSON Schema (inputSchema) describing the expected input parameters.
  • Line 10: A note annotation indicating that the LLM (powering the client) autonomously selects which tool to invoke by matching the user's intent against the tool descriptions.
  • Line 11: The Client sends a tools/call JSON-RPC request to the Server, invoking the lookup_order tool with the argument order_id: "ORD-123".
  • Line 12: The Server delegates the call to the Business Logic layer, invoking the lookup_order function with the string argument "ORD-123".
  • Line 13: The Business Logic layer returns the order data (status and tracking number) back to the Server.
  • Line 14: The Server wraps the result in the MCP response format—a content array containing a text block—and sends it back to the Client.

The key architectural insight here is that tool discovery (tools/list) happens at connection time, not at every request. The client caches the tool list and passes it to the LLM as part of the system prompt or tool configuration. When the LLM decides to call a tool, the client sends a tools/call request with the tool name and arguments serialized as JSON. The server executes the function, and returns the result as a list of content blocks (text, images, or embedded resources). This separation means your MCP server can be developed, tested, and deployed independently from the agent framework that consumes it.

Building the Server with the mcp Python SDK

The mcp Python SDK provides two approaches for creating servers: a low-level Server class where you register request handlers manually, and a high-level FastMCP class that uses decorators for a more ergonomic developer experience. In production systems, FastMCP is preferred because it automatically generates JSON Schema from Python type annotations, reducing boilerplate and ensuring schema-code consistency. The following snippet demonstrates creating a FastMCP server instance, registering two business logic tools using the @mcp.tool() decorator, and defining typed parameters with Annotated types that produce rich JSON Schema descriptions. The lookup_order function accepts an order_id string parameter and returns order details, while calculate_shipping accepts origin and destination country codes along with a weight in kilograms to compute shipping cost estimates.

Code snippet python
1from mcp.server.fastmcp import FastMCP 2from typing import Annotated 3import json 4 5mcp = FastMCP( 6 name="business-tools", 7 version="1.0.0", 8 description="Order management and logistics tools" 9) 10 11ORDERS_DB = { 12 "ORD-001": {"status": "shipped", "tracking": "1Z999AA10123456784", "total": 129.99}, 13 "ORD-002": {"status": "processing", "tracking": None, "total": 49.50}, 14} 15 16SHIPPING_RATES = {"US": 5.99, "CA": 12.99, "GB": 18.50, "DE": 17.25} 17 18@mcp.tool() 19def lookup_order( 20 order_id: Annotated[str, "The order identifier in ORD-XXX format"] 21) -> str: 22 """Look up order status, tracking number, and total amount by order ID. 23 Use this tool when the user asks about an existing order.""" 24 order = ORDERS_DB.get(order_id) 25 if order is None: 26 return json.dumps({"error": f"Order {order_id} not found"}) 27 return json.dumps({"order_id": order_id, **order}) 28 29@mcp.tool() 30def calculate_shipping( 31 origin: Annotated[str, "ISO 3166-1 alpha-2 country code for origin"], 32 destination: Annotated[str, "ISO 3166-1 alpha-2 country code for destination"], 33 weight_kg: Annotated[float, "Package weight in kilograms, must be positive"] 34) -> str: 35 """Calculate estimated shipping cost between two countries. 36 Use this when the user needs a shipping quote before placing an order.""" 37 if weight_kg <= 0: 38 return json.dumps({"error": "weight_kg must be positive"}) 39 origin_rate = SHIPPING_RATES.get(origin, 25.00) 40 dest_rate = SHIPPING_RATES.get(destination, 25.00) 41 base_cost = (origin_rate + dest_rate) / 2 42 total = round(base_cost * weight_kg, 2) 43 return json.dumps({ 44 "origin": origin, "destination": destination, 45 "weight_kg": weight_kg, "estimated_cost_usd": total 46 }) 47 48if __name__ == "__main__": 49 mcp.run(transport="stdio")
  • Lines 1-3: Import FastMCP from the mcp SDK's high-level server module, Annotated from typing for parameter descriptions, and json for serializing tool responses.
  • Lines 5-9: Instantiate a FastMCP server with a unique name ("business-tools"), a semantic version, and a human-readable description. The name is used by clients during tool namespacing when connecting to multiple MCP servers simultaneously.
  • Lines 11-14: Define a mock order database as a dictionary. In production, this would be replaced with an async database query or API call to your order management system.
  • Line 16: Define shipping rate lookup data per country code. The fallback rate of 25.00 applies to countries not in the dictionary.
  • Lines 19-27: Register the lookup_order function as an MCP tool using the @mcp.tool() decorator. The Annotated[str, "..."] type hint tells FastMCP to include the description string in the JSON Schema's description field for that parameter. The docstring becomes the tool-level description that the LLM reads when deciding which tool to call. The function returns a JSON string because MCP tool results are transmitted as text content blocks.
  • Lines 30-44: Register calculate_shipping with three typed parameters. Note that weight_kg uses Annotated[float, "..."]—FastMCP converts this to {"type": "number"} in JSON Schema. The validation check for positive weight is done in application code rather than relying on JSON Schema constraints, because LLMs sometimes ignore schema-level minimum constraints.
  • Lines 47-48: Start the server using stdio transport, which communicates via stdin/stdout using newline-delimited JSON-RPC messages. This is the standard transport for local MCP servers launched as subprocesses.

JSON Schema Generation and Tool Descriptions

The quality of your tool descriptions determines whether an LLM agent selects the correct tool. FastMCP automatically generates JSON Schema from your Python type annotations, but the generated schema is only as good as your annotations. When a client calls tools/list, the server returns a schema like the one shown below. Understanding this output is essential because it is exactly what the LLM sees when making tool selection decisions. The following snippet shows the JSON Schema that FastMCP generates for the calculate_shipping tool, demonstrating how Annotated type metadata maps to schema properties, and how the function's docstring becomes the top-level tool description that guides the LLM's decision to invoke this tool over alternatives.

Code snippet python
1# Generated schema for calculate_shipping (returned by tools/list) 2{ 3 "name": "calculate_shipping", 4 "description": "Calculate estimated shipping cost between two countries.\n" 5 "Use this when the user needs a shipping quote before placing an order.", 6 "inputSchema": { 7 "type": "object", 8 "properties": { 9 "origin": { 10 "type": "string", 11 "description": "ISO 3166-1 alpha-2 country code for origin" 12 }, 13 "destination": { 14 "type": "string", 15 "description": "ISO 3166-1 alpha-2 country code for destination" 16 }, 17 "weight_kg": { 18 "type": "number", 19 "description": "Package weight in kilograms, must be positive" 20 } 21 }, 22 "required": ["origin", "destination", "weight_kg"] 23 } 24}
  • Lines 2-5: The tool name is derived directly from the Python function name. The description is pulled from the function's docstring verbatim. This description is the single most important factor in tool selection—write it as if you are explaining to a colleague when this tool should be used versus alternatives.
  • Lines 6-22: The inputSchema is a standard JSON Schema object. Each parameter from the function signature becomes a property. The Annotated metadata string becomes the property-level description. The Python type float maps to JSON Schema "type": "number", str maps to "string", int maps to "integer", and bool maps to "boolean".
  • Line 23: All parameters without default values are listed in the required array. If you give a parameter a default value in the Python signature (e.g., weight_kg: Annotated[float, "..."] = 1.0), it is excluded from required and the default is included in the schema.

Do's and Don'ts

Do's

  1. Do use FastMCP with the @mcp.tool() decorator and Annotated type hintsFastMCP auto-generates inputSchema directly from Python type annotations, keeping the JSON Schema the LLM receives in sync with the actual function signatures without requiring manual schema maintenance.
  2. Do write precise, intent-revealing description strings in every @mcp.tool() registration — the LLM never sees your Python source code; it sees only the name, description, and inputSchema returned by tools/list, so vague descriptions like "handles orders" cause the LLM to select the wrong tool or construct malformed tools/call arguments.
  3. Do centralize all business-logic tools in a single FastMCP server instance — when lookup_order or calculate_shipping live in one server, every compliant client (Pydantic AI, Google ADK, custom loops) discovers them via the initializetools/list handshake, eliminating the parallel copies that silently drift when a function signature changes.

Don'ts

  1. Don't register @mcp.tool() functions with bare, un-annotated parametersFastMCP derives inputSchema entries from Annotated type hints; parameters declared without them produce an inputSchema with no descriptions, leaving the LLM to guess argument names and types and making tools/call failures nearly impossible to diagnose.
  2. Don't expect connected MCP clients to pick up tool changes mid-sessiontools/list is called once at connection time and the result is cached by the client as part of the LLM's tool configuration; any @mcp.tool() addition or signature change (e.g., adding a weight_kg parameter to calculate_shipping) is invisible to existing clients until they re-run the initialize handshake with a freshly started server.
  3. Don't duplicate tool definitions in each agent framework — re-declaring lookup_order's parameter schema inside Pydantic AI and again inside a custom loop recreates exactly the silent-drift problem MCP is designed to solve; when the business logic changes, framework copies diverge and agents issue tools/call requests with arguments the function no longer accepts.

This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Application Engineering subscription.

From · cancel anytime

More free lessons in Full-Stack GenAI Applications

All free lessons in GenAI Application Engineering