Headless Agents: Deploy Skills as MCP Servers β Full Guide from Concept to Three Clients
The Trapped Skill Problem: When a Great LLM Tool Can't Leave the IDE It Was Born In
Build an MCP server once and call it from Cursor, Claude Desktop, and VS Code without rewrites β this guide takes you from a single Python function to a containerized, authenticated, three-client deployment in 11 concrete steps.
π― System Scenario & Requirements
System Goal
Build an MCP server once and call it from Cursor, Claude Desktop, and VS Code without rewrites β this guide takes you from a single Python function to a containerized, authenticated, three-client deployment in 11 concrete steps.
Actors & Components
- Application or API tier
- Core service under design
- Operator or SRE observer
Functional Requirements
- TLDR: Build an MCP server once and call it from Cursor, Claude Desktop, and VS Code without rewrites β this guide takes you from a single Python function to a containerized, authenticated, three-client deployment in 11β¦
- You spent an afternoon building a beautiful skill inside GitHub Copilot CLI: given a repository URL, it summarises the codebase, identifies the top changed files, and drafts a pull-request description.
- It works every time you run it.
Constraints & Assumptions
- Every cross-client MCP deployment surfaces failure modes that do not appear in local testing.
- The table below covers the six most common, with the exact symptom you will see, the underlying cause, and the fix: The most dangerous failure is the last one.
β‘ Execution Stages & Decision Trace
Stage 1 β MCP Fundamentals: Protocol, Transports, and the Server Lifecycle
MCP has three moving parts: a client (the AI assistant β Cursor, Claude Desktop, Copilot), a server (your Python process exposing tools), and a transport (the channel that connects them).
The protocol is intentionally thin.
Deep Dive
Scenario context
Build an MCP server once and call it from Cursor, Claude Desktop, and VS Code without rewrites β this guide takes you from a single Python function to a containerized, authenticated, three-client deployment in 11 concrete steps.
You spent an afternoon building a beautiful skill inside GitHub Copilot CLI: given a repository URL, it summarises the codebase, identifies the top changed files, and drafts a pull-reqβ¦
This walkthrough focuses on The Trapped Skill Problem: When a Great LLM Tool Can't Leave the IDE It Was Born In.
Before making a design choice, clarify:
- You spent an afternoon building a beautiful skill inside GitHub Copilot CLI: given a repository URL, it summarises the codebase, identifies the top changed files, and drafts a pull-request description.
- It works every time you run it.
- Then your teammate on Cursor asks if they can use it.
Concepts in play: llm-agents-and-langgraph, system-design-hld-architectures, jvm-internals-and-garbage-collection, low-level-design-systems
Stage 1 β MCP Fundamentals: Protocol, Transports, and the Server Lifecycle
- MCP has three moving parts: a client (the AI assistant β Cursor, Claude Desktop, Copilot), a server (your Python process exposing tools), and a transport (the channel that connects them).
- The protocol is intentionally thin.
- At its core, MCP defines: - Tool registration β the server advertises a list of callable tools with JSON Schema-typed parameters.
- Resource registration β the server can expose read-only data sources (files, database rows, API responses) the client can fetch.
Observable outcome: - Resource registration β the server can expose read-only data sources (files, database rows, API responses) the client can fetch.
Stage 2 β Building Your MCP Server: From Empty Directory to Locally Tested Tool
- These four steps take you from nothing to a fully verified local MCP server.
- Every subsequent section builds on this foundation.
- Step 1 β Set Up the Project Structure Create the directory layout and install dependencies: Your directory should look like this: The pyproject.toml declares the package and its runtime dependencies: Step 2 β Write theβ¦
- You declare tools using two decorators: @server.list tools() for the capability announcement and @server.call tool() for the dispatcher.
mkdir mcp-pr-summarizer && cd mcp-pr-summarizer
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install "mcp>=1.0" fastmcp openai
mcp-pr-summarizer/
βββ server.py
βββ pyproject.toml
βββ Dockerfile
Observable outcome: You declare tools using two decorators: @server.list tools() for the capability announcement and @server.call tool() for the dispatcher.
Stage 3 β Deep Dive: How MCP Routes Messages Under the Hood
- The Internals: JSON-RPC 2.0, Capability Negotiation, and Message Framing MCP's wire format is JSON-RPC 2.0 β the same protocol powering Language Server Protocol (LSP).
- Every message has three fields: jsonrpc: "2.0" , a method string, and either params (for requests) or result / error (for responses).
- The initialization handshake is a two-step exchange: 1.
- The client sends initialize with its own capabilities (protocol version, supported features).
flowchart TD
Task["Task Type"]
Changing{Requires frequent
knowledge updates?}
RAG["Use RAG
(Dynamic retrieval)"]
Finetune["Use Fine-tuning
(Static weights)"]
Task --> Changing
Changing -->|Yes| RAG
Changing -->|No| Finetune
Observable outcome: The client sends initialize with its own capabilities (protocol version, supported features).
Stage 4 β From Local to Headless: HTTP+SSE Transport, Auth Middleware, and Docker
- With a locally tested server in hand, these three steps lift it into a shared deployment that any client on any machine can reach.
- The call flow diagram at the end of this section shows how both transports converge at the same handler.
- Step 5 β Switch to HTTP+SSE Transport Replace the stdio server entrypoint with SSE transport.
- The tool handlers are unchanged β only main() changes: Test it immediately with curl before adding auth: Step 6 β Add Bearer Token Authentication Wrap the SSE route with a Starlette middleware that checks the Authorizatβ¦
# server.py β updated main() for SSE
import uvicorn
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.routing import Route
sse = SseServerTransport("/messages/")
async def handle_sse(request):
async with sse.connect_sse(request.scope, request.receive, request._send) as streams:
await server.run(*streams, server.create_initialization_options())
starlette_app = Starlette(routes=[Route("/sse", endpoint=handle_sse)])
if __name__ == "__main__":
uvicorn.run(starlette_app, host="0.0.0.0", port=8080)
python server.py &
curl -N http://localhost:8080/sse
# Should emit: data: {"type":"endpoint","uri":"/messages/?session_id=..."}
Observable outcome: The tool handlers are unchanged β only main() changes: Test it immediately with curl before adding auth: Step 6 β Add Bearer Token Authentication Wrap the SSE route with a Starlette middleware that checks the Authorizatβ¦
Stage 5 β Complete Worked Example: The Repo Summarizer From One Python File to Three Live Clients
- Here is the complete repo summarizer skill as a single deployable file, callable from Cursor, Claude Desktop, and GitHub Copilot simultaneously.
- This is the production-ready version combining everything from the preceding steps.
- The Complete Server File Dockerfile for Headless Deployment Deploy to Railway with railway up .
- The resulting URL ( https://repo-summarizer.railway.app ) is the endpoint you register in each client config.
# repo_summarizer_server.py
import asyncio
import os
from mcp.server import Server
from mcp.server.sse import SseServerTransport
from mcp import types
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData, INTERNAL_ERROR, INVALID_PARAMS
import httpx
app = Server("repo-summarizer")
@app.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(
name="summarize_repo",
description="Fetch recent commits and open PRs for a GitHub repo and return a structured summary.",
inputSchema={
"type": "object",
"properties": {
"repo": {"type": "string", "description": "owner/repo (e.g. anthropics/mcp)"},
"days": {"type": "integer", "default": 7, "description": "Lookback window in days"}
},
"required": ["repo"]
}
)
]
@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
if name != "summarize_repo":
raise McpError(ErrorData(code=INVALID_PARAMS, message=f"Unknown tool: {name}"))
repo = arguments.get("repo", "").strip()
if not repo or "/" not in repo:
raise McpError(ErrorData(code=INVALID_PARAMS, message="repo must be in owner/repo format"))
days = arguments.get("days", 7)
token = os.environ.get("GITHUB_TOKEN", "")
headers = {"Authorization": f"Bearer {token}"} if token else {}
try:
async with httpx.AsyncClient(headers=headers) as client:
commits_resp = await client.get(
f"https://api.github.com/repos/{repo}/commits",
params={"per_page": 20}
)
prs_resp = await client.get(
f"https://api.github.com/repos/{repo}/pulls",
params={"state": "open", "per_page": 10}
)
commits = [c["commit"]["message"].split("\n")[0] for c in commits_resp.json()[:10]]
prs = [f"#{p['number']}: {p['title']}" for p in prs_resp.json()[:5]]
summary = (
f"## {repo} β last {days} days\n\n"
f"**Recent commits ({len(commits)}):**\n"
+ "\n".join(f"- {c}" for c in commits)
+ f"\n\n**Open PRs ({len(prs)}):**\n"
+ "\n".join(f"- {p}" for p in prs)
)
return [types.TextContent(type="text", text=summary)]
except Exception as exc:
raise McpError(ErrorData(code=INTERNAL_ERROR, message=f"GitHub API call failed: {exc}")) from exc
FROM python:3.12-slim AS builder
WORKDIR /app
COPY pyproject.toml .
RUN pip install --no-cache-dir "mcp>=1.0" httpx uvicorn starlette
FROM python:3.12-slim AS runtime
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
COPY repo_summarizer_server.py .
EXPOSE 8080
CMD ["python", "repo_summarizer_server.py"]
Observable outcome: The resulting URL ( https://repo-summarizer.railway.app ) is the endpoint you register in each client config.
Stage 6 β FastMCP and MCP Inspector: Two Tools That Save Hours Every Week
- FastMCP: Type Hints as JSON Schema The raw mcp SDK is explicit and flexible, but its decorator pattern can feel ceremonial for small servers.
- FastMCP ( jlowin/fastmcp ) provides a @mcp.tool() decorator that mirrors FastAPI's ergonomics β Python type hints become the JSON Schema automatically, and you skip the list tools / call tool split entirely.
- The same repo summarizer in FastMCP: FastMCP extracts the function signature, docstring, and type hints to build the full JSON Schema automatically.
- The mcp.run() call defaults to stdio but accepts a transport="sse" argument for HTTP deployment.
# fast_server.py
from fastmcp import FastMCP
import httpx, os
mcp = FastMCP("repo-summarizer")
@mcp.tool()
async def summarize_repo(repo: str, days: int = 7) -> str:
"""Fetch recent commits and open PRs for a GitHub repository and return a structured summary."""
token = os.environ.get("GITHUB_TOKEN", "")
headers = {"Authorization": f"Bearer {token}"} if token else {}
async with httpx.AsyncClient(headers=headers) as client:
commits = (await client.get(
f"https://api.github.com/repos/{repo}/commits",
params={"per_page": 20}, headers=headers
)).json()
prs = (await client.get(
f"https://api.github.com/repos/{repo}/pulls",
params={"state": "open", "per_page": 10}, headers=headers
)).json()
lines = [f"## {repo}", "", "**Commits:**"]
lines += [f"- {c['commit']['message'].split(chr(10))[0]}" for c in commits[:10]]
lines += ["", "**Open PRs:**"]
lines += [f"- #{p['number']}: {p['title']}" for p in prs[:5]]
return "\n".join(lines)
if __name__ == "__main__":
mcp.run()
flowchart TD
Pretrained["Pretrained
Model
(Frozen)"]
LoRA["LoRA Adapter
(Trainable)"]
Finetune["Fine-tune on
Custom Data"]
Result["Fine-tuned
Model"]
Pretrained --> LoRA
LoRA --> Finetune
Finetune --> Result
Observable outcome: The mcp.run() call defaults to stdio but accepts a transport="sse" argument for HTTP deployment.
End-to-end outcome
After working through the stages, you should be able to explain:
- Write the tool description before the implementation.
- The description field is the single most important field in your schema β not for humans, but for the LLM that routes calls to your tool.
Alternatives considered
With the server running β either locally via stdio or in Docker via SSE β these three steps write the exact config entries needed for each client. The diagram below maps the full journey from Steps 1β11 so you can see where you are in the process.
The decision diamond is the key branch: stdio registration skips the Docker steps entirely and goes straight to config files.
Step 8 β Register in Claude Desktop
Claude Desktop reads its server registry from a JSON config file in the OS application support directory.
Step 9 β Register in Cursor
Cursor reads MCP config from ~/.cursor/mcp.json in your home directory (global, applies to all projects) or from .cursor/mcp.json in your project root (workspace-scoped, only that project).
Step 10 β Register in VS Code and GitHub Copilot Agent Mode
VS Code reads MCP config from .vscode/mcp.json in your workspace root.
Decision Guide: stdio, SSE, or Container β Choosing Once and Choosing Right
The simplest production pattern for a small team: one Railway or Fly.io container running SSE on port 8080, bearer token authentication via environment variable, and a shared config snippet that each developer pastes into their client config file.
Failure and degraded modes
Trade-offs and Failure Modes: What Breaks When You Register Across Three Clients
- Detection: Every cross-client MCP deployment surfaces failure modes that do not appear in local testing.
- User or operator impact: Every cross-client MCP deployment surfaces failure modes that do not appear in local testing.
