Skip to main content
Articles/ai agents/headless-agents-deploying-skills-as-mcp-server
Series: Model Context Protocol (MCP)β€’Part 2 of 3
Scenario Article

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

Abstract Algorithmsβ€’Mar 28, 2026
XLinkedInReddit
πŸ’‘ Executive Summary

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

1

Stage 1 β€” MCP Fundamentals: Protocol, Transports, and the Server Lifecycle

Action / Decision:

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).

Component Behavior:

The protocol is intentionally thin.

Observable Output: At its core, MCP defines: - Tool registration β€” the server advertises a list of callable tools with JSON Schema-typed parameters.
Read Full Stage in Deep Dive ↓
πŸ“–

Deep Dive

Detailed Architectural Analysis

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.

Share this case study

Discuss architecture trade-offs and lessons with your team.

Share:XLinkedInReddit