{"slug": "tutorial-python-mcp-internal-api-llm", "title": "Tutorial: Python MCP Internal API > LLM", "summary": "A tutorial demonstrates how to build a Python MCP server using FastMCP that wraps a corporate REST API into three typed tools—search_customers, get_order, and create_support_ticket—enabling LLMs like Claude Desktop to call the API with type safety without exposing credentials.", "body_md": "# Ship an MCP Server in Python That Exposes Your Internal API to LLMs\n\nWrap a corporate REST API in three typed tools using FastMCP, inspect them locally, and connect them to Claude Desktop—without ever exposing credentials to the model.\n\n[Mariana Souza](https://www.devclubhouse.com/u/mariana_souza)\n\n## What You'll Build\n\nA Python MCP server using `FastMCP`\n\nthat wraps a corporate REST API as three structured tools—`search_customers`\n\n, `get_order`\n\n, and `create_support_ticket`\n\n. Any MCP-compatible client (Claude Desktop, Cursor, custom agents) can call your API with full type safety, without the model ever seeing credentials or constructing raw URLs.\n\n## Prerequisites\n\n- Python 3.10+ (required for built-in generic types like\n`list[dict]`\n\n) `pip`\n\nor`uv`\n\nfor package management- Node.js 18+ —\n`mcp dev`\n\ninvokes`npx @modelcontextprotocol/inspector`\n\nunder the hood - Latest Claude Desktop (for end-to-end testing; optional if using only the inspector)\n- A REST API with a bearer token — a mock URL works fine to follow along\n- Comfortable with\n`async`\n\n/`await`\n\nPython\n\n## 1. Set Up the Project\n\n```\nmkdir mcp-internal-api && cd mcp-internal-api\npython -m venv .venv\nsource .venv/bin/activate        # Windows: .venv\\Scripts\\activate\npip install \"mcp[cli]\" httpx python-dotenv\n```\n\n`mcp[cli]`\n\ninstalls the `mcp`\n\nCLI used for local inspection. `httpx`\n\nhandles async HTTP to your backend.\n\nCreate `.env`\n\nfor local credentials — **add it to .gitignore now**:\n\n```\nAPI_BASE_URL=https://api.corp.example.com\nAPI_KEY=sk-your-real-token-here\n```\n\n## 2. Write the Server\n\nCreate `server.py`\n\n:\n\n``` python\nimport os\nimport httpx\nfrom dotenv import load_dotenv\nfrom mcp.server.fastmcp import FastMCP\n\nload_dotenv()\n\nmcp = FastMCP(\"internal-api\")\n\n_BASE = os.environ[\"API_BASE_URL\"]\n_KEY  = os.environ[\"API_KEY\"]\n\ndef _auth_headers() -> dict[str, str]:\n    return {\"Authorization\": f\"Bearer {_KEY}\", \"Accept\": \"application/json\"}\n\n@mcp.tool()\nasync def search_customers(query: str, limit: int = 10) -> list[dict]:\n    \"\"\"Search customers by name or email. Returns a list of customer records.\"\"\"\n    async with httpx.AsyncClient() as client:\n        r = await client.get(\n            f\"{_BASE}/customers\",\n            headers=_auth_headers(),\n            params={\"q\": query, \"limit\": limit},\n            timeout=10.0,\n        )\n        r.raise_for_status()\n        return r.json()\n\n@mcp.tool()\nasync def get_order(order_id: str) -> dict:\n    \"\"\"Fetch a single order by its ID.\"\"\"\n    async with httpx.AsyncClient() as client:\n        r = await client.get(\n            f\"{_BASE}/orders/{order_id}\",\n            headers=_auth_headers(),\n            timeout=10.0,\n        )\n        r.raise_for_status()\n        return r.json()\n\n@mcp.tool()\nasync def create_support_ticket(\n    customer_id: str,\n    subject: str,\n    body: str,\n    priority: str = \"normal\",\n) -> dict:\n    \"\"\"Open a support ticket for a customer.\n\n    Args:\n        customer_id: The customer's UUID.\n        subject: One-line summary (max 120 chars).\n        body: Full description of the issue.\n        priority: 'low', 'normal', or 'high'.\n    \"\"\"\n    if priority not in {\"low\", \"normal\", \"high\"}:\n        raise ValueError(f\"priority must be low/normal/high, got '{priority}'\")\n\n    async with httpx.AsyncClient() as client:\n        r = await client.post(\n            f\"{_BASE}/tickets\",\n            headers=_auth_headers(),\n            json={\n                \"customer_id\": customer_id,\n                \"subject\": subject,\n                \"body\": body,\n                \"priority\": priority,\n            },\n            timeout=10.0,\n        )\n        r.raise_for_status()\n        return r.json()\n\nif __name__ == \"__main__\":\n    mcp.run()\n```\n\n**Why each decision matters:**\n\n| Detail | Reason |\n|---|---|\n| Type annotations | `FastMCP` auto-generates JSON Schema from them — the LLM receives exact parameter types, not free-form text |\n| Docstrings | Become the tool description the model reads before calling; write them like an API spec |\n`raise_for_status()` + `ValueError` |\nExceptions surface to the LLM as structured tool errors rather than crashing the server process |\n| Credentials in env vars | Never passed as tool arguments, never echoed in responses, never in source control |\n\n`mcp.run()`\n\ndefaults to **stdio transport**, which is what Claude Desktop and most local clients expect — the client spawns your server as a subprocess and talks JSON-RPC over stdin/stdout.\n\n## 3. Inspect Locally with `mcp dev`\n\nBefore touching any LLM, validate the wiring in a browser UI:\n\n```\nmcp dev server.py\n```\n\nThis starts your server and opens the MCP Inspector (the URL is printed in your terminal). Navigate to **Tools** — you'll see all three tools with auto-generated input forms matching your Python signatures. Call `search_customers`\n\nwith `query = \"alice\"`\n\nand confirm a JSON response or a typed upstream error.\n\nTip:Set`API_BASE_URL=https://httpbin.org`\n\ntemporarily to exercise the async/auth plumbing without a live internal API. You'll get a 404 back, which correctly surfaces as an`httpx.HTTPStatusError`\n\ntool error.\n\n## 4. Wire to Claude Desktop\n\nLocate the config file:\n\n| OS | Path |\n|---|---|\n| macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` |\n| Windows | `%APPDATA%\\Claude\\claude_desktop_config.json` |\n\nAdd your server entry. **Use absolute paths** — Claude Desktop spawns a clean, non-login shell that won't activate your virtualenv:\n\n[Serverless Inference by DigitalOcean 55+ models, every modality. One API key, one bill.](https://www.devclubhouse.com/go/ad/13)\n\n```\n{\n  \"mcpServers\": {\n    \"internal-api\": {\n      \"command\": \"/absolute/path/to/.venv/bin/python\",\n      \"args\": [\"/absolute/path/to/server.py\"],\n      \"env\": {\n        \"API_BASE_URL\": \"https://api.corp.example.com\",\n        \"API_KEY\": \"sk-your-real-token-here\"\n      }\n    }\n  }\n}\n```\n\nRestart Claude Desktop, then in a new conversation:\n\n\"Search for customers named 'smith', then open a high-priority support ticket for the first result explaining their order is delayed.\"\n\nClaude will call `search_customers`\n\n, inspect the output, then call `create_support_ticket`\n\n— tool calls appear inline in the UI with their arguments and responses visible.\n\n## Verify It Works\n\n**Inspector:** After `mcp dev server.py`\n\n, the Tools tab lists all three tools with correct schemas and no import errors in the terminal.\n\n**Claude Desktop:** Open **Settings → Developer**. Your server appears as `internal-api`\n\nwith a green connected indicator. If it shows an error state, restart Claude Desktop after editing the config.\n\n**Schema sanity-check** — confirm FastMCP generated correct schemas without starting a full client:\n\n``` python\npython -c \"\nimport os; os.environ['API_BASE_URL']='http://x'; os.environ['API_KEY']='x'\nimport asyncio\nimport server\nasync def main():\n    for tool in await server.mcp.list_tools():\n        print(tool.name, tool.inputSchema)\nasyncio.run(main())\n\"\n```\n\nYou should see each tool name alongside its JSON Schema `inputSchema`\n\ndict.\n\n## Troubleshooting\n\n** ModuleNotFoundError: No module named 'mcp'** — Claude Desktop uses a clean shell; your virtualenv isn't activated. Confirm\n\n`\"command\"`\n\npoints to the venv interpreter: `/path/to/.venv/bin/python`\n\n, not the system `python`\n\n.**Tools don't appear in Claude Desktop** — Run `mcp dev server.py`\n\nfirst; import errors or missing env vars appear there immediately. Also check `~/Library/Logs/Claude/`\n\non macOS — Claude Desktop writes a per-server log file named after your server key (`internal-api`\n\n).\n\n** KeyError: 'API_BASE_URL'** — The\n\n`env`\n\nblock in `claude_desktop_config.json`\n\nreplaces the shell environment entirely; `load_dotenv()`\n\nwon't read your `.env`\n\nfrom there. Set all required keys explicitly in the JSON config.** httpx.ReadTimeout** — Your backend is slow. Raise\n\n`timeout=30.0`\n\n, or restructure long-running operations to return an `AsyncGenerator`\n\nand use `yield`\n\nto stream partial results back to the client.## Next Steps\n\n**Resources:** Expose read-only context (OpenAPI specs, internal wikis) via`@mcp.resource()`\n\nso the LLM can pull reference material without consuming tool-call budget.**HTTP/SSE transport:** For multi-user or remote deployments, replace`mcp.run()`\n\nwith`mcp.run(transport=\"sse\")`\n\nand mount it behind a secured reverse proxy; validate per-request tokens in middleware rather than a static env var.**Rate limiting:** Wrap`_auth_headers()`\n\nwith a token-bucket limiter (`aiolimiter`\n\nis async-native) to prevent an agentic loop from flooding your upstream API.**Richer schemas:** Replace`dict`\n\nreturn types with Pydantic models —`FastMCP`\n\ngenerates detailed JSON Schema from them, giving the model better guidance on what fields to expect and use.**Spec & SDK:**[modelcontextprotocol.io/docs](https://modelcontextprotocol.io/docs)and the[Python SDK on GitHub](https://github.com/modelcontextprotocol/python-sdk).\n\n[Mariana Souza](https://www.devclubhouse.com/u/mariana_souza)· Senior Editor\n\nMariana covers the fast-moving world of machine learning and generative AI, with a particular focus on how these technologies are reshaping development workflows. When she isn't stress-testing the latest foundation models, she's usually at a local hackathon.\n\n## Discussion 0\n\nNo comments yet\n\nBe the first to weigh in.", "url": "https://wpnews.pro/news/tutorial-python-mcp-internal-api-llm", "canonical_source": "https://www.devclubhouse.com/a/ship-an-mcp-server-in-python-that-exposes-your-internal-api-to-llms", "published_at": "2026-06-19 02:14:10+00:00", "updated_at": "2026-06-19 02:30:26.898227+00:00", "lang": "en", "topics": ["developer-tools", "large-language-models", "ai-tools"], "entities": ["FastMCP", "Claude Desktop", "Python", "MCP"], "alternates": {"html": "https://wpnews.pro/news/tutorial-python-mcp-internal-api-llm", "markdown": "https://wpnews.pro/news/tutorial-python-mcp-internal-api-llm.md", "text": "https://wpnews.pro/news/tutorial-python-mcp-internal-api-llm.txt", "jsonld": "https://wpnews.pro/news/tutorial-python-mcp-internal-api-llm.jsonld"}}