@three-ws/mcp-policy

@three-ws/mcp-policy

The three.ws MCP tool policy: every tool's group and tier, read-only by default with opt-in financial groups, named confirm flags, and preview ids that must precede every irreversible call.

npm install @three-ws/mcp-policy
View on GitHubStar the repo

@three-ws/mcp-policy

One tool policy for every three.ws MCP server: read-only by default, money behind a named confirm flag and a fresh preview.

Every tool on every three.ws MCP server, hosted or stdio, is classified here by group (what it is about) and tier (what it can do to the caller). A session sees the read and write tiers by default. Financial tools, the ones that move funds or cannot be undone, stay hidden until the person opts in, and even then each call must carry a named confirm flag (confirm_payment: true, confirm_swap: true, ...) plus a preview id issued by the matching preview tool within the last ten minutes, bound to the same arguments.

The hosted servers (through api/_mcp/policy.js), the /api/mcp-policy allowlist builder, and the stdio packages all read the same table, so a rule changes in one place.

#Install

The package is a workspace of this monorepo (packages/mcp-policy) and is not on the npm registry yet. A root npm install links it, after which import '@three-ws/mcp-policy' resolves from any file in the repo:

npm install
node -e "import('@three-ws/mcp-policy').then((p) => console.log(Object.keys(p.POLICY).length, 'servers'))"

Zero runtime dependencies. Node 20+. ESM only. Its package.json is already set for a public release (publishConfig.access: public, files: src, README.md, LICENSE), so publishing is npm publish -w packages/mcp-policy, an owner-approved step.

#The model

#Tiers

Tier Meaning Default
read Changes nothing. on
write Changes account state, and a follow-up call can undo it. on
financial Moves funds or cannot be undone. off; every call needs its confirm flag and a fresh preview id

#Groups

agents, chat, runs, skills, trading, orders, perps, lending, predictions, launch, marketplace, cards, mail, x402, wallet, billing, intelligence, integrations, account, allowlist, assets, gateway, sandbox, utility. Each has a label and a one-line summary in GROUPS (exported), written for the person deciding whether to turn it on.

#Financial tools

A financial row names three things:

The preview id is quote_id when the preview tool is a quote, preview_id otherwise. It lives for PREVIEW_TTL_MS (10 minutes), belongs to the caller who previewed, and is burned the moment the financial call it authorized succeeds.

A tool with no row in the table is treated as financial with no group: it is hidden unless named explicitly, so an unclassified tool can never slip into a default session.

#The spec grammar

One grammar is read from the X-Three-Tools header, the tools query parameter, the THREE_WS_TOOLS env var of a stdio server, and the saved settings. Tokens are comma- or space-separated and applied left to right:

Token Effect
default read + write on, financial off (the implicit base)
all / none / readonly every tier / nothing / only read
financial, -write turn a tier on or off
trading, -chat turn a whole group on (every tier) or off
swap_execute, -forget turn one tool on or off

Precedence is tool over group over tier: -trading,swap_quote hides the trading group except swap_quote. A spec made only of plain tool names is an exact allow list; to add a tool to the defaults instead, lead with a base: default,pay_and_call.

Hosted servers resolve a session in this order, first present wins: the client's X-Three-Allowed-Tools list, X-Three-Tools, the tools query parameter, the per-key setting layered over the account setting, then the default. A stdio process resolves THREE_WS_ALLOWED_TOOLS, then THREE_WS_TOOLS, then the selection npx three-ws tools saved under tools["three-ws-<name>"] in ~/.config/three-ws/credentials.json, then the default.

#Quick start

The full preview, confirm, burn cycle against the real x402-mcp table:

import { createPolicy, enablementFromSpec, describeEnablement } from '@three-ws/mcp-policy';

const policy = createPolicy({ serverId: 'x402-mcp' });
const en = enablementFromSpec('default,x402', 'example'); // the whole x402 group, financial tier included
console.log(describeEnablement(en));

const call = { url: 'https://three.ws/api/x402/example', method: 'GET' };

// 1. A financial call with no confirm flag is refused with a tool result the model can read.
const refused = await policy.beforeCall({ name: 'pay_and_call', args: call, en, principal: 'me' });
console.log(refused.ok, refused.result.structuredContent.reason); // false confirmation_required

// 2. The preview tool runs; afterCall stamps a preview id onto its result.
const preview = await policy.afterCall({
	name: 'inspect_endpoint',
	args: call,
	principal: 'me',
	result: { content: [{ type: 'text', text: 'price: $0.01 per call' }] },
});
const previewId = preview._meta['three.ws/preview'].preview_id;

// 3. The confirmed call with the fresh preview id passes, policy-only arguments stripped.
const gate = await policy.beforeCall({
	name: 'pay_and_call',
	args: { ...call, preview_id: previewId, confirm_payment: true },
	en,
	principal: 'me',
});
console.log(gate.ok, gate.args); // true { url: ..., method: 'GET' }

// 4. After it succeeds the preview id is burned, so it can never authorize a second payment.
await policy.afterCall({ name: 'pay_and_call', args: call, principal: 'me', result: { content: [] }, preview: gate.preview });
const replay = await policy.beforeCall({ name: 'pay_and_call', args: { ...call, preview_id: previewId, confirm_payment: true }, en, principal: 'me' });
console.log(replay.ok, replay.result.structuredContent.reason); // false preview_unknown

Refusals are MCP tool results with isError: true and a structuredContent reason (tool_disabled, confirmation_required, preview_required, preview_unknown, preview_stale, preview_mismatch), never JSON-RPC errors: a model reads a tool result and acts on it, while most clients show a protocol error only to the person.

#Wiring a stdio server

#High-level McpServer

Call applyPolicy before the registerTool loop. It wraps registerTool, so a disabled tool is never registered (calling it anyway explains how to turn it on), a financial tool gains its confirm flag and preview-id arguments in its zod schema, and a preview tool stamps a preview id onto its result.

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { applyPolicy } from '@three-ws/mcp-policy/stdio';

const server = new McpServer({ name: 'x402-mcp', version: '1.0.0' });
const { enablement, hidden } = applyPolicy(server, { serverId: 'x402-mcp', z });

server.registerTool(
	'inspect_endpoint',
	{ description: 'Price an x402 endpoint without paying.', inputSchema: { url: z.string().url(), method: z.string().default('GET') } },
	async ({ url, method }) => ({ content: [{ type: 'text', text: `${method} ${url}: $0.01 per call` }] }),
);
server.registerTool(
	'pay_and_call',
	{ description: 'Pay an x402 endpoint and return its response.', inputSchema: { url: z.string().url(), method: z.string().default('GET') } },
	async ({ url }) => ({ content: [{ type: 'text', text: `paid ${url}` }] }),
);

console.error(`policy source: ${enablement.source}; hidden: ${[...hidden].join(', ') || 'none'}`);
if (process.argv.includes('--serve')) await server.connect(new StdioServerTransport());

Run it bare and it prints policy source: default; hidden: pay_and_call. Run it with THREE_WS_TOOLS=default,x402 and it prints policy source: env; hidden: none. Pass the package's own z so the added arguments match its zod version.

#Low-level Server with JSON-Schema tools

import { createStdioPolicy } from '@three-ws/mcp-policy/stdio';

const gate = createStdioPolicy('x402-mcp');
// ListTools handler:  return { tools: gate.listTools(TOOLS) };
// CallTool handler:   return gate.callTool(name, args, (cleanArgs) => runTool(name, cleanArgs));

listTools drops disabled tools, appends the financial rule to each financial tool's description, adds the confirm and preview arguments to its schema, and stamps _meta['three.ws/policy'] (group, tier, confirm flag, preview tool, preview argument) on every tool.

#API

#@three-ws/mcp-policy

Export What it is
GROUPS, GROUP_IDS Every group { id, label, summary }, in settings-page order, and just the ids.
TIERS, DEFAULT_TIERS ['read', 'write', 'financial'] and the tiers a fresh key sees (['read', 'write']).
CONFIRM_FLAGS, PREVIEW_ARGS, PREVIEW_TTL_MS The confirm flag vocabulary, ['quote_id', 'preview_id'], and the 10-minute preview lifetime.
POLICY The table: { [serverId]: { [toolName]: { group, tier, confirmFlag?, previewTool?, bind?, ownsPreview? } } }. The single source of truth.
SERVERS Human names, endpoints and transport (remote or stdio) for every server id in POLICY.
createPolicy({ serverId, table?, store?, settingsUrl?, now? }) The runtime for one server: listTools(tools, en), beforeCall({ name, args, en, principal, ownArgs }), afterCall({ name, args, principal, result, preview }), enabled(name, en), entry(name), disabledResult, policyMeta, financialNote. Throws when serverId has no table.
createMemoryPreviewStore() An in-process { put, get, del } preview store, right for a single stdio process. The hosted servers pass a Redis-backed store instead so a quote issued on one instance is honored on another.
normalizeEntry(name, row) Expand a terse table row into the shape every consumer reads.
parseSpec(spec) Spec string or array to { tokens, invalid }; a typo lands in invalid instead of being silently dropped.
enablementFromSpec(spec, source) A spec to an enablement, honoring the exact-allow-list rule.
enablementFromTokens(tokens, source), defaultEnablement(), allowListEnablement(names, source?) Build an enablement directly.
settingsToTokens(settings) Saved { groups: { id: bool }, tools: { name: bool } } settings to tokens; unknown groups are dropped.
resolveEnablement({ allowedTools, headerSpec, querySpec, keySettings, accountSettings }) The hosted resolution order above, in one call.
enablementFromSelection(sel, source?), selectionToTokens(sel), specFromSelection(sel) Convert a three-ws tools selection ({ tiers, allow, deny }) to an enablement, tokens, or an X-Three-Tools spec.
isToolEnabled(name, entry, en) Is this tool on for the session.
describeEnablement(en) A compact, serializable view for logs and the settings API.
SETTINGS_URL, CLI_COMMAND https://three.ws/settings/mcp-tools and npx three-ws tools, quoted in every refusal.

#@three-ws/mcp-policy/stdio

Export What it is
applyPolicy(server, { serverId, z, env?, enablement?, store? }) Put the policy in front of every tool an McpServer registers from now on. Returns { policy, enablement, hidden }.
createStdioPolicy(serverId, { env?, enablement?, store? }) The same policy for a low-level Server: { policy, enablement, listTools(tools), callTool(name, args, run, ownArgs?) }.
resolveStdioEnablement(serverId, env?) The stdio resolution order above.
cliSlug(serverId) The CLI's config key for a package: x402-mcp becomes three-ws-x402.

#Adding a tool

Add a row for it under its server in src/table.js with the row helpers at the top of the file: r(group), w(group), f(group, flag, previewTool, bind), or own(group, flag, previewTool) for a tool that verifies its own preview id. A financial tool must name the preview tool a person sees before it runs. The audit that checks each tool's annotations against its handler is npm run audit:mcp-safety (docs/mcp-safety.md).

#License

Apache-2.0. See LICENSE.