> Level 10 of Agent Harness Patterns, a track of patterns on how AI agents work. Web version: https://harnesspatterns.dev/patterns/mcp · All patterns: https://harnesspatterns.dev/llms.txt

# MCP

So far every tool was a function you wrote into your own agent. MCP is a standard plug: any program can offer tools through it, and any agent can use them.

## The problem

Every service your agent should reach (a calendar, a repository, a database, a browser) means writing a tool by hand: its schema, its code, its error handling. Then the next agent, or the next app, writes the same tool again. And when the service changes, every copy breaks on its own.

## The solution

The Model Context Protocol (MCP) separates the two sides. An **MCP server** is a program that offers tools; whoever builds the service writes it once. An **MCP client** lives inside the agent and speaks the protocol. They can run on the same machine, the server as a child process talking over stdin and stdout, or far apart, over HTTP. The conversation always has the same three steps:

- `initialize`
   The handshake: the client and the server say who they are and which version of the protocol they speak.
- `tools/list`
   The server describes its tools: name, description and input schema, the same three things from level 3.
- `tools/call`
   Each time the model asks for one of them, the client sends its name and arguments, and gets the result back.

The tools you get are ordinary tools. They join the list that goes with every request, the loop runs them like any other, and the model can’t tell where they came from. And because the plug is standard, the same server works in Claude Code, Codex, Cursor, VS Code or your own agent.

## The cast

Same cast as always, in Dream Land, with a new friend.

- **Kirby** (the MCP client): Part of your agent. One mouth that fits every server, and copies what each one can do.
- **The enemies** (MCP servers): Programs of their own, each at its own door. They go back there after Kirby copies them: they keep running.
- **The abilities** (tools/list): PARASOL and WHEEL: the tools each server described, now in the status bar the model reads.
- **The cables** (the protocol): The same kind of plug for every server. A tools/call runs down one, and its result runs back.
- **Astor** (the loop): Runs the loop as usual, and hands every MCP call to Kirby.
- **The Oracle** (the model): Picks from the toolbox by name and description, MCP or not.

## The code

**With astorlm:** `mountMcpServer()` connects, lists the tools and adapts them, named `<server>__<tool>` so two servers can’t clash. Its `tools` go into the agent like any other, and `close()` hangs up.

**From scratch:** a stdio client is a child process and JSON-RPC, one JSON object per line: the handshake, `tools/list`, and a function per tool that sends `tools/call`. The tools then go into the loop from level 2.

**With astorlm**

```ts
import { OpenAIProvider, createLocalAgent, mountMcpServer } from 'astorlm'

// 1. Mount each MCP server: connect, ask for its tools (tools/list), adapt them.
//    Each one is a separate program; nobody wrote its schemas in your code.
const weather = await mountMcpServer({
  name: 'weather', // becomes the prefix: weather__forecast, weather__alerts
  transport: { type: 'stdio', command: 'npx', args: ['-y', 'dreamland-weather-mcp'] }, // runs it as a child process
})

const maps = await mountMcpServer({
  name: 'maps',
  transport: { type: 'http', url: 'https://maps.dreamland.example/mcp' }, // or a server that's already running
})

const agent = await createLocalAgent({
  // Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
  provider: new OpenAIProvider({
    baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
    model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
    apiKey: 'YOUR_API_KEY', // local servers usually ignore it
  }),
  // 2. MCP tools are tools: mix them with your own, or keep only the ones you need.
  tools: [...weather.tools, ...maps.tools],
  maxTurns: 10,
})

agent.on('tool-start', ({ name }) => console.log('→', name)) // maps__route, then weather__forecast

try {
  await agent.run('Picnic at the Fountain of Dreams tomorrow. How do I get there from Cappy Town, and will it rain?')
} finally {
  // 3. Hang up when you're done: a stdio server is a process of yours.
  await Promise.all([weather.close(), maps.close()])
}
```

**TypeScript**

```ts
// An MCP client from scratch, over stdio. Node's standard library only, no SDK.
// MCP is JSON-RPC: one JSON object per line, requests with an id, replies with the same id.
import { spawn } from 'node:child_process'
import { createInterface } from 'node:readline'

type Tool = { name: string; description: string; inputSchema: object; run: (args: object) => Promise<string> }

// 1. Start the server as a child process, and pair each reply with its request by id.
async function connect(name: string, command: string, args: string[] = []): Promise<Tool[]> {
  const server = spawn(command, args, { stdio: ['pipe', 'pipe', 'inherit'] })
  const waiting = new Map<number, (result: any) => void>()
  let nextId = 1
  createInterface({ input: server.stdout }).on('line', (line) => {
    const message = JSON.parse(line)
    waiting.get(message.id)?.(message.result)
    waiting.delete(message.id)
  })
  const send = (message: object) => server.stdin.write(JSON.stringify({ jsonrpc: '2.0', ...message }) + '\n')
  const request = (method: string, params: object = {}) =>
    new Promise<any>((resolve) => {
      const id = nextId++
      waiting.set(id, resolve)
      send({ id, method, params })
    })

  // 2. The handshake: who we are, which protocol version we speak.
  await request('initialize', {
    protocolVersion: '2025-06-18',
    capabilities: {},
    clientInfo: { name: 'my-agent', version: '1.0.0' },
  })
  send({ method: 'notifications/initialized' })

  // 3. tools/list: the server describes its own tools. That's what the model will see.
  const { tools } = await request('tools/list')
  return tools.map((tool: { name: string; description: string; inputSchema: object }) => ({
    name: `${name}__${tool.name}`, // prefixed, so two servers can't clash
    description: tool.description,
    inputSchema: tool.inputSchema,
    // 4. tools/call: run it on the server, and turn its content blocks into text for the model.
    run: async (input: object) => {
      const result = await request('tools/call', { name: tool.name, arguments: input })
      return result.content.map((block: { text?: string }) => block.text ?? '').join('\n')
    },
  }))
}

// 5. Into the loop from level 2: one list of tools, wherever they came from.
const mcpTools = [...(await connect('weather', 'npx', ['-y', 'dreamland-weather-mcp'])), ...(await connect('maps', 'npx', ['-y', 'dreamland-maps-mcp']))]
const tools = Object.fromEntries(mcpTools.map((tool) => [tool.name, tool.run]))
const toolSchemas = mcpTools.map(({ name, description, inputSchema }) => ({ type: 'function', function: { name, description, parameters: inputSchema } }))
await runAgent('Picnic at the Fountain of Dreams tomorrow. How do I get there from Cappy Town, and will it rain?', tools, toolSchemas)
```

**Python**

```python
# An MCP client from scratch, over stdio. Standard library only, no SDK.
# MCP is JSON-RPC: one JSON object per line, requests with an id, replies with the same id.
import itertools
import json
import subprocess

class McpServer:
    # 1. Start the server as a child process. One request at a time keeps the pairing simple.
    def __init__(self, name, command):
        self.name = name
        self.process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
        self.ids = itertools.count(1)

    def send(self, message):
        self.process.stdin.write(json.dumps({"jsonrpc": "2.0", **message}) + "\n")
        self.process.stdin.flush()

    def request(self, method, params=None):
        request_id = next(self.ids)
        self.send({"id": request_id, "method": method, "params": params or {}})
        for line in self.process.stdout:  # skip anything that isn't our reply
            message = json.loads(line)
            if message.get("id") == request_id:
                return message["result"]

    # 2. The handshake, then tools/list: the server describes its own tools.
    def connect(self):
        self.request("initialize", {
            "protocolVersion": "2025-06-18",
            "capabilities": {},
            "clientInfo": {"name": "my-agent", "version": "1.0.0"},
        })
        self.send({"method": "notifications/initialized"})
        return self.request("tools/list")["tools"]

    # 3. tools/call: run it on the server, and turn its content blocks into text for the model.
    def call(self, tool, arguments):
        result = self.request("tools/call", {"name": tool, "arguments": arguments})
        return "\n".join(block.get("text", "") for block in result["content"])

    def close(self):
        self.process.terminate()

# 4. Into the loop from level 2: one list of tools, wherever they came from.
def remote(server, tool):
    return lambda **args: server.call(tool, args)

TOOLS, TOOL_SCHEMAS = {}, []
for server in [McpServer("weather", ["npx", "-y", "dreamland-weather-mcp"]), McpServer("maps", ["npx", "-y", "dreamland-maps-mcp"])]:
    for tool in server.connect():
        name = f"{server.name}__{tool['name']}"  # prefixed, so two servers can't clash
        TOOLS[name] = remote(server, tool["name"])
        TOOL_SCHEMAS.append({"type": "function", "function": {"name": name, "description": tool.get("description", ""), "parameters": tool["inputSchema"]}})

run_agent("Picnic at the Fountain of Dreams tomorrow. How do I get there from Cappy Town, and will it rain?")
```

## What to watch

- **Every tool rides in every request.** A server with forty tools adds forty schemas to each turn, and a long list makes picking the right one harder. Mount only the servers you need, and pass on only the tools you use: `weather.tools.filter(…)`.
- **A server is code you run.** A stdio server runs with your permissions, on your machine. Treat it like a dependency: install it from a source you trust, and pin its version.
- **Descriptions are instructions too.** The model reads every tool description as if you had written it, and every result as data that may carry orders. A server you don’t control can steer your agent through either (level 18).
- **Remote servers need credentials.** Over HTTP, pass a token in the headers, scoped to what the agent needs, never your own all-powerful key.
- **Close what you open.** A stdio server lives as long as its connection. Close it when the agent is done, or it keeps running in the background.

## Related patterns

- [3 · Designing a tool](https://harnesspatterns.dev/patterns/designing-a-tool.md)
- [11 · On-demand skills](https://harnesspatterns.dev/patterns/skills.md)
- [18 · Security and sandboxing](https://harnesspatterns.dev/patterns/security.md)
- [19 · Subagents](https://harnesspatterns.dev/patterns/subagents.md)
