Add YEA to your MCP server (TypeScript)
You have an MCP server, and one of its tools does something a person should see first: it deletes a file, sends an email, or moves money. This guide adds YEA to that tool. Before the tool runs, the server asks the person, through their client, to approve its plan by typing a phrase. The model can't approve it through the tool call. What else must stay out of the agent's reach, such as your client's config, is in the security model.
Steps 1–3 give you that tool, and need no keys, no policy and no account. Steps 4–6 are separate, later steps: undo, letting safe things run on their own, and clients that can't ask.
The code on this page comes from examples/mcp-quickstart.ts, a small files server that the test suite runs.
1. Install
Unreleased: install from the repo
@yea-protocol/mcp, @yea-protocol/sdk and the yea command aren't on npm yet. Until they are, build them from the repo and install the packed files into your server's project:
git clone https://github.com/yea-protocol/yea ~/yea
cd ~/yea && npm ci && npm run build
mkdir -p ~/yea-pkgs && npm pack -w @yea-protocol/sdk -w @yea-protocol/mcp --pack-destination ~/yea-pkgs
cd ~/your-server
npm i ~/yea-pkgs/yea-protocol-sdk-0.1.0.tgz ~/yea-pkgs/yea-protocol-mcp-0.1.0.tgz \
@modelcontextprotocol/server zodSteps 5 and 6 also use the yea command. From the clone: alias yea="node $HOME/yea/cli/bin/yea.js".
@yea-protocol/mcp works with the official TypeScript MCP SDK v2 (@modelcontextprotocol/server 2.1 or later) and Node 20 or later. Input schemas can be zod 4.2 or later, or any Standard Schema that exposes JSON Schema.
2. Guard one tool you already have
Here is the whole change. delete_file is registered exactly as before, and one approvals.guard(...) call turns it into a tool that shows its plan and asks. The snippets below, in order, are a complete server.ts:
import { randomUUID } from 'node:crypto';
import {
link,
lstat,
mkdir,
realpath,
rename,
rm,
unlink,
} from 'node:fs/promises';
import {
basename,
dirname,
isAbsolute,
join,
relative,
resolve,
sep,
} from 'node:path';
import { fileURLToPath } from 'node:url';
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { type Approvals, yea } from '@yea-protocol/mcp';
import * as z from 'zod';The example's tools only touch files inside one folder, so a mistaken or hostile path can't reach anything else, including the server's own key and approval store. That folder comes from FILES_ROOT, which must be set and must not hold the server's own files:
/**
* `path` inside `root`, with symlinks resolved. Anything that leads outside is refused, so the
* tools can't reach the server's own files (its key, its approval store) or yours; so is a
* symlink as the file itself, so the plan the person approves always names the file that changes.
*/
async function inside(root: string, path: string): Promise<string> {
const base = await realpath(root);
const given = resolve(base, path);
if ((await lstat(given)).isSymbolicLink()) {
throw new Error(`${path} is a symlink`);
}
const full = await realpath(given);
const rel = relative(base, full);
if (
rel === '' ||
rel === '..' ||
rel.startsWith(`..${sep}`) ||
isAbsolute(rel)
) {
throw new Error(`${path} is outside ${base}`);
}
return full;
}
/** `path` inside `root`, as `inside`, and a regular file: never a directory. */
async function fileInside(root: string, path: string): Promise<string> {
const file = await inside(root, path);
if (!(await lstat(file)).isFile()) {
throw new Error(`${path} is not a regular file`);
}
return file;
}
/**
* The folder the tools may change: FILES_ROOT, which must exist and must not hold this server's
* own files, or `delete_file` could remove them.
*/
export async function filesRoot(
env: NodeJS.ProcessEnv = process.env,
self = fileURLToPath(import.meta.url),
): Promise<string> {
const hint =
'set FILES_ROOT to a folder of files the tools may change, such as "$HOME/yea-scratch"';
if (!env.FILES_ROOT) {
throw new Error(`FILES_ROOT is not set: ${hint}`);
}
const root = await realpath(env.FILES_ROOT).catch(() => {
throw new Error(
`FILES_ROOT ${env.FILES_ROOT} doesn't exist: create it first`,
);
});
const rel = relative(root, await realpath(self));
if (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)) {
throw new Error(
`FILES_ROOT ${root} holds this server's own files: ${hint}`,
);
}
return root;
}/** Your existing tool, registered as usual, then guarded. */
function addDeleteFile(server: McpServer, approvals: Approvals, root: string) {
const deleteFile = server.registerTool(
'delete_file',
{
description: 'Delete a file for good',
inputSchema: z.object({ path: z.string() }),
},
async ({ path }) => {
await rm(await fileInside(root, path));
return { content: [{ type: 'text', text: `deleted ${path}` }] };
},
);
// One call: now it shows its plan and asks the person before it runs.
approvals.guard(server, deleteFile, {
describe: async (input) => {
await fileInside(root, String(input.path)); // refuse before anyone is asked
return {
summary: `Delete ${String(input.path)}`,
effects: [{ op: 'delete', target: `file/${String(input.path)}` }],
};
},
// The person types the file's name to approve.
confirmWith: (_plan, input) => basename(String(input.path)),
});
}describe(input)says what the call will do: a one-linesummaryand itseffects. It must not change anything. Its input is untyped (Record<string, unknown>), so convert fields withString(...).confirmWithnames the phrase the person types, here the file's name. Without it, the phrase isapprove. It must be typeable: a phrase with control or invisible characters, or a tab or other unusual space inside, is refused as a tool error.approvals.serverOptions()lets YEA verify the approval state it sends round the client.
Build the server in a factory. Leave out the addMoveToTrash line until step 4:
/** A fresh server each time: the SDK may call the factory more than once. */
export function createServer(
approvals: Approvals,
root: string, // the only folder the tools touch
): McpServer {
const server = new McpServer(
{ name: 'files', version: '1.0.0' },
approvals.serverOptions(), // lets YEA verify the approval state it sends round
);
addDeleteFile(server, approvals, root);
addMoveToTrash(server, approvals, root); // step 4
return server;
}Then check FILES_ROOT (the server refuses to start with a message saying what to set), create the approval context once per process, and serve. The SDK may call the factory more than once, so it builds a fresh McpServer each time, while yea(), which holds the keys and the store, runs once:
const root = await filesRoot().catch((e: Error) => {
console.error(`files: ${e.message}`); // refuse to start
process.exit(1);
});
const approvals = yea({ name: 'files', transport: 'stdio' }); // once per process
serveStdio(() => createServer(approvals, root));Show the full file
/**
* A small `files` MCP server with YEA approval: the example in the "Add YEA to your MCP server"
* guide (site/guide/mcp-typescript.md). The guide shows its regions in order (the README copies
* `guard`), and mcp/test/quickstart.test.ts drives them with in-memory clients.
*
* FILES_ROOT=~/yea-scratch npx tsx examples/mcp-quickstart.ts # or, on Node 22.18+: node …
*/
// #region imports
import { randomUUID } from 'node:crypto';
import {
link,
lstat,
mkdir,
realpath,
rename,
rm,
unlink,
} from 'node:fs/promises';
import {
basename,
dirname,
isAbsolute,
join,
relative,
resolve,
sep,
} from 'node:path';
import { fileURLToPath } from 'node:url';
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { type Approvals, yea } from '@yea-protocol/mcp';
import * as z from 'zod';
// #endregion imports
import { pathToFileURL } from 'node:url';
// #region root
/**
* `path` inside `root`, with symlinks resolved. Anything that leads outside is refused, so the
* tools can't reach the server's own files (its key, its approval store) or yours; so is a
* symlink as the file itself, so the plan the person approves always names the file that changes.
*/
async function inside(root: string, path: string): Promise<string> {
const base = await realpath(root);
const given = resolve(base, path);
if ((await lstat(given)).isSymbolicLink()) {
throw new Error(`${path} is a symlink`);
}
const full = await realpath(given);
const rel = relative(base, full);
if (
rel === '' ||
rel === '..' ||
rel.startsWith(`..${sep}`) ||
isAbsolute(rel)
) {
throw new Error(`${path} is outside ${base}`);
}
return full;
}
/** `path` inside `root`, as `inside`, and a regular file: never a directory. */
async function fileInside(root: string, path: string): Promise<string> {
const file = await inside(root, path);
if (!(await lstat(file)).isFile()) {
throw new Error(`${path} is not a regular file`);
}
return file;
}
/**
* The folder the tools may change: FILES_ROOT, which must exist and must not hold this server's
* own files, or `delete_file` could remove them.
*/
export async function filesRoot(
env: NodeJS.ProcessEnv = process.env,
self = fileURLToPath(import.meta.url),
): Promise<string> {
const hint =
'set FILES_ROOT to a folder of files the tools may change, such as "$HOME/yea-scratch"';
if (!env.FILES_ROOT) {
throw new Error(`FILES_ROOT is not set: ${hint}`);
}
const root = await realpath(env.FILES_ROOT).catch(() => {
throw new Error(
`FILES_ROOT ${env.FILES_ROOT} doesn't exist: create it first`,
);
});
const rel = relative(root, await realpath(self));
if (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)) {
throw new Error(
`FILES_ROOT ${root} holds this server's own files: ${hint}`,
);
}
return root;
}
// #endregion root
// #region guard
/** Your existing tool, registered as usual, then guarded. */
function addDeleteFile(server: McpServer, approvals: Approvals, root: string) {
const deleteFile = server.registerTool(
'delete_file',
{
description: 'Delete a file for good',
inputSchema: z.object({ path: z.string() }),
},
async ({ path }) => {
await rm(await fileInside(root, path));
return { content: [{ type: 'text', text: `deleted ${path}` }] };
},
);
// One call: now it shows its plan and asks the person before it runs.
approvals.guard(server, deleteFile, {
describe: async (input) => {
await fileInside(root, String(input.path)); // refuse before anyone is asked
return {
summary: `Delete ${String(input.path)}`,
effects: [{ op: 'delete', target: `file/${String(input.path)}` }],
};
},
// The person types the file's name to approve.
confirmWith: (_plan, input) => basename(String(input.path)),
});
}
// #endregion guard
// #region job
/** A job written for YEA: the undoable way to get rid of a file. */
function addMoveToTrash(server: McpServer, approvals: Approvals, root: string) {
approvals.job(server, 'move_to_trash', {
description: 'Move a file to the trash. It can be undone for a day.',
inputSchema: z.object({ path: z.string() }),
risk: 'low',
// plan() only reads. revert() can't put back a directory, so fileInside refuses one.
plan: async ({ path }) => {
const file = await fileInside(root, path);
return [
{
summary: `Move ${path} to the trash`,
effects: [
{ op: 'update', target: `file/${path}`, detail: 'moved to .trash' },
],
undoWindow: 86_400, // one day, in seconds
apply: async () => {
const trash = join(dirname(file), '.trash');
const trashedAs = join(trash, `${randomUUID()}-${basename(file)}`);
await mkdir(trash, { recursive: true });
await rename(file, trashedAs);
return { file, trashedAs }; // what revert() gets back as `result`
},
},
];
},
// With revert and an undoWindow, the plan is undoable: YEA adds an `undo` tool.
revert: async ({ result }) => {
const { file, trashedAs } = result as { file: string; trashedAs: string };
// link() fails, and changes nothing, if a new file took the old one's place.
await link(trashedAs, file);
await unlink(trashedAs);
},
});
}
// #endregion job
// #region serve
/** A fresh server each time: the SDK may call the factory more than once. */
export function createServer(
approvals: Approvals,
root: string, // the only folder the tools touch
): McpServer {
const server = new McpServer(
{ name: 'files', version: '1.0.0' },
approvals.serverOptions(), // lets YEA verify the approval state it sends round
);
addDeleteFile(server, approvals, root);
addMoveToTrash(server, approvals, root); // step 4
return server;
}
// #endregion serve
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
// #region start
const root = await filesRoot().catch((e: Error) => {
console.error(`files: ${e.message}`); // refuse to start
process.exit(1);
});
const approvals = yea({ name: 'files', transport: 'stdio' }); // once per process
serveStdio(() => createServer(approvals, root));
// #endregion start
}3. Run it in your client
The tools only act inside FILES_ROOT, so give them a folder of files you can lose. The server refuses to start without it, or when it holds the server's own files, since delete_file could then remove your project's files, such as server.ts and package.json. Make the folder, with a file for step 3 to delete and one for step 4 to move to the trash:
mkdir -p "$HOME/yea-scratch/docs"
echo "Q3 report" > "$HOME/yea-scratch/docs/report.pdf"
echo "meeting notes" > "$HOME/yea-scratch/docs/notes.txt"Then, in Claude Code, from your server's folder:
claude mcp add files -e FILES_ROOT="$HOME/yea-scratch" -- npx tsx server.tsNode 22.18 or later can run TypeScript directly, so node server.ts works in place of npx tsx server.ts.
Cursor and VS Code
Cursor, in .cursor/mcp.json (project) or ~/.cursor/mcp.json (global):
{
"mcpServers": {
"files": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/server.ts"],
"env": { "FILES_ROOT": "/absolute/path/to/yea-scratch" }
}
}
}VS Code, in .vscode/mcp.json. The top-level key is servers:
{
"servers": {
"files": {
"type": "stdio",
"command": "npx",
"args": ["tsx", "${workspaceFolder}/server.ts"],
"env": { "FILES_ROOT": "/absolute/path/to/yea-scratch" }
}
}
}Ask the agent to delete docs/report.pdf. Before anything happens, the server sends the client a form (MCP form elicitation) with the plan and a field to type in. How a client shows it is up to the client; we've tested the exchange with the MCP SDK's own client, not yet in a named app. What it says is this:
Approval needed: delete_file can't be undone.
[1] Delete docs/report.pdf
- delete file/docs/report.pdf
risk: medium · undo: never
to approve, type: report.pdf
Confirm: Type "report.pdf" to approve.Type report.pdf and the file is deleted. Decline, or type something else three times, and nothing is. That's the tool guarded.
Three things to know at this point:
- The server's stderr warns
yea: no pinned principal key (YEA_PRINCIPAL_PUB is not set): nothing auto-runs and no consent is accepted. That's expected: it only matters for steps 5 and 6. - The client must support form elicitation to show the form. The client table says which clients we've run and what their vendors document. A client without it gets step 6's consent codes instead.
- A tool marked
risk: 'high', or anoutOfBandrule in~/.yea/policy.json, is never approved in the chat. It goes to consent codes, which need step 5's pinned key.
4. Make it undoable
Deleting can't be undone, so it always asks. Moving a file to a trash folder can, and YEA can offer undo for it. A job() is a tool written for YEA from the start: plan says what will happen, and apply does it.
/** A job written for YEA: the undoable way to get rid of a file. */
function addMoveToTrash(server: McpServer, approvals: Approvals, root: string) {
approvals.job(server, 'move_to_trash', {
description: 'Move a file to the trash. It can be undone for a day.',
inputSchema: z.object({ path: z.string() }),
risk: 'low',
// plan() only reads. revert() can't put back a directory, so fileInside refuses one.
plan: async ({ path }) => {
const file = await fileInside(root, path);
return [
{
summary: `Move ${path} to the trash`,
effects: [
{ op: 'update', target: `file/${path}`, detail: 'moved to .trash' },
],
undoWindow: 86_400, // one day, in seconds
apply: async () => {
const trash = join(dirname(file), '.trash');
const trashedAs = join(trash, `${randomUUID()}-${basename(file)}`);
await mkdir(trash, { recursive: true });
await rename(file, trashedAs);
return { file, trashedAs }; // what revert() gets back as `result`
},
},
];
},
// With revert and an undoWindow, the plan is undoable: YEA adds an `undo` tool.
revert: async ({ result }) => {
const { file, trashedAs } = result as { file: string; trashedAs: string };
// link() fails, and changes nothing, if a new file took the old one's place.
await link(trashedAs, file);
await unlink(trashedAs);
},
});
}plan(input)returns the plans. It must not change anything: it runs on preview and on every retry.apply()runs at most once, and only after the plan is approved or allowed.revertplus anundoWindowmake the plan undoable. The first job withrevertadds anundotool to the server.
Ask the agent to move docs/notes.txt to the trash. After approval, the call returns a receipt:
✓ Move docs/notes.txt to the trash (receipt r_Ny6CTkeKuDVI) · undo until 2026-09-29T02:37:30Z
result:
file: /Users/me/yea-scratch/docs/notes.txt
trashedAs: /Users/me/yea-scratch/docs/.trash/b0974c8d-9a52-4c19-acd0-ccb8bc0cef3f-notes.txtplan() refuses anything that isn't a regular file, such as a directory, since revert couldn't put it back. undo({ receipt: "r_Ny6CTkeKuDVI" }) puts the file back, within the window. Undo doesn't ask, because it restores what the person already approved changing. Any job call also takes "preview": true, which returns the plans and does nothing.
5. Let safe things run on their own
So far everything asks. A signed policy lets undoable plans run without asking: for example, move_to_trash, at low risk, for the next seven days. How the policy works covers what one can limit and what happens past it.
The policy is only as strong as the place where your private principal key lives: whoever can use that key can sign any policy. So there are two setups.
The real setup: the key on another OS user or device
On the other user or device, create the principal key and copy its public key:
shyea init yea whoami # principal ed25519:…On the server's machine, pin that public key where the server's user can't change it:
shsudo mkdir -p /etc/yea && echo 'ed25519:…' | sudo tee /etc/yea/principal.pubThe server refuses a pinned key file that its own user could change, or replace through a directory above it.
Get the server's id from its start-up line on stderr:
textyea: service id ed25519:… (name files)On the key's side, sign a policy for that id:
shyea grant --to ed25519:… --can move_to_trash --risk low --exp 7dIt prints only the
pg1.token on stdout. Copy it into a file on the server's machine, for example/Users/me/.config/yea/files.policy.In the client config, point the server at both:
shclaude mcp add files \ -e FILES_ROOT="$HOME/yea-scratch" \ -e YEA_PRINCIPAL_PUB=/etc/yea/principal.pub \ -e YEA_POLICY="$HOME/.config/yea/files.policy" \ -- npx tsx server.ts~isn't expanded in client config files, so use a full path there, or$HOMEin a shell as above. The policy is read on every call, so a new one applies without a restart.
Now move_to_trash runs straight away and returns its receipt. delete_file still asks.
Trying it on one account (best effort)
The same steps, with yea init and yea grant run on this account:
yea init && yea whoami
sudo mkdir -p /etc/yea && echo 'ed25519:…' | sudo tee /etc/yea/principal.pub
mkdir -p "$HOME/.config/yea"
yea grant --to ed25519:… --can move_to_trash --risk low --exp 7d > "$HOME/.config/yea/files.policy"For trying YEA, not for protecting anything
The principal key is now in ~/.yea on the account the agent runs as. An agent that can run commands or read files, as Claude Code can, could sign its own policy. Move the key to another OS user or device before you rely on it.
What a policy can't do
- Only undoable plans run on their own. That's why the grant names the step-4 job. The step-2 guard never auto-runs unless its
describereturns anundoWindowand it has arevert: without them there's no way back from a mistake, so the person decides each time. - The person signs the policy, not the agent. The server trusts only the pinned key, and the agent never holds the private key in the real setup. Unsigned settings in
~/.yea/policy.jsoncan only tighten it. - A plan over the policy's risk, a tool it doesn't name, or an expired policy all go back to asking.
In your own code, approvals.serviceId() returns the same id as the start-up line.
6. Clients that can't ask
Some clients don't support form elicitation. For them, a call that needs approval fails closed. The model gets the plan and a consent code, and nothing runs:
✗ approval needed: delete_file can't be undone; nothing was run
1 plan:
[1] Delete docs/report.pdf
- delete file/docs/report.pdf
risk: medium · undo: never
Ask the user to run `yea approve <code>` in their terminal, then call again.
code for [1]: pc1.eyJjYXBhYmlsaXR5Ijoi…yea approve <code> shows the plan, has the person type its phrase, and signs a one-time consent for that exact plan with the principal key. It saves the consent in the store the server reads (YEA_STORE, else $YEA_HOME/store, else ~/.yea/store). When the model calls again with the same input, the call runs once.
So yea approve needs both the principal key and the server's store. In the one-account setup, both are on your account, and that's the case our tests cover. With the key on another OS user, that user would run it on the server's machine with YEA_STORE set to the server's store; we haven't yet tested a store written by two OS users. A key on another device can't answer a consent code yet.
Why the pinned key matters here: the server accepts only consents signed by the pinned principal key. Without one it can't accept any, so it gives no codes and says why:
No consent can be accepted: YEA_PRINCIPAL_PUB is not set.The same happens on a server whose store is in memory (the default over HTTP), because yea approve can't reach it.
Where to next
- The full example:
examples/mcp-quickstart.ts, and From REST to YEA for turning an API's writes into job tools. - The security model: what must stay out of the agent's reach.
- The specs:
@yea-protocol/mcp, and the approval contract it implements.