Agent Development Kit · Build

MCP: tools you didn't write

An MCP server is a bag of tools someone else already shipped — a filesystem, a GitHub, your own internal service. app.mcp.server(config) declares one; putting it in an agent's tools turns every tool it advertises into an ADK tool the model can call. This chapter runs the declaration, the filtering, and the lifecycle here in the page, and is exact about the one part a browser tab cannot do: talk to a real server.

Builds on · the Tools chapter Needs · nothing (no API key) Surface · app.mcp

Step 1

Declaring a server spawns nothing

app.mcp.server(config) registers a server on the app and hands you a handle. It does not spawn a process, open a socket, or list a single tool. Most of this page runs here precisely because declaring is inert — the connection is deferred until an agent actually needs the tools, and section 6 is where that deferral comes due.

import { adk } from '@animahealth/adk'

const app = adk({ name: 'mcp-tour' })

// stdio: the ADK spawns this command and speaks MCP over its stdin/stdout.
const files = app.mcp.server({
  name: 'files',
  command: 'npx',
  args: ['-y', '@modelcontextprotocol/server-filesystem', '/workspace'],
  cacheToolsList: true,
})

// HTTP: no child process, a URL. `authorization` is the raw token — the client sends the
// `Authorization: Bearer <token>` header for you, so do not write the word Bearer yourself.
const issues = app.mcp.server({
  name: 'issues',
  url: 'https://example.com/mcp',
  authorization: 'a-token-that-is-not-real',
  transport: 'http',
})

const declared = {
  kind: files.kind,
  name: files.name,
  connected: files.isConnected(),
  state: files.getState(),
  registered: app.mcp.servers().map((server) => server.name),
  sameHandleTwice: app.mcp.get('issues') === issues,
}

declared

Three things to take from that object. A server's kind is 'mcp_server', which is what lets it sit in an agent's tools array beside your own functions. Its state starts disconnected with no toolCount, because nothing has been listed yet. And registration is keyed by name: declaring the same name twice returns the handle you already have, so module-level declaration in two files does not give you two child processes.

A config must carry exactly one of command and url. Neither or both is a build-time throw, not a surprise at first use.

const rejected = ['neither', 'both'].map((shape) => {
  try {
    app.mcp.server(
      shape === 'neither'
        ? { name: `bad-${shape}` }
        : { name: `bad-${shape}`, command: 'npx', url: 'https://example.com/mcp' },
    )
    return { shape, error: null }
  } catch (error) {
    return { shape, error: error instanceof Error ? error.message : String(error) }
  }
})

rejected

Step 2

stdio, HTTP, SSE — and how one is chosen

The transport is decided by the config, not by a switch you flip. command means stdio, always. url means a network transport, and then transport picks between 'http' (streamable HTTP) and 'sse'. Omit transport with a url and the client tries HTTP first, falls back to SSE, and — if both fail — reports the HTTP error, because that is the one you probably meant to succeed.

The fields each side reads:

  • stdiocommand, args, cwd, and env. The env rule is worth knowing: supply it and the child gets your whole process.env with your entries merged over it; omit it and the ADK passes nothing, leaving the transport to pick its own default environment. If a server needs a token, put the token in env rather than assuming inheritance.
  • HTTP and SSEurl, headers, and authorization. authorization is a convenience that sets one header; anything else (an API key header, a tenant id) goes in headers.
  • Bothtimeout in milliseconds, defaulting to 30 000. It is per operation, not per server: connecting, listing tools, and each tool call get their own budget.

Step 3

What the model ends up seeing

An MCP server goes in tools, exactly where a function tool goes. It stays a server there — the array holds one entry, not thirty — and it is expanded into real tools once per turn, before the first model call.

import { isMCPTool, isFunctionTool } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { z } from 'zod'

const recordDecision = app.tool({
  name: 'record_decision',
  description: 'Write a decision to the audit log',
  schema: z.object({ note: z.string() }),
  execute: (ctx) => ({ recorded: ctx.args.note }),
})

const librarian = app.agent({
  name: 'librarian',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('Read the files before answering.'), app.context.history()],
  tools: [files, recordDecision],
})

// Three kinds can sit in `tools`, and the ADK tells them apart by shape, not by registration.
const declaredTools = librarian.tools.map((tool) => {
  if (isMCPTool(tool)) return { from: 'mcp server', name: tool.name }
  if (isFunctionTool(tool)) return { from: 'app.tool', name: tool.name }
  return { from: 'provider', name: tool.type }
})

declaredTools

At expansion time each tool the server advertises becomes an ordinary FunctionTool, and three transformations happen to it. Its name is prefixed mcp_<server>_<tool> — which is what keeps two servers' identically named tools apart, and the reason to keep the server name short, since it rides on every tool name the model reads. Its description passes through unchanged, or becomes MCP tool: <name> if the server offered none. And its JSON Schema is converted to a Zod schema: required properties stay required, optional ones become nullable and optional, description survives as .describe(), and the object is .strict() so an invented argument is rejected at the border.

So a server advertising this — the MCP wire format, not ADK —

{
  name: 'read_file',
  description: 'Read the complete contents of a file',
  inputSchema: {
    type: 'object',
    properties: {
      path: { type: 'string', description: 'Absolute path to the file' },
      encoding: { type: 'string' },
    },
    required: ['path'],
  },
}

reaches the model as this:

{
  name: 'mcp_files_read_file',
  description: 'Read the complete contents of a file',
  schema: z
    .object({
      path: z.string().describe('Absolute path to the file'),
      encoding: z.string().nullable().optional(),
    })
    .strict(),
}

When the model calls it, the arguments cross that schema, the client calls the server, and the reply is unwrapped before the model sees it. A single text block is parsed as JSON when it parses and handed over as a string when it does not; a single non-text block is passed through as-is; several blocks arrive as an array; none at all becomes { success: true, message: 'Operation completed with no output' }. If the server answers with isError, the tool throws with the server's own text — and from there it is the ordinary tool failure contract: a tool_result carrying an error, read by the model, run intact.

Step 4

Only these, never those

A general-purpose MCP server hands you everything it has, including the destructive half. Two filters trim it, and they compose: server.only([…]) keeps a list, server.exclude([…]) drops one. Both return a new server sharing the original's connection, so filtering costs no extra process. The same lists can be given up front as includeTools and excludeTools in the config.

const readOnly = files.only(['read_file', 'list_directory', 'search_files'])
const noDelete = files.exclude(['delete_file', 'move_file'])

const filtering = {
  derivedAreNewHandles: readOnly !== files && noDelete !== files,
  registryUnchanged: app.mcp.servers().map((server) => server.name),
  configIsTheOriginalObject: readOnly.config === files.config,
  configIncludeTools: readOnly.config.includeTools ?? null,
}

filtering

Note the last two lines: a derived server keeps the config object you passed to app.mcp.server, so .config.includeTools is not where .only() writes its list. Read the filter from the code that built the handle, not from the config. The rules themselves are short — .only() replaces the keep-list, .exclude() adds to the drop-list, and exclusion is applied last, so a name in both lists is dropped.

Filtering is client-side: the server is still asked for its full inventory and the trimming happens before the tools are built. It shapes what the model can reach, and it is the right first move on any server you did not write. It is not a permission boundary — the credentials you handed the server still are. For a genuine confirmation step, wrap the risky operation in your own tool with a yieldSchema and let a human answer; that is Stopping to ask.

Step 5

Resources and prompts are context, not tools

MCP servers also publish resources (documents addressed by URI) and prompts (named, parameterised message templates). Neither is something a model calls. Both become context renderers, so they go in an agent's context array alongside app.context.system and app.context.history.

const readme = files.resource('file:///workspace/README.md')
const houseStyle = files.prompt('code-review', { language: 'typescript' })

const reviewer = app.agent({
  name: 'reviewer',
  model: openai('gpt-5.6-luna'),
  context: [
    app.context.system('Review the diff against the project README.'),
    readme,
    houseStyle,
    app.context.history(),
  ],
  tools: [readOnly],
})

const contextShape = {
  renderers: reviewer.context.length,
  resourceIsARenderer: typeof readme === 'function',
  promptIsARenderer: typeof houseStyle === 'function',
}

contextShape

What they render is worth knowing exactly. A resource is fetched and appended as one system event reading [Resource: <uri>] then the text — so it is text or nothing: a binary resource has no text and contributes nothing. A prompt is fetched and its messages are appended as user and assistant events, which is how a server ships a worked example rather than an instruction.

And both fail open. If the fetch throws, the renderer logs a warning and returns the context untouched; the turn proceeds without that document. This is the opposite of the tools path, which fails the turn — the asymmetry in the next section — and it means a missing resource degrades an agent quietly. If a document is load-bearing, read it yourself with await server.readResource(uri) and decide what to do when it is not there.

The lower-level pair sits underneath: server.readResource(uri) returns { uri, mimeType, text, data } (data being a decoded Buffer for a blob), and server.getPrompt(name, args) returns { messages }. server.resourceDefinitions() and server.promptDefinitions() list what a server offers, the way server.toolDefinitions() lists its tools.

Step 6

Connect, fail, reconnect, close

Connection is lazy: the first turn of an agent carrying an MCP server connects it, because expanding the server into tools has to list them. await app.mcp.connect() moves that cost to startup, warming every registered server at once — and it never throws. Failures are collected, warned about, and swallowed, so one dead server cannot stop your process from booting. The state is where you find out.

Run the cell. It takes a few seconds — a connection is attempted three times with randomized backoff before it is given up on — and then both servers report the same thing, because this page is a browser tab: there is no process to spawn npx in and no @modelcontextprotocol/sdk to load. That is the honest answer here, and it is the exact shape you would read on your own machine when a server is misconfigured.

await app.mcp.connect()

const afterWarmup = app.mcp.servers().map((server) => ({
  name: server.name,
  ...server.getState(),
}))

afterWarmup

Now the asymmetry that matters in production. app.mcp.connect() swallowing a failure does not make the failure harmless — it defers it. The tools are needed at the top of a turn, and if the server cannot be reached then, the connection error is thrown out of the run. Not a tool_result with an error; a rejected promise from app.run. The same agent, the same dead server — and the same few seconds, because the failed warm-up left nothing cached to reuse:

import { runTest, user, model } from '@animahealth/adk/testing'

let turnFailure = ''
try {
  await runTest(librarian, [user('What is in the README?'), model('It is a project readme.')])
} catch (error) {
  turnFailure = error instanceof Error ? error.message : String(error)
}

turnFailure

So: a warm-up call tells you whether a server is reachable, and server.getState() or await server.healthCheck() tells you which one is not. Check them at boot. An agent whose MCP server is down does not degrade — it stops.

Once connected, the client keeps itself alive without your help:

  • Reconnect. An operation that fails with a connection-shaped error — EPIPE, ECONNRESET, ECONNREFUSED, or a message about being closed, disconnected, or not connected — reconnects and retries once. Other errors are yours to see.
  • Caching. Tools are listed once per turn unless you set cacheToolsList: true, which reuses the first listing for the life of the connection. On a stdio server that is a round-trip to a child process on every turn, so set it — and know it is a real cache: a server that gains a tool while connected will not be noticed until it reconnects. cacheResourcesList and cachePromptsList do the same for the other two listings.
  • Shutdown. await app.mcp.disconnect() closes every registered server and clears its caches; await app.close() does that and closes the session store. Child processes are also cleaned up on beforeExit, SIGTERM, and SIGINT, so a Ctrl-C does not leave an npx behind.

Step 7

The whole thing, on your machine

Everything above ran here. This last piece cannot: it needs a Node process, an OpenAI key, and one peer dependency — npm install @animahealth/adk @modelcontextprotocol/sdk. The MCP SDK is an optional peer, which is why importing the ADK does not drag it in, and why every failure in this page's cells says it is missing. Copy this into a file and run it.

import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'

const app = adk({ name: 'file-reader' })

const files = app.mcp.server({
  name: 'files',
  command: 'npx',
  args: ['-y', '@modelcontextprotocol/server-filesystem', process.cwd()],
  cacheToolsList: true,
})

const agent = app.agent({
  name: 'librarian',
  model: openai('gpt-5-mini'),
  context: [
    app.context.system('Answer from the files. Read before you answer.'),
    app.context.history(),
  ],
  tools: [files.only(['read_file', 'list_directory', 'search_files'])],
})

// Warm up first, so a broken server is a startup error and not a mid-conversation one.
await app.mcp.connect()
console.log(files.getState())
console.log((await files.toolDefinitions()).map((tool) => tool.name))

const run = await app.run(agent, 'What does this project do? Read the README.')
console.log(run.output.text)

await app.close()

That is the whole surface. Declare a server, filter it down to the tools you want the model reaching for, warm it at boot, close it on the way out — and the rest of the ADK cannot tell the difference between those tools and the ones you wrote by hand.