Your agent needs tools from MCP servers. Some servers run on your machine. Some are remote and take an API key. Notion and Linear sign in with OAuth. mcp() connects each server by name, and gives the model the tools of each server.
This page connects your harness to MCP servers. To use your harness from an MCP client such as Claude Code, read Use a harness from any MCP client.
npm i @tanstack/ai-harness @tanstack/ai-mcppnpm add @tanstack/ai-harness @tanstack/ai-mcpyarn add @tanstack/ai-harness @tanstack/ai-mcpbun add @tanstack/ai-harness @tanstack/ai-mcpimport { defineHarness } from '@tanstack/ai-harness'
import { mcp } from '@tanstack/ai-mcp/harness'
import { openaiText } from '@tanstack/ai-openai'
// One time, outside `plugins`, so the sessions share the stdio server.
const tools = mcp({
servers: {
files: {
type: 'stdio',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', '.'],
},
docs: {
type: 'http',
url: 'https://mcp.example.com/mcp',
headers: { authorization: `Bearer ${process.env.DOCS_TOKEN}` },
},
linear: { type: 'http', url: 'https://mcp.linear.app/mcp', oauth: true },
},
})
export const assistant = defineHarness({
name: 'acme/assistant',
adapter: openaiText('gpt-6.1-sol'),
plugins: () => [tools],
})The name of a server is the prefix of its tools. A search tool of the docs server is docs_search for the model. To choose other names, see Rename the tools of a server.
The servers connect in the background when the session opens. The first turn waits for them. Each server connects on its own, so a server that fails does not stop the tools of the others.
/mcp shows each server and its status:
files: connected (14 tools)
docs: failed: Failed to connect to MCP server: Version negotiation probe failed: connection refused
linear: failed: Not signed in. Run /connect linear.In your own UI, read the same list from session.snapshot().plugins['tanstack/mcp'].servers. Each entry is an McpServerStatus:
A stdio server can crash, and a connection can close. Then /mcp shows files: reconnecting, and the turns run without the tools of that server. mcp() connects a server without oauth: true again:
An OAuth server, also one from mcpConnector, connects again at the next turn after its connection closes. After tools/list_changed, the next turn lists its tools again. If that list fails, the old list stays.
Some servers change their tools while they run. When a server sends tools/list_changed, the next turn lists the tools of that server again, and /mcp shows the new count.
Before sign-in, the model knows that Linear is not connected. If the user asks for Linear, the model tells them to run /connect linear.
The sign-in follows the MCP authorization spec:
Sometimes the server no longer accepts the sign-in, for example after the user removes the app in Linear. Then the turn runs without the Linear tools, and clients get a harness.auth_required event. The CLI prints Sign in to linear. Run /connect linear., and the model tells the user to run /connect linear. If the server refuses the saved refresh token, the connector deletes the saved sign-in. It keeps a newer token that another session saved after this session read its own.
When a token expires, the MCP SDK of the session refreshes it. The connector saves the new token with credentials.refresh:
The model never sees the token. To keep sign-ins after a restart, give the host a credential store. Auth and connectors shows one. With oauth: true, headers and authProvider do not apply.
Set needsApproval on a server to change this. true asks before every tool of the server. A function gets the MCP tool and returns true to ask:
export const careful = mcp({
servers: {
files: {
type: 'stdio',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', '.'],
// Ask before each tool that the server does not mark read-only.
needsApproval: (tool) => tool.annotations?.readOnlyHint !== true,
},
docs: { type: 'http', url: 'https://mcp.example.com/mcp', needsApproval: true },
},
})Two servers can give a tool with the same name. Set toolName on a server to choose the names that the model sees. It gets the MCP tool and returns the name:
export const renamed = mcp({
servers: {
docs: {
type: 'http',
url: 'https://mcp.example.com/mcp',
toolName: (tool) => `docs_v2_${tool.name}`,
},
},
})When two servers still give the same tool name, the model gets the tool of the server that comes first in servers. mcp() drops the other tool. It writes one warning with the names of both servers to the host logger (the console by default).
To treat a clash as a config error, set onDuplicateTool: 'error'. Then the later server shows failed with an error that names both servers and the tool, and the model gets none of its tools. The other servers keep working:
export const strict = mcp({
onDuplicateTool: 'error',
servers: {
docs: { type: 'http', url: 'https://mcp.example.com/mcp' },
},
})An MCP server can ask the user for input in the middle of a tool call. MCP calls this elicitation. The session asks the user, and the tool call waits for the answer.
Answer with session.answer({ questionId, value }), or from a HarnessClient. The value tells the server what the user did:
| Value | The server gets |
|---|---|
| The form content, for example { city: 'Paris' } | accept with that content |
| Any other value, for a URL request | accept |
| { action: 'decline' } | decline |
| { action: 'cancel' } | cancel |
session.onQuestion calls you the moment a server asks. This code answers every question with a city:
import type { HarnessSession } from '@tanstack/ai-harness'
export function answerWithCity(session: HarnessSession) {
// Call the returned function to stop answering.
return session.onQuestion(({ question }) =>
session.answer({
questionId: question.questionId,
value: { city: 'Paris' },
}),
)
}Open questions also stay in session.snapshot().pendingWaits, so a client that connects later still sees them.
timeoutMs limits how long one server can take to connect, to send its tool list, and to answer each tool call:
export const quick = mcp({
servers: {
docs: { type: 'http', url: 'https://mcp.example.com/mcp', timeoutMs: 10_000 },
},
})An http server that does not connect in time shows this in /mcp:
docs: failed: Failed to connect to MCP server: Version negotiation probe timed out after 10000msmcpConnector adds one OAuth server, with options that mcp() does not have. The sign-in works as in step 3:
import { mcpConnector } from '@tanstack/ai-mcp/connector'
export const linearWithoutWrites = mcpConnector({
id: 'linear',
label: 'Linear',
url: 'https://mcp.linear.app/mcp',
// Ask before every tool whose name starts with "save".
needsApproval: (tool) => tool.name.startsWith('save'),
})Add it to the plugins like any plugin: plugins: () => [linearWithoutWrites]. The CLI then prints Connected to Linear., with the label.
A plugin can give tools that it finds at run time, like the tools of a server that the user signed in to after the session started. Return them from discoverTools. The session asks each plugin before every turn:
import { definePlugin } from '@tanstack/ai-harness'
import type { AnyTool } from '@tanstack/ai'
const found: Array<AnyTool> = []
export const lateTools = definePlugin({
name: 'acme/late-tools',
setup: () => ({
discoverTools: () => found,
}),
})