@stage-labs/metro 0.1.0-beta.303 → 0.1.0-beta.304

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +2 -2
  3. package/runtime/marketplace/plugin/METRO.md +2 -0
  4. package/runtime/marketplace/plugin/bin/guard.mjs +2 -1
  5. package/runtime/marketplace/plugin/bin/metro-plugin.mjs +20 -2
  6. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +4 -1
  7. package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +10 -3
  8. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +11 -0
  9. package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +6 -3
  10. package/runtime/node_modules/@metro-labs/daemon/src/channels/notice.ts +22 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +1 -0
  12. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +11 -6
  13. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +2 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +27 -8
  15. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +6 -4
  16. package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +109 -0
  17. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +29 -0
  18. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/link.ts +186 -0
  19. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/token.ts +50 -0
  20. package/runtime/node_modules/@metro-labs/http/package.json +2 -1
  21. package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +2 -1
  22. package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +81 -0
  23. package/runtime/runtime.json +1 -1
  24. package/runtime/sdk-runner/runner.json +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.303",
3
+ "version": "0.1.0-beta.304",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.303",
4
- "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
3
+ "version": "0.1.0-beta.304",
4
+ "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon (or one `mcp` server on mcp.metro.box), the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
7
7
  "url": "https://metro.box"
@@ -15,6 +15,8 @@ You are the main thread of a Claude Code session that is connected to chat throu
15
15
 
16
16
  **Calls that need the owner's approval run in a worker.** The owner can make a metro tool on a channel wait for approval. On the main thread such a call is refused, because waiting for the answer would stop the whole session: hand that exact call to a background worker (`run_in_background: true`) and keep answering. The worker waits for the owner's answer on its own.
17
17
 
18
+ **Connectors through the `mcp` server.** When the `mcp` server is present, it holds every connector tool behind two tools. Find what you need with `find_tools`: `select:notion.search,linear.create_issue` for exact names, keywords (`notion page update`) to rank, or `+slack send` to require `slack` in the tool name. Have a worker run them with `call_tool`, up to 10 independent calls in one go. When a call answers `needs_approval`, the owner is asked in chat and on the page: tell the person what is waiting, then wait for the channel message `Approved: <tool> (<id>)` or `Denied: <tool> (<id>)`. After an approval, have a worker call `call_tool` again with the same tool and arguments and `approval: "<id>"`.
19
+
18
20
  **Finish what was asked.** You work on your own. Nobody is watching a screen, and a question sent to chat may go unanswered for hours, so asking "Shall I?" about work already requested simply stops it. Anything reversible that follows from the request, do. Stop only for a destructive action or a genuine change of scope. Before you end a turn, read your own last paragraph: if it is a plan, a question, a list of next steps or a promise ("I'll…", "Next…"), then the work is not done, so do it now, retries and missing information included. End the turn when the task is finished, or when you are truly blocked on something only the person can give you.
19
21
 
20
22
  The exception is a person describing a problem, asking a question or thinking out loud. There the answer IS the deliverable: reply, and change nothing until they ask.
@@ -23,6 +23,7 @@ const ORCHESTRATION = new Set([
23
23
  const IMAGE = /\.(png|jpe?g|gif|webp|bmp)$/i;
24
24
  const MCP = /^mcp__/;
25
25
  const METRO_MCP = /^mcp__metro__/;
26
+ const FIND_TOOLS = 'mcp__plugin_metro_mcp__find_tools';
26
27
  const VOICE = process.env.METRO_SESSION === 'voice';
27
28
 
28
29
  function answer(decision, reason) {
@@ -54,7 +55,7 @@ function verdict(payload, input) {
54
55
  : 'A foreground subagent blinds the main thread: it cannot see or acknowledge inbound metro messages until the agent finishes. Re-issue the identical Agent call with run_in_background: true, then wait for the task notification.';
55
56
  if (VOICE) return null;
56
57
  if (ORCHESTRATION.has(tool)) return null;
57
- if (METRO_MCP.test(tool)) return null;
58
+ if (METRO_MCP.test(tool) || tool === FIND_TOOLS) return null;
58
59
  if (MCP.test(tool))
59
60
  return `A connector call is work, not orchestration: ${tool} reads somebody's files, mail or records, and its answer can be long enough to crowd out the chat you are here to follow. Only metro's own tools run on this thread. Delegate it to a subagent with the Agent tool (run_in_background: true; the worker reaches every connector) and relay what it finds.`;
60
61
  if (tool === 'Read' && typeof input.file_path === 'string' && IMAGE.test(input.file_path)) return null;
@@ -4,6 +4,7 @@ import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
 
6
6
  const agentsDir = () => process.env.METRO_AGENTS_DIR || join(homedir(), '.metro', 'agents');
7
+ const MCP_TOKEN = /^mcp_[A-Za-z0-9_-]{43}$/;
7
8
 
8
9
  function readAgent() {
9
10
  const path = join(agentsDir(), 'agent.json');
@@ -17,8 +18,25 @@ function readAgent() {
17
18
  return parsed;
18
19
  }
19
20
 
20
- if (process.argv[2] !== 'headers') {
21
- process.stderr.write('usage: metro-plugin.mjs headers\n');
21
+ function readMcpToken() {
22
+ try {
23
+ const token = readFileSync(join(agentsDir(), 'mcp-token'), 'utf8').trim();
24
+ return MCP_TOKEN.test(token) ? token : null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ const command = process.argv[2];
31
+
32
+ if (command === 'mcp-headers') {
33
+ const token = readMcpToken();
34
+ if (token !== null) process.stdout.write(`${JSON.stringify({ Authorization: `Bearer ${token}` })}\n`);
35
+ process.exit(0);
36
+ }
37
+
38
+ if (command !== 'headers') {
39
+ process.stderr.write('usage: metro-plugin.mjs headers | mcp-headers\n');
22
40
  process.exit(1);
23
41
  }
24
42
 
@@ -3,6 +3,8 @@ import { join } from 'node:path';
3
3
  import { errMsg, log } from '@metro-labs/core/log';
4
4
  import { isRecord } from '@metro-labs/core/is-record';
5
5
  import { agentsDir } from '../agents/files.js';
6
+ import { mcpServerUrl } from '../mcp-server/config.js';
7
+ import { MCP_TOKEN_FILE } from '../mcp-server/token.js';
6
8
  import { removeHome, writeHomeText } from './home-fs.js';
7
9
  import { closeMetroHome } from './metro-home.js';
8
10
  import { agentUser, agentViewDir, type AgentUser } from './user.js';
@@ -49,7 +51,8 @@ export function viewFiles(dir = agentsDir()): Map<string, string | null> {
49
51
  ['agent.json', agentFile(dir)],
50
52
  ['model.json', modelFile(dir)],
51
53
  ]);
52
- for (const name of COPIED) files.set(name, existsSync(join(dir, name)) ? readText(join(dir, name)) : null);
54
+ const copied = mcpServerUrl() === null ? COPIED : [...COPIED, MCP_TOKEN_FILE];
55
+ for (const name of copied) files.set(name, existsSync(join(dir, name)) ? readText(join(dir, name)) : null);
53
56
  return files;
54
57
  }
55
58
 
@@ -17,6 +17,7 @@ export interface PendingPrompt {
17
17
  line: string | undefined;
18
18
  at: number;
19
19
  call?: CallSource;
20
+ origin?: 'mcp-server';
20
21
  }
21
22
 
22
23
  interface Held extends PendingPrompt {
@@ -93,6 +94,8 @@ export const pendingPrompts = (): PendingPrompt[] => [...held.values()].map(show
93
94
 
94
95
  export const promptLine = (requestId: string): string | undefined => held.get(requestId)?.line;
95
96
 
97
+ export const isPromptHeld = (requestId: string): boolean => held.has(requestId);
98
+
96
99
  function chatNotice(via: AnsweredVia, behavior: Behavior): string | undefined {
97
100
  if (via === 'expiry') return 'Expired, denied.';
98
101
  if (via === 'page') return behavior === 'allow' ? 'Approved on the page.' : 'Denied on the page.';
@@ -116,8 +119,12 @@ export async function answerPrompt(
116
119
  const entry = held.get(requestId);
117
120
  if (entry === undefined) return undefined;
118
121
  held.delete(requestId);
119
- if (behavior === 'allow' && via !== 'expiry') grant(entry);
120
- log.info({ requestId, tool: entry.tool, behavior, by: who === via ? via : `${via} ${who}` }, 'approvals: a Claude Code permission prompt answered');
122
+ const server = entry.origin === 'mcp-server';
123
+ if (behavior === 'allow' && via !== 'expiry' && !server) grant(entry);
124
+ log.info(
125
+ { requestId, tool: entry.tool, behavior, by: who === via ? via : `${via} ${who}` },
126
+ server ? 'approvals: an mcp.metro.box approval answered' : 'approvals: a Claude Code permission prompt answered',
127
+ );
121
128
  await entry.send(behavior);
122
129
  await tellChat(entry, via, behavior);
123
130
  return shown(entry);
@@ -152,7 +159,7 @@ export function revokeCallPrompts(route: CallRoute): void {
152
159
 
153
160
  export function settlePrompts(toolMatches: (tool: string) => boolean, args: Record<string, unknown>): void {
154
161
  for (const [id, entry] of held)
155
- if (toolMatches(entry.tool) && previewMatches(entry.preview, args)) {
162
+ if (entry.origin !== 'mcp-server' && toolMatches(entry.tool) && previewMatches(entry.preview, args)) {
156
163
  held.delete(id);
157
164
  log.info({ requestId: id, tool: entry.tool }, 'approvals: the call reached metro, so Claude Code already closed the prompt');
158
165
  }
@@ -25,6 +25,8 @@ import { connectorsReady, publishConnectors } from '../connectors/publication.js
25
25
  import { adoptOrgServerKeys } from '../connectors/org-adopt.js';
26
26
  import { readAgentKey } from '../connectors/agent-key.js';
27
27
  import { stopOrgConnectorSync, watchOrgConnectorSync } from '../connectors/org-sync-watch.js';
28
+ import { stopMcpServer, watchMcpServer } from '../mcp-server/link.js';
29
+ import { holdServerApproval } from '../mcp/server-approvals.js';
28
30
  import { ensureMetroPlugin } from '../claude/plugin-install.js';
29
31
  import { runningAsRoot } from '../metro-user/privilege.js';
30
32
  import { provisionAgentUser } from '../agent-user/provision.js';
@@ -161,6 +163,12 @@ installBearerSessions(agentsDir(), localOwner);
161
163
  startUploadReaper();
162
164
  startAttachReaper();
163
165
  watchOrgConnectorSync({ owner: localOwner, key: readAgentKey });
166
+ watchMcpServer({
167
+ owner: localOwner,
168
+ hold: (approval, answer) => {
169
+ holdServerApproval(approval, answer, emit);
170
+ },
171
+ });
164
172
  startCloudWatchPublisher();
165
173
  watchFallbackUsage();
166
174
  announceLocalEndpoint();
@@ -213,6 +221,9 @@ async function shutdown(): Promise<void> {
213
221
  shuttingDown = true;
214
222
  log.info('dispatcher shutting down');
215
223
  stopOrgConnectorSync(true);
224
+ stopMcpServer().catch((err: unknown) => {
225
+ log.warn({ err: errMsg(err) }, 'mcp-server: could not stop the approval relay');
226
+ });
216
227
  const leaving = leaveCallsForShutdown();
217
228
  unwatchSession();
218
229
  tunnel?.stop();
@@ -7,6 +7,7 @@ import {
7
7
  type SavedMedia,
8
8
  } from './media-note.js';
9
9
  import { buildWebhookNote } from './webhook-note.js';
10
+ import { isMetroNotice } from './notice.js';
10
11
  import { replyMeta } from './addressed.js';
11
12
  import { reactContent, reactionEmoji } from './react-note.js';
12
13
  import {
@@ -285,7 +286,9 @@ export class InboundRelay {
285
286
  capSet(this.allowedLines, ALLOWED_LINES_MAX);
286
287
  }
287
288
 
288
- private droppedSender(from: string, line: string, verified: unknown): boolean {
289
+ private droppedSender(ev: Record<string, unknown>, from: string, line: string): boolean {
290
+ if (isMetroNotice(ev)) return false;
291
+ const verified = ev.senderVerified;
289
292
  if (
290
293
  from.startsWith('metro://claude') ||
291
294
  from === 'metro://user' ||
@@ -311,7 +314,7 @@ export class InboundRelay {
311
314
  if (!this.deps.getStations().has(station)) return null;
312
315
  const from = str(ev.from);
313
316
  const line = str(ev.line);
314
- if (this.droppedSender(from, line, ev.senderVerified)) return null;
317
+ if (this.droppedSender(ev, from, line)) return null;
315
318
  const text = str(ev.text);
316
319
  if (!replay && this.isDuplicate(station, line, evType, str(ev.messageId))) {
317
320
  this.deps.log(
@@ -337,7 +340,7 @@ export class InboundRelay {
337
340
  if (await this.handlePermissionReply(ev, base)) return;
338
341
  await this.notify('notifications/claude/channel', {
339
342
  content:
340
- base.evType === 'system'
343
+ base.evType === 'system' && !isMetroNotice(ev)
341
344
  ? buildWebhookNote(base.text, str(ev.lineName), ev.payload)
342
345
  : base.text,
343
346
  meta: {
@@ -0,0 +1,22 @@
1
+ import { mintId, type MetroEvent } from '@metro-labs/core/events';
2
+ import { isRecord } from '@metro-labs/core/is-record';
3
+ import { asLine, Line } from '@metro-labs/core/lines';
4
+
5
+ const NOTICE_FROM = 'metro://metro';
6
+ const NOTICE_SOURCE = 'metro';
7
+
8
+ export function metroNotice(line: string, text: string): MetroEvent {
9
+ return {
10
+ id: mintId(),
11
+ ts: new Date().toISOString(),
12
+ station: Line.station(line) ?? '',
13
+ line: asLine(line),
14
+ from: asLine(NOTICE_FROM),
15
+ to: asLine(line),
16
+ text,
17
+ event: { type: 'system', source: NOTICE_SOURCE },
18
+ };
19
+ }
20
+
21
+ export const isMetroNotice = (ev: Record<string, unknown>): boolean =>
22
+ ev.from === NOTICE_FROM && isRecord(ev.event) && ev.event.type === 'system' && ev.event.source === NOTICE_SOURCE;
@@ -151,6 +151,7 @@ const PRIOR_METRO: ReadonlySet<string> = new Set([
151
151
  'e6b59eba825b4568dfa9ace5049fd9568a2e10d731719976ad2657084fd4e5f9',
152
152
  '5ce3f76d610aae91adc922d0edc6c8be1f27aa2fec4a5e3a6d6231a04fb9310b',
153
153
  'b910597d24f31fa479b130a2545f94dd23684d802d99e36b637fc57c818a33be',
154
+ 'd3f7ed0f5175b9a7a77f06f6204ae0020369d0c8f6e2724196bb3c5822d4d8ba',
154
155
  ]);
155
156
  const PRIOR_STAGE: ReadonlySet<string> = new Set([
156
157
  'dd7d30474765de3865ddc87c1cf3220161ded09502027cdff18597e5df0aab43',
@@ -5,10 +5,12 @@ import { join } from 'node:path';
5
5
  import { errMsg, log } from '@metro-labs/core/log';
6
6
  import { claudeDir } from '../claude/files.js';
7
7
  import { loopbackBase } from '../files/attach-serve.js';
8
+ import { MCP_SERVER_KEY, mcpServerUrl } from '../mcp-server/config.js';
8
9
 
9
10
  const MARKER = join('bin', 'metro-plugin.mjs');
10
11
  const FILE = '.mcp.json';
11
12
  const HELPER = 'node "${CLAUDE_PLUGIN_ROOT}/bin/metro-plugin.mjs" headers';
13
+ const MCP_HELPER = 'node "${CLAUDE_PLUGIN_ROOT}/bin/metro-plugin.mjs" mcp-headers';
12
14
  const SLUG_MAX = 40;
13
15
 
14
16
  export interface PluginRow {
@@ -39,22 +41,25 @@ function availableKey(wanted: string, reserved: ReadonlySet<string>): string {
39
41
  return key;
40
42
  }
41
43
 
42
- export function serverKeysOf(rows: PluginRow[], saved?: ReadonlyMap<string, string>): Map<string, string> {
43
- const keys = new Map(saved);
44
- const reserved = new Set(keys.values());
44
+ const reservedKeys = (): ReadonlySet<string> => new Set(mcpServerUrl() === null ? [] : [MCP_SERVER_KEY]);
45
+
46
+ export function serverKeysOf(rows: PluginRow[], saved?: ReadonlyMap<string, string>, reserved: ReadonlySet<string> = reservedKeys()): Map<string, string> {
47
+ const keys = new Map([...(saved ?? [])].filter(([, key]) => !reserved.has(key)));
48
+ const used = new Set([...keys.values(), ...reserved]);
45
49
  for (const row of [...rows].sort((a, b) => a.id.localeCompare(b.id))) {
46
50
  if (keys.has(row.id)) continue;
47
- const key = availableKey(serverKey(row.name), reserved);
48
- reserved.add(key);
51
+ const key = availableKey(serverKey(row.name), used);
52
+ used.add(key);
49
53
  keys.set(row.id, key);
50
54
  }
51
55
  return keys;
52
56
  }
53
57
 
54
- export function pluginServers(rows: PluginRow[], base: string, keys: ReadonlyMap<string, string> = serverKeysOf(rows)): Record<string, PluginServer> {
58
+ export function pluginServers(rows: PluginRow[], base: string, keys: ReadonlyMap<string, string> = serverKeysOf(rows), mcpUrl = mcpServerUrl()): Record<string, PluginServer> {
55
59
  const out: Record<string, PluginServer> = {};
56
60
  const visible = new Set(rows.map((row) => row.id));
57
61
  for (const [id, key] of keys) if (visible.has(id)) out[key] = { type: 'http', url: `${base}/relay/${id}`, headersHelper: HELPER };
62
+ if (mcpUrl !== null) out[MCP_SERVER_KEY] = { type: 'http', url: mcpUrl, headersHelper: MCP_HELPER };
58
63
  return out;
59
64
  }
60
65
 
@@ -19,6 +19,7 @@ import { errMsg } from '@metro-labs/core/log';
19
19
  import { channelLog, type McpSession } from './session.js';
20
20
  import { SessionSlot } from './session-slot.js';
21
21
  import { connectCallBridge } from './call-bridge.js';
22
+ import { connectServerApprovals } from './server-approvals.js';
22
23
  import { handleIncomingDiagnostic } from './diagnostics.js';
23
24
 
24
25
  const isInitialize = (b: unknown): boolean =>
@@ -120,6 +121,7 @@ export async function createMetroMcp(options: { liveEvents?: boolean } = {}): Pr
120
121
  const voiceSlot = new SessionSlot(false, 'voice');
121
122
  activeSlots = [slot, voiceSlot];
122
123
  connectCallBridge(() => slot.current);
124
+ connectServerApprovals(() => slot.current);
123
125
 
124
126
  const httpHandler = async (
125
127
  req: IncomingMessage,
@@ -75,15 +75,16 @@ function metroLines(tool: string, input: Record<string, unknown>): string[] {
75
75
 
76
76
  const ARGS_MAX = 8;
77
77
 
78
- function connectorLines(name: string, tool: string, input: Record<string, unknown> | undefined): string[] {
79
- const lines = [`Approval needed: ${tool}`, `Connector: ${name}`];
78
+ function argLines(input: Record<string, unknown> | undefined): string[] {
80
79
  const args = Object.entries(input ?? {}).flatMap(([key, value]) => {
81
80
  const text = argText(value);
82
81
  return text === undefined ? [] : [`${key}: ${shorten(text)}`];
83
82
  });
84
- lines.push(...args.slice(0, ARGS_MAX));
85
- if (args.length > ARGS_MAX) lines.push(`(${String(args.length - ARGS_MAX)} more)`);
86
- return lines;
83
+ return args.length > ARGS_MAX ? [...args.slice(0, ARGS_MAX), `(${String(args.length - ARGS_MAX)} more)`] : args;
84
+ }
85
+
86
+ function connectorLines(name: string, tool: string, input: Record<string, unknown> | undefined): string[] {
87
+ return [`Approval needed: ${tool}`, `Connector: ${name}`, ...argLines(input)];
87
88
  }
88
89
 
89
90
  function bodyLines(params: PermissionParams): string[] {
@@ -100,9 +101,27 @@ function bodyLines(params: PermissionParams): string[] {
100
101
  return [`Claude wants to run ${params.tool_name}: ${shorten(params.description)}`, '', ...raw];
101
102
  }
102
103
 
103
- export function promptBody(params: PermissionParams): string {
104
- const answer = `Reply "yes ${params.request_id}" or "no ${params.request_id}"`;
105
- const body = bodyLines(params).join('\n');
104
+ function withAnswer(requestId: string, lines: string[]): string {
105
+ const answer = `Reply "yes ${requestId}" or "no ${requestId}"`;
106
+ const body = lines.join('\n');
106
107
  const room = PROMPT_MAX - answer.length - 3;
107
108
  return `${body.length > room ? `${body.slice(0, room)}…` : body}\n\n${answer}`;
108
109
  }
110
+
111
+ export function promptBody(params: PermissionParams): string {
112
+ return withAnswer(params.request_id, bodyLines(params));
113
+ }
114
+
115
+ export interface ServerPrompt {
116
+ requestId: string;
117
+ tool: string;
118
+ connector: string | undefined;
119
+ preview: string;
120
+ }
121
+
122
+ export function serverPromptBody(prompt: ServerPrompt): string {
123
+ const { input } = readPreview(prompt.preview);
124
+ const raw = prompt.preview.trim() === '' ? [] : [shorten(prompt.preview, RAW_MAX)];
125
+ const args = input === undefined ? raw : argLines(input);
126
+ return withAnswer(prompt.requestId, [`Approval needed: ${prompt.tool}`, ...(prompt.connector === undefined ? [] : [`Connector: ${prompt.connector}`]), ...args]);
127
+ }
@@ -24,7 +24,7 @@ type PermissionRequest = z.infer<typeof PermissionRequestSchema>;
24
24
 
25
25
  const trainOf = (line: string): string => line.split('/')[2] ?? '';
26
26
 
27
- async function metroSend(line: string, text: string): Promise<void> {
27
+ export async function metroSend(line: string, text: string): Promise<void> {
28
28
  await metroCall(trainOf(line), 'send', { line, text });
29
29
  }
30
30
 
@@ -36,15 +36,17 @@ export interface PermissionRelayDeps {
36
36
  log: (...a: unknown[]) => void;
37
37
  }
38
38
 
39
- const callChat = (deps: PermissionRelayDeps): string | undefined => {
39
+ export type PromptLineDeps = Pick<PermissionRelayDeps, 'relay' | 'inScope' | 'live' | 'log'>;
40
+
41
+ const callChat = (deps: PromptLineDeps): string | undefined => {
40
42
  const route = sharedCalls.snapshot();
41
43
  return route !== null && deps.inScope(route.line) ? route.line : undefined;
42
44
  };
43
45
 
44
- const ownerChat = (deps: PermissionRelayDeps, owner: OwnerLine | undefined): string | undefined =>
46
+ const ownerChat = (deps: PromptLineDeps, owner: OwnerLine | undefined): string | undefined =>
45
47
  owner !== undefined && mayApprove(owner.line, owner.from) && deps.inScope(owner.line) && lineReceives(owner.line) ? owner.line : undefined;
46
48
 
47
- function relayLine(deps: PermissionRelayDeps, requestId: string, callLine?: string): string | undefined {
49
+ export function relayLine(deps: PromptLineDeps, requestId: string, callLine?: string): string | undefined {
48
50
  if (!deps.live()) {
49
51
  deps.log('permission_request: live events are off, so no chat answer can arrive; held for the page only', requestId);
50
52
  return undefined;
@@ -0,0 +1,109 @@
1
+ import type { MetroEvent } from '@metro-labs/core/events';
2
+ import { errMsg, log } from '@metro-labs/core/log';
3
+ import type { McpApproval, McpDecision } from '@metro-labs/http/mcp-server-protocol';
4
+ import { holdPrompt, isPromptHeld, type Behavior } from '../approvals/pending.js';
5
+ import { ownerLine } from '../approvals/owner-line.js';
6
+ import { metroNotice } from '../channels/notice.js';
7
+ import { connectorGates } from '../connectors/gates.js';
8
+ import { serverKey } from '../connectors/plugin-sync.js';
9
+ import type { AnswerOutcome } from '../mcp-server/link.js';
10
+ import { serverPromptBody } from './permission-prompt.js';
11
+ import { metroSend, relayLine } from './permission-relay.js';
12
+ import { channelLog, type McpSession } from './session.js';
13
+
14
+ const RECENT_MS = 10 * 60_000;
15
+ const RECENT_MAX = 1_000;
16
+
17
+ const owner = {};
18
+ const recent = new Map<string, number>();
19
+ let current: () => McpSession | undefined = () => undefined;
20
+
21
+ export function connectServerApprovals(session: () => McpSession | undefined): void {
22
+ current = session;
23
+ }
24
+
25
+ function promptLineFor(requestId: string): string | undefined {
26
+ const session = current();
27
+ if (session === undefined) return undefined;
28
+ return relayLine({ relay: session.relay, inScope: (line) => session.inScope(line), live: session.live, log: channelLog }, requestId);
29
+ }
30
+
31
+ const noticeLineFor = (line: string | undefined): string | undefined => line ?? current()?.relay.knownLine ?? ownerLine()?.line;
32
+
33
+ function remember(id: string, now = Date.now()): void {
34
+ for (const [seen, at] of recent) if (now - at >= RECENT_MS) recent.delete(seen);
35
+ recent.set(id, now);
36
+ while (recent.size > RECENT_MAX) {
37
+ const oldest = recent.keys().next();
38
+ if (oldest.done) break;
39
+ recent.delete(oldest.value);
40
+ }
41
+ }
42
+
43
+ const answeredRecently = (id: string, now = Date.now()): boolean => now - (recent.get(id) ?? -Infinity) < RECENT_MS;
44
+
45
+ function connectorName(key: string): string | undefined {
46
+ const names = new Set(connectorGates().filter((gate) => gate.server === key || serverKey(gate.name) === key).map((gate) => gate.name));
47
+ return names.size === 1 ? [...names][0] : undefined;
48
+ }
49
+
50
+ const noticeText = (tool: string, id: string, behavior: Behavior): string =>
51
+ behavior === 'allow' ? `Approved: ${tool} (${id}). Call call_tool again with the same arguments and approval: "${id}".` : `Denied: ${tool} (${id}).`;
52
+
53
+ const requestedAt = (at: number, now = Date.now()): number => (Number.isFinite(at) && at > 0 ? Math.min(at, now) : now);
54
+
55
+ interface Held {
56
+ id: string;
57
+ tool: string;
58
+ line: string | undefined;
59
+ answer: (decision: McpDecision) => Promise<AnswerOutcome>;
60
+ publish: (event: MetroEvent) => void;
61
+ }
62
+
63
+ async function deliver(held: Held, behavior: Behavior): Promise<void> {
64
+ const outcome = await held.answer(behavior);
65
+ if (outcome !== 'acknowledged') {
66
+ log.info({ id: held.id, outcome }, 'mcp-server: the answer was not taken, so the agent is not told');
67
+ return;
68
+ }
69
+ const line = noticeLineFor(held.line);
70
+ if (line === undefined) {
71
+ log.warn({ id: held.id }, 'mcp-server: no chat is known to tell the agent the answer');
72
+ return;
73
+ }
74
+ held.publish(metroNotice(line, noticeText(held.tool, held.id, behavior)));
75
+ }
76
+
77
+ function sender(held: Held): (behavior: Behavior) => Promise<void> {
78
+ return (behavior) => {
79
+ remember(held.id);
80
+ deliver(held, behavior).catch((err: unknown) => {
81
+ log.warn({ id: held.id, err: errMsg(err) }, 'mcp-server: the answer could not be delivered');
82
+ });
83
+ return Promise.resolve();
84
+ };
85
+ }
86
+
87
+ export function holdServerApproval(approval: McpApproval, answer: Held['answer'], publish: Held['publish']): boolean {
88
+ if (isPromptHeld(approval.id) || answeredRecently(approval.id)) return false;
89
+ const tool = `${approval.connector}.${approval.tool}`;
90
+ const name = connectorName(approval.connector);
91
+ const line = promptLineFor(approval.id);
92
+ const held: Held = { id: approval.id, tool, line, answer, publish };
93
+ holdPrompt(
94
+ { requestId: approval.id, tool, description: name ?? approval.connector, preview: approval.preview, line, at: requestedAt(approval.requestedAt), origin: 'mcp-server' },
95
+ owner,
96
+ sender(held),
97
+ line === undefined ? undefined : async (text) => metroSend(line, text),
98
+ );
99
+ log.info({ id: approval.id, tool, inChat: line !== undefined }, 'mcp-server: an approval is waiting for the owner');
100
+ if (line !== undefined)
101
+ metroSend(line, serverPromptBody({ requestId: approval.id, tool, connector: name, preview: approval.preview })).catch((err: unknown) => {
102
+ log.warn({ id: approval.id, err: errMsg(err) }, 'mcp-server: the approval prompt could not be posted');
103
+ });
104
+ return true;
105
+ }
106
+
107
+ export function forgetServerApprovals(): void {
108
+ recent.clear();
109
+ }
@@ -0,0 +1,29 @@
1
+ import { log } from '@metro-labs/core/log';
2
+
3
+ export const MCP_SERVER_KEY = 'mcp';
4
+
5
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
6
+
7
+ let refused: string | undefined;
8
+
9
+ function usable(raw: string): boolean {
10
+ let url: URL;
11
+ try {
12
+ url = new URL(raw);
13
+ } catch {
14
+ return false;
15
+ }
16
+ const secure = url.protocol === 'https:' || (url.protocol === 'http:' && LOOPBACK.has(url.hostname));
17
+ return secure && url.username === '' && url.password === '' && url.search === '' && url.hash === '';
18
+ }
19
+
20
+ export function mcpServerUrl(env: NodeJS.ProcessEnv = process.env): string | null {
21
+ const raw = env.METRO_MCP_SERVER_URL?.trim() ?? '';
22
+ if (raw === '') return null;
23
+ if (usable(raw)) return raw.replace(/\/+$/, '');
24
+ if (refused !== raw) {
25
+ refused = raw;
26
+ log.warn({ url: raw }, 'mcp-server: METRO_MCP_SERVER_URL must be an https url (http only on this machine), so it is ignored');
27
+ }
28
+ return null;
29
+ }
@@ -0,0 +1,186 @@
1
+ import { errMsg, log } from '@metro-labs/core/log';
2
+ import { MCP_APPROVAL_WAIT_MS, MCP_PATHS, mcpApprovalsOf, type McpApproval, type McpDecision } from '@metro-labs/http/mcp-server-protocol';
3
+ import { agentsDir } from '../agents/files.js';
4
+ import { readAgentKey, type AgentKey } from '../connectors/agent-key.js';
5
+ import { agentCall, MetroApiRefusal, type AgentFetch, type MetroApi } from '../connectors/metro-api.js';
6
+ import { mcpServerUrl } from './config.js';
7
+ import { ensureMcpToken } from './token.js';
8
+
9
+ export type AnswerOutcome = 'acknowledged' | 'gone' | 'stopped';
10
+
11
+ export type HoldApproval = (approval: McpApproval, answer: (decision: McpDecision) => Promise<AnswerOutcome>) => void;
12
+
13
+ export interface McpTiming {
14
+ retryMs: number;
15
+ retryMaxMs: number;
16
+ idleMs: number;
17
+ minPollMs: number;
18
+ pollTimeoutMs: number;
19
+ }
20
+
21
+ export interface McpLinkDeps {
22
+ url: string;
23
+ owner: () => string | null;
24
+ key: () => AgentKey | null;
25
+ dir: () => string;
26
+ hold: HoldApproval;
27
+ fetch?: AgentFetch;
28
+ timing?: Partial<McpTiming>;
29
+ }
30
+
31
+ const TIMING: McpTiming = { retryMs: 2_000, retryMaxMs: 60_000, idleMs: 30_000, minPollMs: 1_000, pollTimeoutMs: MCP_APPROVAL_WAIT_MS + 15_000 };
32
+ const APPROVALS_MAX = 256 * 1024;
33
+
34
+ const refusedWith = (err: unknown, status: number): boolean => err instanceof MetroApiRefusal && err.upstreamStatus === status;
35
+
36
+ export class McpServerLink {
37
+ private readonly api: MetroApi;
38
+ private readonly timing: McpTiming;
39
+ private readonly abort = new AbortController();
40
+ private registered: string | null = null;
41
+ private failures = 0;
42
+ private running: Promise<void> | null = null;
43
+
44
+ constructor(private readonly deps: McpLinkDeps) {
45
+ this.api = { base: deps.url, fetch: deps.fetch ?? ((url, init) => fetch(url, init)), now: () => Date.now() };
46
+ this.timing = { ...TIMING, ...deps.timing };
47
+ }
48
+
49
+ private get stopped(): boolean {
50
+ return this.abort.signal.aborted;
51
+ }
52
+
53
+ start(): void {
54
+ this.running ??= this.run().catch((err: unknown) => {
55
+ log.error({ err: errMsg(err) }, 'mcp-server: the token and approval loop stopped');
56
+ });
57
+ }
58
+
59
+ async stop(): Promise<void> {
60
+ this.abort.abort();
61
+ await this.running;
62
+ }
63
+
64
+ async answer(id: string, decision: McpDecision): Promise<AnswerOutcome> {
65
+ for (let attempt = 0; !this.stopped; attempt += 1) {
66
+ if (attempt > 0) await this.pause(this.delay(attempt));
67
+ const key = this.enrolledKey();
68
+ if (key === null || this.stopped) continue;
69
+ try {
70
+ await agentCall(this.api, key, MCP_PATHS.answer, { id, decision }, { signal: this.abort.signal });
71
+ log.info({ id, decision }, 'mcp-server: the owner answer reached the server');
72
+ return 'acknowledged';
73
+ } catch (err) {
74
+ if (refusedWith(err, 404)) return 'gone';
75
+ if (refusedWith(err, 401)) this.registered = null;
76
+ if (!this.stopped) log.warn({ id, attempt: attempt + 1, err: errMsg(err) }, 'mcp-server: the owner answer did not reach the server; trying again');
77
+ }
78
+ }
79
+ return 'stopped';
80
+ }
81
+
82
+ private enrolledKey(): AgentKey | null {
83
+ try {
84
+ const key = this.deps.key();
85
+ const owner = this.deps.owner();
86
+ return key !== null && owner !== null && key.enrollment?.organization === owner ? key : null;
87
+ } catch (err) {
88
+ log.warn({ err: errMsg(err) }, 'mcp-server: the agent key could not be read');
89
+ return null;
90
+ }
91
+ }
92
+
93
+ private async run(): Promise<void> {
94
+ while (!this.stopped) {
95
+ const key = this.enrolledKey();
96
+ if (key === null) {
97
+ this.registered = null;
98
+ await this.pause(this.timing.idleMs);
99
+ } else if (await this.step(key)) this.failures = 0;
100
+ else await this.pause(this.delay(++this.failures));
101
+ }
102
+ }
103
+
104
+ private async step(key: AgentKey): Promise<boolean> {
105
+ let doing = 'register the MCP token';
106
+ try {
107
+ await this.register(key);
108
+ doing = 'ask for approvals';
109
+ await this.poll(key);
110
+ return true;
111
+ } catch (err) {
112
+ if (this.stopped) return true;
113
+ if (refusedWith(err, 401)) this.registered = null;
114
+ log.warn({ server: this.deps.url, failures: this.failures + 1, err: errMsg(err) }, `mcp-server: could not ${doing}; trying again`);
115
+ return false;
116
+ }
117
+ }
118
+
119
+ private async register(key: AgentKey): Promise<void> {
120
+ const token = ensureMcpToken(key.keyId, this.deps.dir());
121
+ if (token.fresh) log.info({ keyId: key.keyId }, 'mcp-server: made a new MCP token for this agent');
122
+ const binding = `${key.keyId} ${token.hash}`;
123
+ if (this.registered === binding) return;
124
+ await agentCall(this.api, key, MCP_PATHS.token, { tokenHash: token.hash }, { signal: this.abort.signal });
125
+ this.registered = binding;
126
+ log.info({ server: this.deps.url, keyId: key.keyId }, 'mcp-server: the MCP token is registered');
127
+ }
128
+
129
+ private async poll(key: AgentKey): Promise<void> {
130
+ const started = Date.now();
131
+ const answer = await agentCall(this.api, key, MCP_PATHS.approvals, {}, { signal: this.abort.signal, timeoutMs: this.timing.pollTimeoutMs, answerMax: APPROVALS_MAX });
132
+ const approvals = mcpApprovalsOf(answer);
133
+ for (const approval of approvals) this.receive(approval);
134
+ const rest = this.timing.minPollMs - (Date.now() - started);
135
+ if (approvals.length === 0 && rest > 0) await this.pause(rest);
136
+ }
137
+
138
+ private receive(approval: McpApproval): void {
139
+ try {
140
+ this.deps.hold(approval, (decision) => this.answer(approval.id, decision));
141
+ } catch (err) {
142
+ log.warn({ id: approval.id, err: errMsg(err) }, 'mcp-server: an approval could not be held');
143
+ }
144
+ }
145
+
146
+ private delay(attempt: number): number {
147
+ return Math.min(this.timing.retryMs * 2 ** Math.min(attempt - 1, 20), this.timing.retryMaxMs);
148
+ }
149
+
150
+ private pause(ms: number): Promise<void> {
151
+ if (this.stopped) return Promise.resolve();
152
+ const signal = this.abort.signal;
153
+ return new Promise((resolve) => {
154
+ const done = (): void => {
155
+ clearTimeout(timer);
156
+ signal.removeEventListener('abort', done);
157
+ resolve();
158
+ };
159
+ const timer = setTimeout(done, ms);
160
+ timer.unref();
161
+ signal.addEventListener('abort', done, { once: true });
162
+ });
163
+ }
164
+ }
165
+
166
+ let active: McpServerLink | null = null;
167
+
168
+ export interface McpServerWatch {
169
+ owner: () => string | null;
170
+ hold: HoldApproval;
171
+ }
172
+
173
+ export function watchMcpServer(watch: McpServerWatch, env: NodeJS.ProcessEnv = process.env): boolean {
174
+ const url = mcpServerUrl(env);
175
+ if (url === null || active !== null) return false;
176
+ active = new McpServerLink({ url, owner: watch.owner, key: () => readAgentKey(), dir: agentsDir, hold: watch.hold });
177
+ active.start();
178
+ log.info({ server: url }, 'mcp-server: connector approvals relay from this server once the agent is enrolled');
179
+ return true;
180
+ }
181
+
182
+ export async function stopMcpServer(): Promise<void> {
183
+ const link = active;
184
+ active = null;
185
+ await link?.stop();
186
+ }
@@ -0,0 +1,50 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { isRecord } from '@metro-labs/core/is-record';
5
+ import { writeSecure } from '@metro-labs/core/secure-fs';
6
+ import { MCP_TOKEN_RE } from '@metro-labs/http/mcp-server-protocol';
7
+
8
+ export const MCP_TOKEN_FILE = 'mcp-token';
9
+ const BINDING_FILE = 'mcp-token.json';
10
+
11
+ export interface McpToken {
12
+ token: string;
13
+ hash: string;
14
+ fresh: boolean;
15
+ }
16
+
17
+ export const mcpTokenHash = (token: string): string => createHash('sha256').update(token).digest('base64url');
18
+
19
+ const readText = (path: string): string | null => {
20
+ try {
21
+ return readFileSync(path, 'utf8');
22
+ } catch {
23
+ return null;
24
+ }
25
+ };
26
+
27
+ export function readMcpToken(dir: string): string | null {
28
+ const text = readText(join(dir, MCP_TOKEN_FILE))?.trim() ?? '';
29
+ return MCP_TOKEN_RE.test(text) ? text : null;
30
+ }
31
+
32
+ function boundKeyId(dir: string): string | null {
33
+ const text = readText(join(dir, BINDING_FILE));
34
+ if (text === null) return null;
35
+ try {
36
+ const parsed: unknown = JSON.parse(text);
37
+ return isRecord(parsed) && parsed.version === 1 && typeof parsed.keyId === 'string' ? parsed.keyId : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ export function ensureMcpToken(keyId: string, dir: string): McpToken {
44
+ const kept = readMcpToken(dir);
45
+ if (kept !== null && boundKeyId(dir) === keyId) return { token: kept, hash: mcpTokenHash(kept), fresh: false };
46
+ const token = `mcp_${randomBytes(32).toString('base64url')}`;
47
+ writeSecure(join(dir, MCP_TOKEN_FILE), `${token}\n`);
48
+ writeSecure(join(dir, BINDING_FILE), `${JSON.stringify({ version: 1, keyId })}\n`);
49
+ return { token, hash: mcpTokenHash(token), fresh: true };
50
+ }
@@ -24,7 +24,8 @@
24
24
  "./connector-input": "./src/connector-input.ts",
25
25
  "./connector-delivery": "./src/connector-delivery.ts",
26
26
  "./connector-migration": "./src/connector-migration.ts",
27
- "./connector-grant": "./src/connector-grant.ts"
27
+ "./connector-grant": "./src/connector-grant.ts",
28
+ "./mcp-server-protocol": "./src/mcp-server-protocol.ts"
28
29
  },
29
30
  "scripts": {
30
31
  "build": "tsc",
@@ -9,6 +9,7 @@ export interface RemoteTool {
9
9
  title: string;
10
10
  description: string;
11
11
  readOnly: boolean;
12
+ inputSchema?: Record<string, unknown>;
12
13
  }
13
14
 
14
15
  const TIMEOUT_MS = 15_000;
@@ -39,7 +40,7 @@ function toTool(raw: unknown): RemoteTool | null {
39
40
  if (!isRecord(raw) || typeof raw.name !== 'string') return null;
40
41
  const annotations = isRecord(raw.annotations) ? raw.annotations : {};
41
42
  const title = typeof raw.title === 'string' && raw.title !== '' ? raw.title : raw.name;
42
- return { name: raw.name, title, description: typeof raw.description === 'string' ? raw.description : '', readOnly: annotations.readOnlyHint === true };
43
+ return { name: raw.name, title, description: typeof raw.description === 'string' ? raw.description : '', readOnly: annotations.readOnlyHint === true, ...(isRecord(raw.inputSchema) ? { inputSchema: raw.inputSchema } : {}) };
43
44
  }
44
45
 
45
46
  async function endSession(url: URL, auth: ConnectorAuth, session: Map<string, string>, fetchImpl?: FetchLike): Promise<void> {
@@ -0,0 +1,81 @@
1
+ import { isRecord } from '@metro-labs/core/is-record';
2
+
3
+ export const MCP_PATHS = { token: '/v1/token', approvals: '/v1/approvals', answer: '/v1/approvals/answer' } as const;
4
+ export const MCP_FIND_DEFAULT = 5;
5
+ export const MCP_FIND_MAX = 20;
6
+ export const MCP_CALLS_MAX = 10;
7
+ export const MCP_QUERY_MAX = 500;
8
+ export const MCP_APPROVAL_WAIT_MS = 25_000;
9
+ export const MCP_APPROVAL_ID_RE = /^[a-km-z]{5}$/;
10
+ export const MCP_TOKEN_RE = /^mcp_[A-Za-z0-9_-]{43}$/;
11
+
12
+ export type McpErrorCode = 'blocked' | 'needs_approval' | 'denied' | 'not_found' | 'signin' | 'unavailable' | 'vendor_error' | 'invalid';
13
+
14
+ export interface McpFindRequest {
15
+ query: string;
16
+ max_results: number;
17
+ }
18
+
19
+ export interface McpCall {
20
+ tool: string;
21
+ arguments: Record<string, unknown>;
22
+ approval?: string;
23
+ }
24
+
25
+ export interface McpApproval {
26
+ id: string;
27
+ connector: string;
28
+ tool: string;
29
+ preview: string;
30
+ requestedAt: number;
31
+ }
32
+
33
+ export type McpDecision = 'allow' | 'deny';
34
+
35
+ const TOOL_RE = /^([a-z0-9][a-z0-9-]{0,62})\.([A-Za-z0-9_][A-Za-z0-9_.:/-]{0,127})$/;
36
+
37
+ export function splitMcpTool(name: string): { connector: string; tool: string } | null {
38
+ const match = TOOL_RE.exec(name);
39
+ return match === null ? null : { connector: match[1] ?? '', tool: match[2] ?? '' };
40
+ }
41
+
42
+ export function mcpFindRequestOf(raw: unknown): McpFindRequest {
43
+ if (!isRecord(raw) || typeof raw.query !== 'string' || raw.query.trim() === '' || raw.query.length > MCP_QUERY_MAX) throw new Error('find_tools needs a query of 1 to 500 characters.');
44
+ const wanted = raw.max_results ?? MCP_FIND_DEFAULT;
45
+ if (typeof wanted !== 'number' || !Number.isInteger(wanted) || wanted < 1) throw new Error('max_results must be a whole number from 1.');
46
+ return { query: raw.query.trim(), max_results: Math.min(wanted, MCP_FIND_MAX) };
47
+ }
48
+
49
+ function callOf(raw: unknown): McpCall {
50
+ if (!isRecord(raw) || typeof raw.tool !== 'string' || splitMcpTool(raw.tool) === null) throw new Error('Each call needs a tool named <connector>.<tool>, as find_tools returns it.');
51
+ const args = raw.arguments ?? {};
52
+ if (!isRecord(args)) throw new Error(`The arguments of ${raw.tool} must be an object.`);
53
+ if (raw.approval !== undefined && (typeof raw.approval !== 'string' || !MCP_APPROVAL_ID_RE.test(raw.approval))) throw new Error('approval must be the 5-letter id the approval request gave.');
54
+ return { tool: raw.tool, arguments: args, ...(typeof raw.approval === 'string' ? { approval: raw.approval } : {}) };
55
+ }
56
+
57
+ export function mcpCallsOf(raw: unknown): McpCall[] {
58
+ const calls = isRecord(raw) ? raw.calls : undefined;
59
+ if (!Array.isArray(calls) || calls.length === 0 || calls.length > MCP_CALLS_MAX) throw new Error(`call_tool needs 1 to ${String(MCP_CALLS_MAX)} calls.`);
60
+ return calls.map(callOf);
61
+ }
62
+
63
+ export function mcpTokenHashOf(raw: unknown): string {
64
+ if (!isRecord(raw) || typeof raw.tokenHash !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(raw.tokenHash)) throw new Error('tokenHash must be the base64url SHA-256 of the MCP token.');
65
+ return raw.tokenHash;
66
+ }
67
+
68
+ function approvalOf(raw: unknown): McpApproval | null {
69
+ if (!isRecord(raw) || typeof raw.id !== 'string' || !MCP_APPROVAL_ID_RE.test(raw.id) || typeof raw.connector !== 'string' || typeof raw.tool !== 'string' || typeof raw.preview !== 'string' || typeof raw.requestedAt !== 'number') return null;
70
+ return { id: raw.id, connector: raw.connector, tool: raw.tool, preview: raw.preview, requestedAt: raw.requestedAt };
71
+ }
72
+
73
+ export function mcpApprovalsOf(raw: unknown): McpApproval[] {
74
+ if (!isRecord(raw) || !Array.isArray(raw.approvals)) throw new Error('Mcp sent an unreadable approval list.');
75
+ return raw.approvals.map(approvalOf).filter((row): row is McpApproval => row !== null);
76
+ }
77
+
78
+ export function mcpAnswerOf(raw: unknown): { id: string; decision: McpDecision } {
79
+ if (!isRecord(raw) || typeof raw.id !== 'string' || !MCP_APPROVAL_ID_RE.test(raw.id) || (raw.decision !== 'allow' && raw.decision !== 'deny')) throw new Error('An answer needs the approval id and allow or deny.');
80
+ return { id: raw.id, decision: raw.decision };
81
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.303"
2
+ "version": "0.1.0-beta.304"
3
3
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.303",
2
+ "version": "0.1.0-beta.304",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",