5 / 10
Guide Book Β· Chapter 5

MCP

One protocol between a host and a capability provider β€” servers, transports, the three primitives, and where the trust boundary sits.

The Model Context Protocol solves an NΓ—M problem. Without it, every agent needs a bespoke integration with every system. With it, a server implements the protocol once and every MCP-speaking host can use it.

text
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ host (Claude Code, an IDE, your app) ────────────┐
β”‚                                                               β”‚
β”‚   β”Œβ”€β”€ client ──┐   β”Œβ”€β”€ client ──┐   β”Œβ”€β”€ client ──┐            β”‚
β””β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
    β”‚            β”‚   β”‚            β”‚   β”‚            β”‚
  stdio        stdio            HTTP              HTTP
    β”‚            β”‚                β”‚                β”‚
 β”Œβ”€β”€β–Όβ”€β”€β”      β”Œβ”€β”€β–Όβ”€β”€β”          β”Œβ”€β”€β–Όβ”€β”€β”          β”Œβ”€β”€β–Όβ”€β”€β”
 β”‚ git β”‚      β”‚filesβ”‚          β”‚Linearβ”‚         β”‚ your β”‚
 β””β”€β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”€β”€β”˜          β””β”€β”€β”€β”€β”€β”˜          β””β”€β”€β”€β”€β”€β”˜

One host, many clients, one client per server. The model never talks to a server directly β€” the host does, and hands results to the model as tool results.

The three primitives

PrimitiveControlled byAnalogy
Toolsthe modela function the model may call
Resourcesthe host/usera file the host may read and attach
Promptsthe usera slash command the user may invoke
typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
import { z } from "zod"

const server = new McpServer({ name: "demo", version: "1.0.0" })

// A TOOL β€” the model decides to call it.
server.registerTool(
  "word_count",
  {
    title: "Word count",
    description: "Count the words in a piece of text. Use when the user asks how long something is.",
    inputSchema: { text: z.string().describe("The text to measure") },
  },
  async ({ text }) => ({
    content: [{ type: "text", text: `${text.trim().split(/\s+/).length} words` }],
  }),
)

const [clientSide, serverSide] = InMemoryTransport.createLinkedPair()
const client = new Client({ name: "guide", version: "1.0.0" })
await server.connect(serverSide)
await client.connect(clientSide)

const { tools } = await client.listTools()
console.log("tools:", tools.map((t) => t.name).join(", "))
console.log(JSON.stringify(await client.callTool({ name: "word_count", arguments: { text: "one two three" } })))

await client.close()

Resources and prompts

typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
import { z } from "zod"

const server = new McpServer({ name: "docs", version: "1.0.0" })

// A RESOURCE β€” the host reads it; the model does not choose to.
server.registerResource(
  "style-guide",
  "docs://style-guide",
  { title: "Style guide", description: "The team's writing conventions", mimeType: "text/markdown" },
  async (uri) => ({
    contents: [{ uri: uri.href, text: "# Style\n\n- British spelling\n- No exclamation marks\n" }],
  }),
)

// A PROMPT β€” the user invokes it, usually as a slash command.
server.registerPrompt(
  "review",
  {
    title: "Review a diff",
    description: "Review a diff against the team's checklist",
    argsSchema: { diff: z.string().describe("The unified diff to review") },
  },
  ({ diff }) => ({
    messages: [
      {
        role: "user",
        content: { type: "text", text: `Review this diff for correctness and style:\n\n${diff}` },
      },
    ],
  }),
)

const [c, s] = InMemoryTransport.createLinkedPair()
const client = new Client({ name: "guide", version: "1.0.0" })
await server.connect(s)
await client.connect(c)

console.log("resources:", (await client.listResources()).resources.map((r) => r.uri).join(", "))
console.log("prompts:  ", (await client.listPrompts()).prompts.map((p) => p.name).join(", "))

const read = await client.readResource({ uri: "docs://style-guide" })
console.log("resource body:", JSON.stringify(read.contents[0]))

await client.close()

Transports

typescript
const transports = [
  {
    name: "stdio",
    where: "a subprocess on the same machine",
    auth: "OS process permissions",
    use: "local tools β€” git, the filesystem, a database on localhost",
  },
  {
    name: "Streamable HTTP",
    where: "a remote server",
    auth: "OAuth 2.1, or a bearer token",
    use: "hosted services β€” an issue tracker, a SaaS API",
  },
]

for (const t of transports) {
  console.log(`${t.name}\n  runs: ${t.where}\n  auth: ${t.auth}\n  use:  ${t.use}\n`)
}

stdio is a child process speaking JSON-RPC over stdin/stdout β€” no network, no ports, and it inherits the user's local permissions. Streamable HTTP is for anything remote, and it needs real authentication and real authorisation.

Errors: protocol versus tool

typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
import { z } from "zod"

const server = new McpServer({ name: "demo", version: "1.0.0" })

server.registerTool(
  "divide",
  {
    title: "Divide",
    description: "Divide a by b.",
    inputSchema: { a: z.number(), b: z.number() },
  },
  async ({ a, b }) => {
    if (b === 0) {
      // A TOOL error: the model sees it and can recover.
      return { isError: true, content: [{ type: "text", text: "Cannot divide by zero. Pass a non-zero b." }] }
    }
    return { content: [{ type: "text", text: String(a / b) }] }
  },
)

const [c, s] = InMemoryTransport.createLinkedPair()
const client = new Client({ name: "guide", version: "1.0.0" })
await server.connect(s)
await client.connect(c)

console.log(JSON.stringify(await client.callTool({ name: "divide", arguments: { a: 10, b: 2 } })))
console.log(JSON.stringify(await client.callTool({ name: "divide", arguments: { a: 10, b: 0 } })))

// A PROTOCOL error: the call itself was invalid. It throws.
try {
  await client.callTool({ name: "does_not_exist", arguments: {} })
} catch (e) {
  console.log("protocol error:", (e as Error).message.slice(0, 60))
}

await client.close()

⚠️ The distinction matters. isError: true goes back to the model, which can read it and try something else. A thrown protocol error goes back to the host, which usually surfaces it to the user. A missing file is a tool error; a malformed request is a protocol error. Getting this backwards means either the model never learns what went wrong, or the user sees a stack trace for a routine condition.

Connecting a server to Claude Code

typescript
// .mcp.json in a project β€” checked in, so the whole team gets the same servers.
const config = {
  mcpServers: {
    filesystem: {
      command: "npx",
      args: ["-y", "@modelcontextprotocol/server-filesystem", "./src"],
    },
    linear: {
      type: "http",
      url: "https://mcp.linear.app/mcp",
    },
  },
}

console.log(JSON.stringify(config, null, 2))
console.log("\nscopes: local (just you) Β· project (.mcp.json, the team) Β· user (all your projects)")

Tools from a server appear to the model as mcp__<server>__<tool> β€” which is worth knowing when you are writing a permission rule or reading a transcript.

The trust boundary

typescript
type Source = { origin: string; trusted: boolean; note: string }

const sources: Source[] = [
  { origin: "the user, in the conversation", trusted: true, note: "the only source of instructions" },
  { origin: "an MCP tool result", trusted: false, note: "data β€” may contain text aimed at the model" },
  { origin: "an MCP resource", trusted: false, note: "data β€” the host chose to attach it, not to vouch for it" },
  { origin: "a tool DESCRIPTION from a third-party server", trusted: false, note: "loaded into context; a hostile server can write anything there" },
]

for (const s of sources) {
  console.log(`${s.trusted ? "TRUSTED  " : "UNTRUSTED"} ${s.origin.padEnd(42)} ${s.note}`)
}

That last row is the one people miss: installing a third-party MCP server puts its text into your context. Everything a server returns β€” and everything it declares β€” is untrusted input. The prompt injection chapter is about what follows from that.

Reference: Model Context Protocol and MCP in Claude Code.

main.ts
Loading editor…
No output yet
Press Run or βŒ˜β†΅ to compile and run your code.

Search

Jump to a lesson or a past run