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

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 (61) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +3 -4
  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 +9 -15
  6. package/runtime/marketplace/plugin/bin/policy.mjs +1 -32
  7. package/runtime/node_modules/@metro-labs/core/package.json +0 -1
  8. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +4 -1
  9. package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +3 -5
  10. package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +11 -10
  11. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +16 -9
  12. package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +6 -3
  13. package/runtime/node_modules/@metro-labs/daemon/src/channels/notice.ts +22 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +1 -3
  15. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +2 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +0 -2
  17. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +11 -51
  18. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +2 -0
  19. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +22 -15
  20. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +6 -4
  21. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +0 -14
  22. package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +108 -0
  23. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/announced.ts +68 -0
  24. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +31 -0
  25. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/link.ts +186 -0
  26. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/token.ts +50 -0
  27. package/runtime/node_modules/@metro-labs/daemon/src/policy/policy.ts +6 -5
  28. package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +0 -2
  29. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -27
  30. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -5
  31. package/runtime/node_modules/@metro-labs/http/package.json +3 -3
  32. package/runtime/node_modules/@metro-labs/http/src/connector-grant.ts +0 -51
  33. package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +2 -1
  34. package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +81 -0
  35. package/runtime/runtime.json +1 -1
  36. package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +0 -1
  37. package/runtime/sdk-runner/runner.json +1 -1
  38. package/runtime/node_modules/@metro-labs/core/src/connector-sync.ts +0 -17
  39. package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +0 -104
  40. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-adopt.ts +0 -57
  41. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-relay.ts +0 -61
  42. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +0 -115
  43. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +0 -271
  44. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-api.ts +0 -25
  45. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +0 -173
  46. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-setting.ts +0 -60
  47. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +0 -38
  48. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +0 -208
  49. package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +0 -40
  50. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-id.ts +0 -2
  51. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-policy.ts +0 -90
  52. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +0 -10
  53. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-target.ts +0 -4
  54. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +0 -287
  55. package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +0 -59
  56. package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +0 -1
  57. package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +0 -116
  58. package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +0 -19
  59. package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +0 -178
  60. package/runtime/node_modules/@metro-labs/http/src/transfer-crypto.ts +0 -44
  61. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/connector-sync.ts +0 -17
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.305",
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.305",
4
+ "description": "Metro for Claude Code, installed by the metro daemon on this machine: one `mcp` server on mcp.metro.box for the organization connectors (find_tools, call_tool), the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever reaches this machine.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
7
7
  "url": "https://metro.box"
@@ -11,7 +11,6 @@
11
11
  "keywords": [
12
12
  "metro",
13
13
  "mcp",
14
- "connectors",
15
- "relay"
14
+ "connectors"
16
15
  ]
17
16
  }
@@ -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.** Every connector the organization gave this agent sits behind the `mcp` server's 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,27 +4,21 @@ 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
- function readAgent() {
9
- const path = join(agentsDir(), 'agent.json');
10
- let parsed;
9
+ function readMcpToken() {
11
10
  try {
12
- parsed = JSON.parse(readFileSync(path, 'utf8'));
11
+ const token = readFileSync(join(agentsDir(), 'mcp-token'), 'utf8').trim();
12
+ return MCP_TOKEN.test(token) ? token : null;
13
13
  } catch {
14
- throw new Error(`no agent on this machine yet (${path})`);
14
+ return null;
15
15
  }
16
- if (typeof parsed?.key !== 'string') throw new Error(`no agent key in ${path}`);
17
- return parsed;
18
16
  }
19
17
 
20
- if (process.argv[2] !== 'headers') {
21
- process.stderr.write('usage: metro-plugin.mjs headers\n');
18
+ if (process.argv[2] !== 'mcp-headers') {
19
+ process.stderr.write('usage: metro-plugin.mjs mcp-headers\n');
22
20
  process.exit(1);
23
21
  }
24
22
 
25
- try {
26
- process.stdout.write(`${JSON.stringify({ Authorization: `Bearer ${readAgent().key}` })}\n`);
27
- } catch (err) {
28
- process.stderr.write(`metro: ${err.message}\n`);
29
- process.stdout.write('{}\n');
30
- }
23
+ const token = readMcpToken();
24
+ if (token !== null) process.stdout.write(`${JSON.stringify({ Authorization: `Bearer ${token}` })}\n`);
@@ -56,42 +56,11 @@ function decide(policy, name, group) {
56
56
  return access in RANK ? access : 'allow';
57
57
  }
58
58
 
59
- const normalized = (name) => name.replace(/[^a-zA-Z0-9_-]/g, '_');
60
-
61
- function connectorCall(snap, toolName) {
62
- const entries = Object.entries(isRecord(snap.connectors) ? snap.connectors : {});
63
- const legacy = entries.filter(([, entry]) => isRecord(entry) && !/^org-[A-Za-z0-9][A-Za-z0-9_-]{10}$/.test(text(entry.id)) && toolName.startsWith(`mcp__${normalized(`metro.box ${text(entry.name)}`)}__`));
64
- if (legacy.length > 1) return { name: 'ambiguous connector name; use its instance server key', tool: toolName, group: 'write', policy: { write: 'deny' } };
65
- for (const [server, entry] of entries) {
66
- if (!isRecord(entry)) continue;
67
- const name = text(entry.name);
68
- const prefixes = [`mcp__plugin_metro_${server}__`, `mcp__${server}__`];
69
- if (!/^org-[A-Za-z0-9][A-Za-z0-9_-]{10}$/.test(text(entry.id))) prefixes.push(`mcp__${normalized(`metro.box ${name}`)}__`);
70
- const prefix = prefixes.find((p) => toolName.startsWith(p) && toolName.length > p.length);
71
- if (prefix === undefined) continue;
72
- const called = toolName.slice(prefix.length);
73
- const tools = isRecord(entry.tools) ? entry.tools : {};
74
- const policy = isRecord(entry.policy) ? entry.policy : {};
75
- const known = [...Object.keys(tools), ...Object.keys(isRecord(policy.tools) ? policy.tools : {})];
76
- const tool = known.find((k) => k === called) ?? known.find((k) => normalized(k) === called) ?? called;
77
- return { name: name || server, tool, group: tools[tool] === 'read' ? 'read' : 'write', policy };
78
- }
79
- return null;
80
- }
81
-
82
- function connectorVerdict(snap, toolName) {
83
- const call = connectorCall(snap, toolName);
84
- if (call === null) return null;
85
- const access = decide(call.policy, call.tool, call.group);
86
- return access === 'allow' ? null : { access, tool: call.tool, owner: call.name, where: `${call.tool} on ${call.name}` };
87
- }
88
-
89
59
  export function policyVerdict(toolName, input) {
90
60
  const name = METRO_TOOL.exec(toolName)?.[1];
91
- if (name === undefined && !toolName.startsWith('mcp__')) return null;
61
+ if (name === undefined) return null;
92
62
  const snap = readSnapshot();
93
63
  if (snap === null) return null;
94
- if (name === undefined) return connectorVerdict(snap, toolName);
95
64
  const tools = isRecord(snap.tools) ? snap.tools : {};
96
65
  const group = tools[name] === 'read' ? 'read' : 'write';
97
66
  const accounts = isRecord(snap.accounts) ? snap.accounts : {};
@@ -6,7 +6,6 @@
6
6
  "type": "module",
7
7
  "exports": {
8
8
  "./log": "./src/log.ts",
9
- "./connector-sync": "./src/connector-sync.ts",
10
9
  "./events": "./src/events.ts",
11
10
  "./is-record": "./src/is-record.ts",
12
11
  "./runner-activity": "./src/runner-activity.ts",
@@ -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
 
@@ -26,7 +26,6 @@ export interface AgentApiDeps extends AccountApiDeps {
26
26
  toolGroups?: () => Record<string, { name: string; group: string }[]>;
27
27
  attachable?: string[];
28
28
  features?: string[];
29
- connectorIds: (agentIds: string[]) => Promise<Map<string, string[]>>;
30
29
  }
31
30
 
32
31
  type Routable =
@@ -51,8 +50,8 @@ export function target(path: string): Target {
51
50
  return id === null ? { kind: 'unknown' } : subTarget(id, segments.slice(1));
52
51
  }
53
52
 
54
- function agentPayload(agent: AgentSummary, connectors: Map<string, string[]>): Record<string, unknown> {
55
- return { id: agent.id, name: agent.name, connector_ids: connectors.get(agent.id) ?? [] };
53
+ function agentPayload(agent: AgentSummary): Record<string, unknown> {
54
+ return { id: agent.id, name: agent.name };
56
55
  }
57
56
 
58
57
  function wantsAccounts(req: IncomingMessage): boolean {
@@ -66,9 +65,8 @@ async function handleList(
66
65
  deps: AgentApiDeps,
67
66
  ): Promise<void> {
68
67
  const list = await deps.listAgents();
69
- const connectors = await deps.connectorIds(list.map((a) => a.id));
70
68
  const base = {
71
- agents: list.map((a) => agentPayload(a, connectors)),
69
+ agents: list.map((a) => agentPayload(a)),
72
70
  capabilities: deps.capabilities(),
73
71
  features: deps.features ?? [],
74
72
  attachable: deps.attachable ?? ATTACHABLE,
@@ -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);
@@ -138,21 +145,15 @@ export function cancelPrompt(requestId: string, owner: object): void {
138
145
  });
139
146
  }
140
147
 
141
- export function revokeToolPrompts(matches: (tool: string) => boolean): void {
142
- grants = grants.filter((entry) => !matches(entry.tool));
143
- for (const entry of held.values())
144
- if (matches(entry.tool)) cancelPrompt(entry.requestId, entry.owner);
145
- }
146
-
147
148
  export function revokeCallPrompts(route: CallRoute): void {
148
149
  grants = grants.filter((entry) => entry.call === undefined || !sameCall(entry.call.route, route));
149
150
  for (const entry of held.values())
150
151
  if (entry.call !== undefined && sameCall(entry.call.route, route)) cancelPrompt(entry.requestId, entry.owner);
151
152
  }
152
153
 
153
- export function settlePrompts(toolMatches: (tool: string) => boolean, args: Record<string, unknown>): void {
154
+ function settlePrompts(toolMatches: (tool: string) => boolean, args: Record<string, unknown>): void {
154
155
  for (const [id, entry] of held)
155
- if (toolMatches(entry.tool) && previewMatches(entry.preview, args)) {
156
+ if (entry.origin !== 'mcp-server' && toolMatches(entry.tool) && previewMatches(entry.preview, args)) {
156
157
  held.delete(id);
157
158
  log.info({ requestId: id, tool: entry.tool }, 'approvals: the call reached metro, so Claude Code already closed the prompt');
158
159
  }
@@ -21,10 +21,10 @@ import {
21
21
  trainEventToMetroEvent,
22
22
  } from '../routes/http.js';
23
23
  import { agentsDir, fileSource } from '../agents/files.js';
24
- import { connectorsReady, publishConnectors } from '../connectors/publication.js';
25
- import { adoptOrgServerKeys } from '../connectors/org-adopt.js';
26
- import { readAgentKey } from '../connectors/agent-key.js';
27
- import { stopOrgConnectorSync, watchOrgConnectorSync } from '../connectors/org-sync-watch.js';
24
+ import { syncPluginServers } from '../connectors/plugin-sync.js';
25
+ import { stopMcpServer, watchMcpServer } from '../mcp-server/link.js';
26
+ import { setAnnouncedFile } from '../mcp-server/announced.js';
27
+ import { holdServerApproval } from '../mcp/server-approvals.js';
28
28
  import { ensureMetroPlugin } from '../claude/plugin-install.js';
29
29
  import { runningAsRoot } from '../metro-user/privilege.js';
30
30
  import { provisionAgentUser } from '../agent-user/provision.js';
@@ -65,6 +65,7 @@ import { isCallEvent, leaveCallsForShutdown, onCallEvent, sweepLeftoverCalls } f
65
65
  installCrashGuard();
66
66
  acquireLock(join(STATE_DIR, '.tail-lock'));
67
67
  setOwnerLineFile(join(STATE_DIR, 'approval-line.json'));
68
+ setAnnouncedFile(join(agentsDir(), 'mcp-approvals.json'));
68
69
 
69
70
  log.info({ self: userSelf() }, 'user identity');
70
71
 
@@ -140,7 +141,6 @@ function sessionApis(setLiveEvents: (on: boolean) => void, switchModel: (model:
140
141
  async function main(): Promise<void> {
141
142
  applyLocalOwner();
142
143
  installBearerSessions(agentsDir(), localOwner);
143
- adoptOrgServerKeys();
144
144
  watchPolicySnapshot();
145
145
  log.info({ agent: await ensureLocalAgent() }, 'local daemon: agent');
146
146
  await materializeFrom(fileSource);
@@ -160,7 +160,12 @@ installBearerSessions(agentsDir(), localOwner);
160
160
  startPromptExpiry();
161
161
  startUploadReaper();
162
162
  startAttachReaper();
163
- watchOrgConnectorSync({ owner: localOwner, key: readAgentKey });
163
+ watchMcpServer({
164
+ owner: localOwner,
165
+ hold: (approval, answer) => {
166
+ holdServerApproval(approval, answer, emit);
167
+ },
168
+ });
164
169
  startCloudWatchPublisher();
165
170
  watchFallbackUsage();
166
171
  announceLocalEndpoint();
@@ -192,7 +197,7 @@ async function startClaude(): Promise<void> {
192
197
  ensureMetroPlugin()
193
198
  .then((outcome) => {
194
199
  log.info({ outcome }, 'plugin: metro plugin for Claude Code');
195
- if (outcome !== 'skipped') publishConnectors();
200
+ if (outcome !== 'skipped') syncPluginServers();
196
201
  })
197
202
  .catch((err: unknown) => {
198
203
  log.warn({ err: errMsg(err) }, 'plugin: could not ensure the Claude Code plugin');
@@ -200,7 +205,7 @@ async function startClaude(): Promise<void> {
200
205
  tryClaudeSetup();
201
206
  try { githubSkills().watch(); } catch { log.warn('github-skills: saved source could not be loaded'); }
202
207
  tryMemoryJob(memoryRoutine());
203
- watchSession({ connectorsReady: () => connectorsReady() });
208
+ watchSession();
204
209
  }
205
210
 
206
211
  const SHUTDOWN_TIMEOUT_MS = 3_000;
@@ -212,7 +217,9 @@ async function shutdown(): Promise<void> {
212
217
  if (shuttingDown) return;
213
218
  shuttingDown = true;
214
219
  log.info('dispatcher shutting down');
215
- stopOrgConnectorSync(true);
220
+ stopMcpServer().catch((err: unknown) => {
221
+ log.warn({ err: errMsg(err) }, 'mcp-server: could not stop the approval relay');
222
+ });
216
223
  const leaving = leaveCallsForShutdown();
217
224
  unwatchSession();
218
225
  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;
@@ -49,7 +49,6 @@ export interface SessionDeps {
49
49
  now?: () => number;
50
50
  version?: string;
51
51
  prepare?: (runner: HarnessRunner) => Promise<void>;
52
- connectorsReady?: () => boolean;
53
52
  }
54
53
 
55
54
  export interface SessionStatus {
@@ -317,10 +316,9 @@ function strike(now: number): boolean {
317
316
  return true;
318
317
  }
319
318
 
320
- export type Ensured = 'started' | 'restarted' | 'running' | 'blocked' | 'off' | 'paused' | 'waiting';
319
+ export type Ensured = 'started' | 'restarted' | 'running' | 'blocked' | 'off' | 'paused';
321
320
 
322
321
  function holdsBack(deps: SessionDeps, now: number): Ensured | null {
323
- if (deps.connectorsReady?.() === false) return 'waiting';
324
322
  if (now < memory.pausedUntil) return 'paused';
325
323
  if (sessionBlocked(deps) !== null) return 'blocked';
326
324
  return strike(now) ? 'paused' : null;
@@ -151,6 +151,8 @@ const PRIOR_METRO: ReadonlySet<string> = new Set([
151
151
  'e6b59eba825b4568dfa9ace5049fd9568a2e10d731719976ad2657084fd4e5f9',
152
152
  '5ce3f76d610aae91adc922d0edc6c8be1f27aa2fec4a5e3a6d6231a04fb9310b',
153
153
  'b910597d24f31fa479b130a2545f94dd23684d802d99e36b637fc57c818a33be',
154
+ 'd3f7ed0f5175b9a7a77f06f6204ae0020369d0c8f6e2724196bb3c5822d4d8ba',
155
+ '587a1334f91a8b61541573cef16eaf44fbe63049a8d8b461fa9f5cc55bacbcb8',
154
156
  ]);
155
157
  const PRIOR_STAGE: ReadonlySet<string> = new Set([
156
158
  'dd7d30474765de3865ddc87c1cf3220161ded09502027cdff18597e5df0aab43',
@@ -18,7 +18,6 @@ export interface EnrollApiDeps {
18
18
  owner: () => string | null;
19
19
  dir?: () => string;
20
20
  api?: MetroApi;
21
- invalidate?: () => void;
22
21
  }
23
22
 
24
23
  export interface Enrolled {
@@ -89,7 +88,6 @@ async function enroll(req: IncomingMessage, session: ApiSession, deps: EnrollApi
89
88
  if (enrolling.has(dir)) throw new ApiError('This agent is already enrolling. Wait for it to finish before trying again.', 409);
90
89
  enrolling.add(dir);
91
90
  try {
92
- deps.invalidate?.();
93
91
  return await exchange(ticket, owner, dir, deps);
94
92
  } finally {
95
93
  enrolling.delete(dir);
@@ -4,59 +4,20 @@ import { agentMarketplaceDir, agentUser } from '../agent-user/user.js';
4
4
  import { join } from 'node:path';
5
5
  import { errMsg, log } from '@metro-labs/core/log';
6
6
  import { claudeDir } from '../claude/files.js';
7
- import { loopbackBase } from '../files/attach-serve.js';
7
+ import { MCP_SERVER_KEY, mcpServerUrl } from '../mcp-server/config.js';
8
8
 
9
9
  const MARKER = join('bin', 'metro-plugin.mjs');
10
10
  const FILE = '.mcp.json';
11
- const HELPER = 'node "${CLAUDE_PLUGIN_ROOT}/bin/metro-plugin.mjs" headers';
12
- const SLUG_MAX = 40;
11
+ const MCP_HELPER = 'node "${CLAUDE_PLUGIN_ROOT}/bin/metro-plugin.mjs" mcp-headers';
13
12
 
14
- export interface PluginRow {
15
- id: string;
16
- name: string;
17
- }
18
-
19
- export interface PluginServer {
13
+ interface PluginServer {
20
14
  type: 'http';
21
15
  url: string;
22
16
  headersHelper: string;
23
17
  }
24
18
 
25
- export function serverKey(name: string): string {
26
- const slug = name
27
- .toLowerCase()
28
- .replace(/[^a-z0-9]+/g, '-')
29
- .replace(/^-+|-+$/g, '')
30
- .slice(0, SLUG_MAX)
31
- .replace(/-+$/, '');
32
- return slug === '' ? 'connector' : slug;
33
- }
34
-
35
- function availableKey(wanted: string, reserved: ReadonlySet<string>): string {
36
- let key = wanted;
37
- let suffix = 1;
38
- while (reserved.has(key)) key = `${wanted}-${String(suffix++)}`;
39
- return key;
40
- }
41
-
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());
45
- for (const row of [...rows].sort((a, b) => a.id.localeCompare(b.id))) {
46
- if (keys.has(row.id)) continue;
47
- const key = availableKey(serverKey(row.name), reserved);
48
- reserved.add(key);
49
- keys.set(row.id, key);
50
- }
51
- return keys;
52
- }
53
-
54
- export function pluginServers(rows: PluginRow[], base: string, keys: ReadonlyMap<string, string> = serverKeysOf(rows)): Record<string, PluginServer> {
55
- const out: Record<string, PluginServer> = {};
56
- const visible = new Set(rows.map((row) => row.id));
57
- for (const [id, key] of keys) if (visible.has(id)) out[key] = { type: 'http', url: `${base}/relay/${id}`, headersHelper: HELPER };
58
- return out;
59
- }
19
+ const pluginServers = (url: string | null): Record<string, PluginServer> =>
20
+ url === null ? {} : { [MCP_SERVER_KEY]: { type: 'http', url, headersHelper: MCP_HELPER } };
60
21
 
61
22
  const SEARCH_DEPTH = 5;
62
23
 
@@ -108,9 +69,8 @@ function writeIfChanged(path: string, text: string): boolean {
108
69
 
109
70
  export interface PluginSyncOptions {
110
71
  dir?: string;
111
- base?: string;
112
72
  staged?: string | null;
113
- keys?: ReadonlyMap<string, string>;
73
+ url?: string | null;
114
74
  }
115
75
 
116
76
  function filesFor(opts: PluginSyncOptions): string[] {
@@ -118,23 +78,23 @@ function filesFor(opts: PluginSyncOptions): string[] {
118
78
  return installedPluginFiles(opts.dir ?? claudeDir(), staged);
119
79
  }
120
80
 
121
- export function syncPluginServers(rows: PluginRow[], opts: PluginSyncOptions = {}): number {
81
+ export function syncPluginServers(opts: PluginSyncOptions = {}): number {
122
82
  const files = filesFor(opts);
123
83
  if (files.length === 0) return 0;
124
- const servers = pluginServers(rows, opts.base ?? loopbackBase(), opts.keys);
84
+ const servers = pluginServers(opts.url === undefined ? mcpServerUrl() : opts.url);
125
85
  const text = `${JSON.stringify(servers, null, 2)}\n`;
126
86
  let written = 0;
127
87
  for (const file of files) {
128
88
  try {
129
89
  if (writeIfChanged(file, text)) written += 1;
130
90
  } catch (err) {
131
- log.warn({ file, err: errMsg(err) }, 'plugin: could not write the connector servers');
91
+ log.warn({ file, err: errMsg(err) }, 'plugin: could not write the MCP servers');
132
92
  }
133
93
  }
134
94
  if (written > 0)
135
95
  log.info(
136
- { files: written, servers: Object.keys(servers).length },
137
- 'plugin: connector servers written; run /reload-plugins --force to pick them up in a running session',
96
+ { files: written, servers: Object.keys(servers) },
97
+ 'plugin: MCP servers written; run /reload-plugins --force to pick them up in a running session',
138
98
  );
139
99
  return written;
140
100
  }
@@ -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,
@@ -1,6 +1,5 @@
1
1
  import { str } from '@metro-labs/core/str';
2
2
  import { readPreview, shownValue } from '../approvals/preview.js';
3
- import { connectorGates, connectorToolOf } from '../connectors/gates.js';
4
3
  import { stationByName } from '../stations/registry.js';
5
4
  import { stationOfAccount } from './read-tool.js';
6
5
 
@@ -75,15 +74,12 @@ function metroLines(tool: string, input: Record<string, unknown>): string[] {
75
74
 
76
75
  const ARGS_MAX = 8;
77
76
 
78
- function connectorLines(name: string, tool: string, input: Record<string, unknown> | undefined): string[] {
79
- const lines = [`Approval needed: ${tool}`, `Connector: ${name}`];
77
+ function argLines(input: Record<string, unknown> | undefined): string[] {
80
78
  const args = Object.entries(input ?? {}).flatMap(([key, value]) => {
81
79
  const text = argText(value);
82
80
  return text === undefined ? [] : [`${key}: ${shorten(text)}`];
83
81
  });
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;
82
+ return args.length > ARGS_MAX ? [...args.slice(0, ARGS_MAX), `(${String(args.length - ARGS_MAX)} more)`] : args;
87
83
  }
88
84
 
89
85
  function bodyLines(params: PermissionParams): string[] {
@@ -91,18 +87,29 @@ function bodyLines(params: PermissionParams): string[] {
91
87
  const { input } = readPreview(params.input_preview);
92
88
  const raw = params.input_preview.trim() === '' ? [] : [shorten(params.input_preview, RAW_MAX)];
93
89
  if (metro !== undefined) return input === undefined ? [`Approval needed: ${metro}`, ...raw] : metroLines(metro, input);
94
- const connector = connectorToolOf(params.tool_name);
95
- if (connector !== undefined) {
96
- const { gate, tool } = connector;
97
- const duplicate = connectorGates().some((entry) => entry.id !== gate.id && entry.name === gate.name);
98
- return connectorLines(duplicate ? `${gate.name} (${gate.id})` : gate.name, tool, input);
99
- }
100
90
  return [`Claude wants to run ${params.tool_name}: ${shorten(params.description)}`, '', ...raw];
101
91
  }
102
92
 
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');
93
+ function withAnswer(requestId: string, lines: string[]): string {
94
+ const answer = `Reply "yes ${requestId}" or "no ${requestId}"`;
95
+ const body = lines.join('\n');
106
96
  const room = PROMPT_MAX - answer.length - 3;
107
97
  return `${body.length > room ? `${body.slice(0, room)}…` : body}\n\n${answer}`;
108
98
  }
99
+
100
+ export function promptBody(params: PermissionParams): string {
101
+ return withAnswer(params.request_id, bodyLines(params));
102
+ }
103
+
104
+ export interface ServerPrompt {
105
+ requestId: string;
106
+ tool: string;
107
+ preview: string;
108
+ }
109
+
110
+ export function serverPromptBody(prompt: ServerPrompt): string {
111
+ const { input } = readPreview(prompt.preview);
112
+ const raw = prompt.preview.trim() === '' ? [] : [shorten(prompt.preview, RAW_MAX)];
113
+ const args = input === undefined ? raw : argLines(input);
114
+ return withAnswer(prompt.requestId, [`Approval needed: ${prompt.tool}`, ...args]);
115
+ }