@stage-labs/metro 0.1.0-beta.304 → 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 (51) 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 +1 -1
  4. package/runtime/marketplace/plugin/bin/metro-plugin.mjs +4 -28
  5. package/runtime/marketplace/plugin/bin/policy.mjs +1 -32
  6. package/runtime/node_modules/@metro-labs/core/package.json +0 -1
  7. package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +3 -5
  8. package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +1 -7
  9. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -9
  10. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +1 -3
  11. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +1 -0
  12. package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +0 -2
  13. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +9 -54
  14. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +1 -13
  15. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +0 -14
  16. package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +14 -15
  17. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/announced.ts +68 -0
  18. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +3 -1
  19. package/runtime/node_modules/@metro-labs/daemon/src/policy/policy.ts +6 -5
  20. package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +0 -2
  21. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -27
  22. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -5
  23. package/runtime/node_modules/@metro-labs/http/package.json +1 -2
  24. package/runtime/node_modules/@metro-labs/http/src/connector-grant.ts +0 -51
  25. package/runtime/runtime.json +1 -1
  26. package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +0 -1
  27. package/runtime/sdk-runner/runner.json +1 -1
  28. package/runtime/node_modules/@metro-labs/core/src/connector-sync.ts +0 -17
  29. package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +0 -104
  30. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-adopt.ts +0 -57
  31. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-relay.ts +0 -61
  32. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +0 -115
  33. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +0 -271
  34. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-api.ts +0 -25
  35. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +0 -173
  36. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-setting.ts +0 -60
  37. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +0 -38
  38. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +0 -208
  39. package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +0 -40
  40. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-id.ts +0 -2
  41. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-policy.ts +0 -90
  42. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +0 -10
  43. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-target.ts +0 -4
  44. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +0 -287
  45. package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +0 -59
  46. package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +0 -1
  47. package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +0 -116
  48. package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +0 -19
  49. package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +0 -178
  50. package/runtime/node_modules/@metro-labs/http/src/transfer-crypto.ts +0 -44
  51. 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.304",
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.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.",
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,7 +15,7 @@ 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>"`.
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
19
 
20
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.
21
21
 
@@ -6,18 +6,6 @@ import { join } from 'node:path';
6
6
  const agentsDir = () => process.env.METRO_AGENTS_DIR || join(homedir(), '.metro', 'agents');
7
7
  const MCP_TOKEN = /^mcp_[A-Za-z0-9_-]{43}$/;
8
8
 
9
- function readAgent() {
10
- const path = join(agentsDir(), 'agent.json');
11
- let parsed;
12
- try {
13
- parsed = JSON.parse(readFileSync(path, 'utf8'));
14
- } catch {
15
- throw new Error(`no agent on this machine yet (${path})`);
16
- }
17
- if (typeof parsed?.key !== 'string') throw new Error(`no agent key in ${path}`);
18
- return parsed;
19
- }
20
-
21
9
  function readMcpToken() {
22
10
  try {
23
11
  const token = readFileSync(join(agentsDir(), 'mcp-token'), 'utf8').trim();
@@ -27,22 +15,10 @@ function readMcpToken() {
27
15
  }
28
16
  }
29
17
 
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');
18
+ if (process.argv[2] !== 'mcp-headers') {
19
+ process.stderr.write('usage: metro-plugin.mjs mcp-headers\n');
40
20
  process.exit(1);
41
21
  }
42
22
 
43
- try {
44
- process.stdout.write(`${JSON.stringify({ Authorization: `Bearer ${readAgent().key}` })}\n`);
45
- } catch (err) {
46
- process.stderr.write(`metro: ${err.message}\n`);
47
- process.stdout.write('{}\n');
48
- }
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",
@@ -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,
@@ -145,19 +145,13 @@ export function cancelPrompt(requestId: string, owner: object): void {
145
145
  });
146
146
  }
147
147
 
148
- export function revokeToolPrompts(matches: (tool: string) => boolean): void {
149
- grants = grants.filter((entry) => !matches(entry.tool));
150
- for (const entry of held.values())
151
- if (matches(entry.tool)) cancelPrompt(entry.requestId, entry.owner);
152
- }
153
-
154
148
  export function revokeCallPrompts(route: CallRoute): void {
155
149
  grants = grants.filter((entry) => entry.call === undefined || !sameCall(entry.call.route, route));
156
150
  for (const entry of held.values())
157
151
  if (entry.call !== undefined && sameCall(entry.call.route, route)) cancelPrompt(entry.requestId, entry.owner);
158
152
  }
159
153
 
160
- export function settlePrompts(toolMatches: (tool: string) => boolean, args: Record<string, unknown>): void {
154
+ function settlePrompts(toolMatches: (tool: string) => boolean, args: Record<string, unknown>): void {
161
155
  for (const [id, entry] of held)
162
156
  if (entry.origin !== 'mcp-server' && toolMatches(entry.tool) && previewMatches(entry.preview, args)) {
163
157
  held.delete(id);
@@ -21,11 +21,9 @@ 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';
28
25
  import { stopMcpServer, watchMcpServer } from '../mcp-server/link.js';
26
+ import { setAnnouncedFile } from '../mcp-server/announced.js';
29
27
  import { holdServerApproval } from '../mcp/server-approvals.js';
30
28
  import { ensureMetroPlugin } from '../claude/plugin-install.js';
31
29
  import { runningAsRoot } from '../metro-user/privilege.js';
@@ -67,6 +65,7 @@ import { isCallEvent, leaveCallsForShutdown, onCallEvent, sweepLeftoverCalls } f
67
65
  installCrashGuard();
68
66
  acquireLock(join(STATE_DIR, '.tail-lock'));
69
67
  setOwnerLineFile(join(STATE_DIR, 'approval-line.json'));
68
+ setAnnouncedFile(join(agentsDir(), 'mcp-approvals.json'));
70
69
 
71
70
  log.info({ self: userSelf() }, 'user identity');
72
71
 
@@ -142,7 +141,6 @@ function sessionApis(setLiveEvents: (on: boolean) => void, switchModel: (model:
142
141
  async function main(): Promise<void> {
143
142
  applyLocalOwner();
144
143
  installBearerSessions(agentsDir(), localOwner);
145
- adoptOrgServerKeys();
146
144
  watchPolicySnapshot();
147
145
  log.info({ agent: await ensureLocalAgent() }, 'local daemon: agent');
148
146
  await materializeFrom(fileSource);
@@ -162,7 +160,6 @@ installBearerSessions(agentsDir(), localOwner);
162
160
  startPromptExpiry();
163
161
  startUploadReaper();
164
162
  startAttachReaper();
165
- watchOrgConnectorSync({ owner: localOwner, key: readAgentKey });
166
163
  watchMcpServer({
167
164
  owner: localOwner,
168
165
  hold: (approval, answer) => {
@@ -200,7 +197,7 @@ async function startClaude(): Promise<void> {
200
197
  ensureMetroPlugin()
201
198
  .then((outcome) => {
202
199
  log.info({ outcome }, 'plugin: metro plugin for Claude Code');
203
- if (outcome !== 'skipped') publishConnectors();
200
+ if (outcome !== 'skipped') syncPluginServers();
204
201
  })
205
202
  .catch((err: unknown) => {
206
203
  log.warn({ err: errMsg(err) }, 'plugin: could not ensure the Claude Code plugin');
@@ -208,7 +205,7 @@ async function startClaude(): Promise<void> {
208
205
  tryClaudeSetup();
209
206
  try { githubSkills().watch(); } catch { log.warn('github-skills: saved source could not be loaded'); }
210
207
  tryMemoryJob(memoryRoutine());
211
- watchSession({ connectorsReady: () => connectorsReady() });
208
+ watchSession();
212
209
  }
213
210
 
214
211
  const SHUTDOWN_TIMEOUT_MS = 3_000;
@@ -220,7 +217,6 @@ async function shutdown(): Promise<void> {
220
217
  if (shuttingDown) return;
221
218
  shuttingDown = true;
222
219
  log.info('dispatcher shutting down');
223
- stopOrgConnectorSync(true);
224
220
  stopMcpServer().catch((err: unknown) => {
225
221
  log.warn({ err: errMsg(err) }, 'mcp-server: could not stop the approval relay');
226
222
  });
@@ -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;
@@ -152,6 +152,7 @@ const PRIOR_METRO: ReadonlySet<string> = new Set([
152
152
  '5ce3f76d610aae91adc922d0edc6c8be1f27aa2fec4a5e3a6d6231a04fb9310b',
153
153
  'b910597d24f31fa479b130a2545f94dd23684d802d99e36b637fc57c818a33be',
154
154
  'd3f7ed0f5175b9a7a77f06f6204ae0020369d0c8f6e2724196bb3c5822d4d8ba',
155
+ '587a1334f91a8b61541573cef16eaf44fbe63049a8d8b461fa9f5cc55bacbcb8',
155
156
  ]);
156
157
  const PRIOR_STAGE: ReadonlySet<string> = new Set([
157
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,64 +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';
8
7
  import { MCP_SERVER_KEY, mcpServerUrl } from '../mcp-server/config.js';
9
8
 
10
9
  const MARKER = join('bin', 'metro-plugin.mjs');
11
10
  const FILE = '.mcp.json';
12
- const HELPER = 'node "${CLAUDE_PLUGIN_ROOT}/bin/metro-plugin.mjs" headers';
13
11
  const MCP_HELPER = 'node "${CLAUDE_PLUGIN_ROOT}/bin/metro-plugin.mjs" mcp-headers';
14
- const SLUG_MAX = 40;
15
12
 
16
- export interface PluginRow {
17
- id: string;
18
- name: string;
19
- }
20
-
21
- export interface PluginServer {
13
+ interface PluginServer {
22
14
  type: 'http';
23
15
  url: string;
24
16
  headersHelper: string;
25
17
  }
26
18
 
27
- export function serverKey(name: string): string {
28
- const slug = name
29
- .toLowerCase()
30
- .replace(/[^a-z0-9]+/g, '-')
31
- .replace(/^-+|-+$/g, '')
32
- .slice(0, SLUG_MAX)
33
- .replace(/-+$/, '');
34
- return slug === '' ? 'connector' : slug;
35
- }
36
-
37
- function availableKey(wanted: string, reserved: ReadonlySet<string>): string {
38
- let key = wanted;
39
- let suffix = 1;
40
- while (reserved.has(key)) key = `${wanted}-${String(suffix++)}`;
41
- return key;
42
- }
43
-
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]);
49
- for (const row of [...rows].sort((a, b) => a.id.localeCompare(b.id))) {
50
- if (keys.has(row.id)) continue;
51
- const key = availableKey(serverKey(row.name), used);
52
- used.add(key);
53
- keys.set(row.id, key);
54
- }
55
- return keys;
56
- }
57
-
58
- export function pluginServers(rows: PluginRow[], base: string, keys: ReadonlyMap<string, string> = serverKeysOf(rows), mcpUrl = mcpServerUrl()): Record<string, PluginServer> {
59
- const out: Record<string, PluginServer> = {};
60
- const visible = new Set(rows.map((row) => row.id));
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 };
63
- return out;
64
- }
19
+ const pluginServers = (url: string | null): Record<string, PluginServer> =>
20
+ url === null ? {} : { [MCP_SERVER_KEY]: { type: 'http', url, headersHelper: MCP_HELPER } };
65
21
 
66
22
  const SEARCH_DEPTH = 5;
67
23
 
@@ -113,9 +69,8 @@ function writeIfChanged(path: string, text: string): boolean {
113
69
 
114
70
  export interface PluginSyncOptions {
115
71
  dir?: string;
116
- base?: string;
117
72
  staged?: string | null;
118
- keys?: ReadonlyMap<string, string>;
73
+ url?: string | null;
119
74
  }
120
75
 
121
76
  function filesFor(opts: PluginSyncOptions): string[] {
@@ -123,23 +78,23 @@ function filesFor(opts: PluginSyncOptions): string[] {
123
78
  return installedPluginFiles(opts.dir ?? claudeDir(), staged);
124
79
  }
125
80
 
126
- export function syncPluginServers(rows: PluginRow[], opts: PluginSyncOptions = {}): number {
81
+ export function syncPluginServers(opts: PluginSyncOptions = {}): number {
127
82
  const files = filesFor(opts);
128
83
  if (files.length === 0) return 0;
129
- const servers = pluginServers(rows, opts.base ?? loopbackBase(), opts.keys);
84
+ const servers = pluginServers(opts.url === undefined ? mcpServerUrl() : opts.url);
130
85
  const text = `${JSON.stringify(servers, null, 2)}\n`;
131
86
  let written = 0;
132
87
  for (const file of files) {
133
88
  try {
134
89
  if (writeIfChanged(file, text)) written += 1;
135
90
  } catch (err) {
136
- 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');
137
92
  }
138
93
  }
139
94
  if (written > 0)
140
95
  log.info(
141
- { files: written, servers: Object.keys(servers).length },
142
- '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',
143
98
  );
144
99
  return written;
145
100
  }
@@ -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
 
@@ -83,21 +82,11 @@ function argLines(input: Record<string, unknown> | undefined): string[] {
83
82
  return args.length > ARGS_MAX ? [...args.slice(0, ARGS_MAX), `(${String(args.length - ARGS_MAX)} more)`] : args;
84
83
  }
85
84
 
86
- function connectorLines(name: string, tool: string, input: Record<string, unknown> | undefined): string[] {
87
- return [`Approval needed: ${tool}`, `Connector: ${name}`, ...argLines(input)];
88
- }
89
-
90
85
  function bodyLines(params: PermissionParams): string[] {
91
86
  const metro = METRO_TOOL.exec(params.tool_name)?.[1];
92
87
  const { input } = readPreview(params.input_preview);
93
88
  const raw = params.input_preview.trim() === '' ? [] : [shorten(params.input_preview, RAW_MAX)];
94
89
  if (metro !== undefined) return input === undefined ? [`Approval needed: ${metro}`, ...raw] : metroLines(metro, input);
95
- const connector = connectorToolOf(params.tool_name);
96
- if (connector !== undefined) {
97
- const { gate, tool } = connector;
98
- const duplicate = connectorGates().some((entry) => entry.id !== gate.id && entry.name === gate.name);
99
- return connectorLines(duplicate ? `${gate.name} (${gate.id})` : gate.name, tool, input);
100
- }
101
90
  return [`Claude wants to run ${params.tool_name}: ${shorten(params.description)}`, '', ...raw];
102
91
  }
103
92
 
@@ -115,7 +104,6 @@ export function promptBody(params: PermissionParams): string {
115
104
  export interface ServerPrompt {
116
105
  requestId: string;
117
106
  tool: string;
118
- connector: string | undefined;
119
107
  preview: string;
120
108
  }
121
109
 
@@ -123,5 +111,5 @@ export function serverPromptBody(prompt: ServerPrompt): string {
123
111
  const { input } = readPreview(prompt.preview);
124
112
  const raw = prompt.preview.trim() === '' ? [] : [shorten(prompt.preview, RAW_MAX)];
125
113
  const args = input === undefined ? raw : argLines(input);
126
- return withAnswer(prompt.requestId, [`Approval needed: ${prompt.tool}`, ...(prompt.connector === undefined ? [] : [`Connector: ${prompt.connector}`]), ...args]);
114
+ return withAnswer(prompt.requestId, [`Approval needed: ${prompt.tool}`, ...args]);
127
115
  }
@@ -7,7 +7,6 @@ import { agentsDir } from '../agents/files.js';
7
7
  import { knownAccounts } from '../agents/map.js';
8
8
  import { onPoliciesChanged, storedPolicies, type ToolPolicy } from '../policy/policy.js';
9
9
  import { UNGATED } from './policy-gate.js';
10
- import { connectorGates } from '../connectors/gates.js';
11
10
  import { stationToolOwners, TOOL_DEFS, toolGroupOf } from './tool-catalog.js';
12
11
 
13
12
  interface PolicySnapshot {
@@ -17,14 +16,6 @@ interface PolicySnapshot {
17
16
  ungated: string[];
18
17
  accounts: Record<string, ToolPolicy>;
19
18
  stations: Record<string, string[]>;
20
- connectors: Record<string, SnapshotConnector>;
21
- }
22
-
23
- interface SnapshotConnector {
24
- id: string;
25
- name: string;
26
- policy: ToolPolicy;
27
- tools: Record<string, ToolGroup>;
28
19
  }
29
20
 
30
21
  const FILE = 'policy.json';
@@ -44,11 +35,6 @@ function policySnapshot(): PolicySnapshot {
44
35
  ungated: [...UNGATED],
45
36
  accounts,
46
37
  stations,
47
- connectors: Object.fromEntries(
48
- connectorGates()
49
- .filter((gate) => gate.server !== '')
50
- .map((gate) => [gate.server, { id: gate.id, name: gate.name, policy: gate.policy, tools: gate.tools }]),
51
- ),
52
38
  };
53
39
  }
54
40
 
@@ -4,8 +4,7 @@ import type { McpApproval, McpDecision } from '@metro-labs/http/mcp-server-proto
4
4
  import { holdPrompt, isPromptHeld, type Behavior } from '../approvals/pending.js';
5
5
  import { ownerLine } from '../approvals/owner-line.js';
6
6
  import { metroNotice } from '../channels/notice.js';
7
- import { connectorGates } from '../connectors/gates.js';
8
- import { serverKey } from '../connectors/plugin-sync.js';
7
+ import { announcedLine, noteAnnounced } from '../mcp-server/announced.js';
9
8
  import type { AnswerOutcome } from '../mcp-server/link.js';
10
9
  import { serverPromptBody } from './permission-prompt.js';
11
10
  import { metroSend, relayLine } from './permission-relay.js';
@@ -42,11 +41,6 @@ function remember(id: string, now = Date.now()): void {
42
41
 
43
42
  const answeredRecently = (id: string, now = Date.now()): boolean => now - (recent.get(id) ?? -Infinity) < RECENT_MS;
44
43
 
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
44
  const noticeText = (tool: string, id: string, behavior: Behavior): string =>
51
45
  behavior === 'allow' ? `Approved: ${tool} (${id}). Call call_tool again with the same arguments and approval: "${id}".` : `Denied: ${tool} (${id}).`;
52
46
 
@@ -84,23 +78,28 @@ function sender(held: Held): (behavior: Behavior) => Promise<void> {
84
78
  };
85
79
  }
86
80
 
81
+ function announce(id: string, line: string, text: string): void {
82
+ metroSend(line, text).then(() => {
83
+ noteAnnounced(id, line);
84
+ }).catch((err: unknown) => {
85
+ log.warn({ id, err: errMsg(err) }, 'mcp-server: the approval prompt could not be posted');
86
+ });
87
+ }
88
+
87
89
  export function holdServerApproval(approval: McpApproval, answer: Held['answer'], publish: Held['publish']): boolean {
88
90
  if (isPromptHeld(approval.id) || answeredRecently(approval.id)) return false;
89
91
  const tool = `${approval.connector}.${approval.tool}`;
90
- const name = connectorName(approval.connector);
91
- const line = promptLineFor(approval.id);
92
+ const announced = announcedLine(approval.id);
93
+ const line = announced ?? promptLineFor(approval.id);
92
94
  const held: Held = { id: approval.id, tool, line, answer, publish };
93
95
  holdPrompt(
94
- { requestId: approval.id, tool, description: name ?? approval.connector, preview: approval.preview, line, at: requestedAt(approval.requestedAt), origin: 'mcp-server' },
96
+ { requestId: approval.id, tool, description: approval.connector, preview: approval.preview, line, at: requestedAt(approval.requestedAt), origin: 'mcp-server' },
95
97
  owner,
96
98
  sender(held),
97
99
  line === undefined ? undefined : async (text) => metroSend(line, text),
98
100
  );
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
- });
101
+ log.info({ id: approval.id, tool, inChat: line !== undefined, announced: announced !== undefined }, 'mcp-server: an approval is waiting for the owner');
102
+ if (line !== undefined && announced === undefined) announce(approval.id, line, serverPromptBody({ requestId: approval.id, tool, preview: approval.preview }));
104
103
  return true;
105
104
  }
106
105
 
@@ -0,0 +1,68 @@
1
+ import { errMsg, log } from '@metro-labs/core/log';
2
+ import { isRecord } from '@metro-labs/core/is-record';
3
+ import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
4
+ import { MCP_APPROVAL_ID_RE } from '@metro-labs/http/mcp-server-protocol';
5
+
6
+ const KEEP_MS = 24 * 3_600_000;
7
+ const MAX = 200;
8
+
9
+ interface Announced {
10
+ line: string;
11
+ at: number;
12
+ }
13
+
14
+ let file: string | undefined;
15
+ let known = new Map<string, Announced>();
16
+
17
+ function entryOf(raw: unknown): Announced | null {
18
+ if (!isRecord(raw) || typeof raw.line !== 'string' || !raw.line.startsWith('metro://') || typeof raw.at !== 'number' || !Number.isFinite(raw.at)) return null;
19
+ return { line: raw.line, at: raw.at };
20
+ }
21
+
22
+ function parse(raw: unknown): Map<string, Announced> {
23
+ const out = new Map<string, Announced>();
24
+ const entries = isRecord(raw) && raw.version === 1 && isRecord(raw.announced) ? raw.announced : {};
25
+ for (const [id, value] of Object.entries(entries)) {
26
+ const entry = entryOf(value);
27
+ if (MCP_APPROVAL_ID_RE.test(id) && entry !== null) out.set(id, entry);
28
+ }
29
+ return out;
30
+ }
31
+
32
+ function prune(now: number): void {
33
+ for (const [id, entry] of known) if (now - entry.at >= KEEP_MS || entry.at > now) known.delete(id);
34
+ const oldest = [...known].sort(([, a], [, b]) => a.at - b.at);
35
+ for (const [id] of oldest.slice(0, Math.max(0, known.size - MAX))) known.delete(id);
36
+ }
37
+
38
+ function save(): void {
39
+ if (file === undefined) return;
40
+ const announced = Object.fromEntries([...known].map(([id, entry]) => [id, { line: entry.line, at: entry.at }]));
41
+ try {
42
+ writeSecure(file, `${JSON.stringify({ version: 1, announced })}\n`);
43
+ } catch (err) {
44
+ log.warn({ err: errMsg(err) }, 'mcp-server: the announced approvals could not be saved, so they are kept in memory only');
45
+ }
46
+ }
47
+
48
+ export function setAnnouncedFile(path: string, now = Date.now()): void {
49
+ file = path;
50
+ known = parse(readJson<unknown>(path, null, { warn: 'mcp-server: the announced approvals file is not valid JSON, so it was ignored' }));
51
+ prune(now);
52
+ }
53
+
54
+ export function announcedLine(id: string, now = Date.now()): string | undefined {
55
+ const entry = known.get(id);
56
+ return entry !== undefined && now - entry.at < KEEP_MS && entry.at <= now ? entry.line : undefined;
57
+ }
58
+
59
+ export function noteAnnounced(id: string, line: string, now = Date.now()): void {
60
+ known.set(id, { line, at: now });
61
+ prune(now);
62
+ save();
63
+ }
64
+
65
+ export function forgetAnnounced(): void {
66
+ file = undefined;
67
+ known = new Map();
68
+ }
@@ -2,6 +2,8 @@ import { log } from '@metro-labs/core/log';
2
2
 
3
3
  export const MCP_SERVER_KEY = 'mcp';
4
4
 
5
+ const DEFAULT_URL = 'https://mcp.metro.box';
6
+
5
7
  const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
6
8
 
7
9
  let refused: string | undefined;
@@ -18,7 +20,7 @@ function usable(raw: string): boolean {
18
20
  }
19
21
 
20
22
  export function mcpServerUrl(env: NodeJS.ProcessEnv = process.env): string | null {
21
- const raw = env.METRO_MCP_SERVER_URL?.trim() ?? '';
23
+ const raw = env.METRO_MCP_SERVER_URL?.trim() ?? DEFAULT_URL;
22
24
  if (raw === '') return null;
23
25
  if (usable(raw)) return raw.replace(/\/+$/, '');
24
26
  if (refused !== raw) {