Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/ai-mcp-harness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@tanstack/ai-mcp': minor
---

Use MCP in a TanStack AI harness.

- `@tanstack/ai-mcp/harness` adds `createHarnessMcpServer`. It serves a harness as an MCP server, so Claude Code, Claude Desktop, Cursor, or another agent can use it. The tools are `chat`, `steer`, `cancel`, `approve`, `reject`, `resolve`, `answer`, and `status`, plus `agent_<name>` for each exposed agent and `command_<name>` for each exposed command. Every tool takes an optional `threadId`. With `approvals: 'ask'` (the default), the client asks the user about each approval when it supports elicitation. Otherwise the approvals come back in the result. `approvals: 'auto'` approves every tool call. `resolve` answers every kind of interrupt in one call.
- `@tanstack/ai-mcp/harness` also adds the `mcp()` harness plugin. It connects stdio and HTTP servers by name, gives the model their tools as `<server>_<tool>`, and adds a `/mcp` status command. `oauth: true` signs in to a server with `/connect <name>`.
- `@tanstack/ai-mcp/connector` adds `mcpConnector`: one OAuth MCP server as a harness plugin, with `needsApproval`, `prefix`, `scopes`, and `clientName`. The sign-in passes the `iss` of the callback on (RFC 9207).
- `mcp()` and `mcpConnector` answer the `elicitation/create` requests of MCP spec 2025 servers, and the input requests of spec 2026-07-28 servers, as session questions. One tool call can ask up to 5 times.

`@tanstack/ai-harness` is an optional peer dependency. Only the `./harness` and `./connector` entries need it.
10 changes: 10 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,16 @@
"label": "Run agents from a harness",
"to": "harness/subagents",
"addedAt": "2026-10-13"
},
{
"label": "Use MCP servers",
"to": "harness/mcp",
"addedAt": "2026-10-13"
},
{
"label": "Use from any MCP client",
"to": "harness/mcp-server",
"addedAt": "2026-10-13"
}
]
},
Expand Down
243 changes: 243 additions & 0 deletions docs/harness/mcp-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
---
title: Use a harness from any MCP client
id: harness-mcp-server
order: 9
description: "Serve your harness as an MCP server. Chat with it, answer its approvals, and run its agents and commands from Claude Code, Claude Desktop, Cursor, or another agent."
keywords:
- tanstack ai
- harness
- mcp
- mcp server
- claude code
- cursor
---

You built a harness, but you do your work in Claude Code, Cursor, or another agent. You want to give work to your harness from there. These clients speak MCP. `createHarnessMcpServer` serves your harness as an MCP server. The client can then chat with the harness, answer its approvals, and run its agents and commands.

## 1. Install the MCP package

<!-- ::start:tabs variant="package-manager" mode="install" -->

react: @tanstack/ai-harness @tanstack/ai-mcp @tanstack/ai-persistence
vue: @tanstack/ai-harness @tanstack/ai-mcp @tanstack/ai-persistence
solid: @tanstack/ai-harness @tanstack/ai-mcp @tanstack/ai-persistence
svelte: @tanstack/ai-harness @tanstack/ai-mcp @tanstack/ai-persistence
preact: @tanstack/ai-harness @tanstack/ai-mcp @tanstack/ai-persistence
angular: @tanstack/ai-harness @tanstack/ai-mcp @tanstack/ai-persistence
octane: @tanstack/ai-harness @tanstack/ai-mcp @tanstack/ai-persistence
vanilla: @tanstack/ai-harness @tanstack/ai-mcp @tanstack/ai-persistence

<!-- ::end:tabs -->

## 2. Serve the harness on stdio

Put this in `mcp.ts`. It opens sessions on your host, and reads MCP messages on stdin and writes the answers on stdout:

```ts group=harness-mcp-server
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import { createHarnessMcpServer } from '@tanstack/ai-mcp/harness'
import { serveMCPStdio } from '@tanstack/ai-mcp/server/stdio'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { openaiText } from '@tanstack/ai-openai'

const assistant = defineHarness({
name: 'acme/assistant',
adapter: openaiText('gpt-6.1-sol'),
})

const host = createHarnessHost({ persistence: memoryPersistence() })

export const server = await createHarnessMcpServer({
host,
harness: assistant,
})

serveMCPStdio(server)
```

For Claude Code, run this command one time:

```bash
claude mcp add --env OPENAI_API_KEY=your-key assistant -- npx tsx /path/to/mcp.ts
```

For Claude Desktop, Cursor, and other clients with a JSON file, add the same command under `mcpServers`:

```json
{
"mcpServers": {
"assistant": {
"command": "npx",
"args": ["tsx", "/path/to/mcp.ts"],
"env": { "OPENAI_API_KEY": "your-key" }
}
}
}
```

- Claude Desktop keeps this in `claude_desktop_config.json`.
- Cursor keeps this in `.cursor/mcp.json`.

The client starts the process when it needs the harness. The process stops when the client closes stdin. In this mode, stdout carries only MCP messages. Write your own logs with `console.error`.

## 3. Use the tools

Ask the client to use the harness, for example: "Ask assistant to summarize my open tickets." The client calls these tools.

To talk to the harness:

- `chat`: send a message and wait for the answer. While a turn runs, the message waits in the queue. To send files too, see [Send files and get media](#send-files-and-get-media).
- `steer`: add a message to the running turn.
- `cancel`: cancel the running turn.
- `status`: show the status, the interrupts and questions that wait, the background agents, and the queued turns.

To answer the harness:

- `approve` and `reject`: answer every approval that waits.
- `resolve`: answer every interrupt that waits, in one call.
- `answer`: answer a question from a command or a plugin.

To run what the harness has:

- `agent_<name>`: one tool for each agent in `expose.agents`. It takes the agent input and returns the agent result.
- `command_<name>`: one tool for each command in `expose.commands`. The command `connect:notion` becomes the tool `command_connect_notion`.

The harness exposes nothing by default. Add the agents and commands that an MCP client can run to `expose` in `defineHarness`. See [Choose what clients can change](./connect#choose-what-clients-can-change).

Two names can give the same tool name, for example `connect:notion` and `connect_notion`. Then the later one in name order gets a number: `command_connect_notion_2`. The description of each of these tools names its command or agent.

Every tool takes an optional `threadId`. Each thread is its own conversation. Without a `threadId`, a tool uses the `threadId` option of the server (default `main`).

`chat` answers with JSON:

```json
{
"status": "completed",
"text": "You have three open tickets.",
"interrupts": [],
"questions": []
}
```

## Interrupts and questions

A turn can stop for an interrupt. The turn then waits for your answer. Each interrupt has a `kind`:

- `approval`: a tool call that needs your yes or no.
- `client-tool`: a tool that the client runs. The turn waits for the tool output.
- `generic`: a question from a middleware. The answer must match its `responseSchema`.

For an approval, the client asks you in its own window (MCP elicitation). Answer `yes` to run the tool. Any other answer rejects it.

In these cases, `chat` returns the interrupts with `status: "interrupted"`:

- The client cannot ask you.
- You close the question without an answer.
- An interrupt of another kind also waits.

```json
{
"status": "interrupted",
"text": "Here is the plan.",
"interrupts": [
{
"id": "interrupt-1",
"kind": "generic",
"message": "Review the plan",
"responseSchema": {
"type": "object",
"properties": { "note": { "type": "string" } },
"required": ["note"]
}
}
],
"questions": []
}
```

An approval and a client tool also have `tool` and `args`, the tool call that waits.

Then the client calls `approve`, `reject`, or `resolve`. That call waits for the turn to continue and returns the same JSON as `chat`. `approve` and `reject` answer approvals only. When an interrupt of another kind also waits, they refuse, and the client uses `resolve`.

`resolve` takes one decision for each interrupt that waits. This example answers a turn with one approval and one generic interrupt:

```json
{
"decisions": [
{ "interruptId": "approval-1", "approved": true },
{ "interruptId": "interrupt-1", "payload": { "note": "Ship it." } }
]
}
```

- For an approval, set `approved`. `true` runs the tool, and `false` rejects it.
- For a client tool, set `payload` to the tool output.
- For a generic interrupt, set `payload` to a value that matches its `responseSchema`.

`resolve` refuses a list that does not answer every interrupt. It also refuses a decision without the field that its kind needs.

To approve every tool call without a question, set `approvals: 'auto'`. Use it only with tools that you trust. It answers approvals only, so other interrupts still come back in the result.

A command or a plugin can also ask you a question. The result then has `status: "waiting"` and the question in `questions`. The client calls `answer` with the question `id` as `questionId` and your `value`. `answer` waits for the work that asked, then returns its result.

## Send files and get media

`chat` takes `attachments`, the files to send with the message. Each attachment is one of these:

- `{ path }`: a file on the machine of the server. The file must be inside a folder that the server can read.
- `{ url, mimeType }`: a URL that goes to the model as it is. The server does not download it. `mimeType` is optional when the URL ends in a known file extension.
- `{ data, mimeType }`: the bytes of the file in base64.

Each attachment also takes an optional `name`. This `chat` call sends a screenshot:

```json
{
"message": "What is wrong in this screenshot?",
"attachments": [{ "path": "./bug.png" }]
}
```

The server reads a `path` only inside the `filePaths` option. By default it reads no files, so send `url` or `data`.

When the work makes media, the results of `chat`, `approve`, `reject`, `resolve`, and `answer` include it:

- An image or an audio file up to 5 MB comes back inline, as MCP image or audio content.
- Any other file comes back as a `resource_link` to `harness-media://<threadId>/<id>`. The client reads the bytes with `resources/read`.
- The JSON result lists each file in `media`, with its `id`, `kind`, `name`, `mimeType`, `size`, and `uri`.

## Serve it over HTTP

The server also has a `fetch` handler. Mount it on a route of your app, for example `/mcp`, and check a token first:

```ts group=harness-mcp-server
export async function handleMcp(request: Request) {
const token = request.headers.get('authorization')
if (token !== `Bearer ${process.env.MCP_TOKEN}`) {
return new Response('Unauthorized', { status: 401 })
}
return server.fetch(request)
}
```

The server opens sessions with no principal. Every caller that passes your check can use every thread and its media, so give each person their own server and token.

Then add it to Claude Code:

```bash
claude mcp add --transport http assistant https://your-app.example.com/mcp --header "Authorization: Bearer your-token"
```

`createHarnessMcpServer` takes these options next to `host` and `harness`:

- `threadId`: the conversation of a tool call without a `threadId`. Default `main`.
- `approvals`: `'ask'` (default) asks in the client, or returns the approvals when the client cannot ask. `'auto'` approves every tool call. Both answer approvals only.
- `filePaths`: the folders that a `path` attachment can read from. The real path of the file, after every symlink, must be inside one of them. Default: none, so the server refuses every `path` attachment.
- `name` and `version`: the MCP server name and version. Default: the harness name and `1.0.0`.

## What you have now

- Your harness in Claude Code, Claude Desktop, Cursor, or any other MCP client.
- Approvals that the client asks you about, or answers with `approve` and `reject`.
- Every kind of interrupt, answered with one `resolve` call.
- The agents and commands of your harness as MCP tools.
- Files that the client sends with a message, and the media of each turn in the result.
Loading
Loading