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.
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:
-
stdio —
command,args,cwd, andenv. Theenvrule is worth knowing: supply it and the child gets your wholeprocess.envwith 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 inenvrather than assuming inheritance. -
HTTP and SSE —
url,headers, andauthorization.authorizationis a convenience that sets one header; anything else (an API key header, a tenant id) goes inheaders. -
Both —
timeoutin 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.cacheResourcesListandcachePromptsListdo 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 onbeforeExit,SIGTERM, andSIGINT, so a Ctrl-C does not leave annpxbehind.
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.