@stage-labs/metro 0.1.0-beta.292 → 0.1.0-beta.293

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 (20) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/marketplace/plugin/bin/policy.mjs +4 -1
  4. package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +27 -23
  5. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +4 -2
  6. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +12 -9
  7. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +20 -0
  8. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +4 -6
  9. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +2 -2
  10. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +39 -12
  11. package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +22 -11
  12. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-passive.ts +96 -0
  13. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +19 -10
  14. package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +46 -0
  15. package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +10 -15
  16. package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +1 -4
  17. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +6 -2
  18. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +1 -2
  19. package/runtime/runtime.json +1 -1
  20. 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.292",
3
+ "version": "0.1.0-beta.293",
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,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.292",
3
+ "version": "0.1.0-beta.293",
4
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.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -59,7 +59,10 @@ function decide(policy, name, group) {
59
59
  const normalized = (name) => name.replace(/[^a-zA-Z0-9_-]/g, '_');
60
60
 
61
61
  function connectorCall(snap, toolName) {
62
- for (const [server, entry] of Object.entries(isRecord(snap.connectors) ? snap.connectors : {})) {
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) {
63
66
  if (!isRecord(entry)) continue;
64
67
  const name = text(entry.name);
65
68
  const prefixes = [`mcp__plugin_metro_${server}__`, `mcp__${server}__`];
@@ -20,7 +20,6 @@ export interface ConnectorGate {
20
20
  }
21
21
 
22
22
  let gates: ConnectorGate[] = [];
23
- let managed = new Set<string>();
24
23
 
25
24
  const normalized = (name: string): string => name.replace(/[^a-zA-Z0-9_-]/g, '_');
26
25
 
@@ -31,21 +30,21 @@ function approvalChanged(gate: ConnectorGate, tool: string, next: GateRow | unde
31
30
  return before !== after;
32
31
  }
33
32
 
34
- export function registerConnectors(rows: readonly GateRow[], managedIds: readonly string[] = [], keys: ReadonlyMap<string, string> = serverKeysOf([...rows])): void {
35
- const nextManaged = new Set(managedIds);
36
- revokeToolPrompts((tool) => {
37
- const found = connectorToolOf(tool);
38
- if (found === undefined || (!isOrgRelayId(found.gate.id) && !managed.has(found.gate.id) && !nextManaged.has(found.gate.id))) return false;
39
- return approvalChanged(found.gate, found.tool, rows.find((row) => row.id === found.gate.id), keys);
40
- });
41
- managed = nextManaged;
42
- gates = rows.map((row) => ({
33
+ export function registerConnectors(rows: readonly GateRow[], keys: ReadonlyMap<string, string> = serverKeysOf([...rows])): void {
34
+ const next = rows.map((row) => ({
43
35
  id: row.id,
44
36
  name: row.name,
45
37
  server: keys.get(row.id) ?? '',
46
38
  policy: row.config.policy ?? {},
47
39
  tools: row.config.toolGroups ?? {},
48
40
  }));
41
+ revokeToolPrompts((tool) => {
42
+ const before = connectorToolOf(tool);
43
+ const after = connectorToolOf(tool, next);
44
+ if (before?.gate.id !== after?.gate.id) return true;
45
+ return before !== undefined && approvalChanged(before.gate, before.tool, rows.find((row) => row.id === before.gate.id), keys);
46
+ });
47
+ gates = next;
49
48
  const entries = rows.flatMap((row): [PolicyTarget, ToolPolicy][] =>
50
49
  row.config.policy === undefined ? [] : [[{ kind: 'connector', id: row.id }, row.config.policy]],
51
50
  );
@@ -63,7 +62,7 @@ const promptFor = (connectorId: string, tool: string) => (asked: string): boolea
63
62
 
64
63
  export function blockedReason(connectorId: string, tool: string, args: Record<string, unknown> = {}): string | null {
65
64
  const gate = gates.find((g) => g.id === connectorId);
66
- if (gate === undefined) return null;
65
+ if (gate === undefined) return 'This connector is not available.';
67
66
  settlePrompts(promptFor(connectorId, tool), args);
68
67
  const access = policyFor({ kind: 'connector', id: connectorId }, { name: tool, group: toolGroupOf(gate, tool) });
69
68
  if (access === 'deny') return `Blocked by the owner's policy for ${gate.name} (${tool}).`;
@@ -92,19 +91,24 @@ export function requestConnectorBlocker(): () => BlockedReason {
92
91
  };
93
92
  }
94
93
 
95
- function prefixesOf(gate: ConnectorGate): string[] {
96
- const prefixes = [`mcp__plugin_metro_${gate.server}__`, `mcp__${gate.server}__`];
97
- return isOrgRelayId(gate.id) ? prefixes : [...prefixes, `mcp__${normalized(`metro.box ${gate.name}`)}__`];
94
+ function resolvedTool(gate: ConnectorGate, called: string): { gate: ConnectorGate; tool: string } {
95
+ const known = [...Object.keys(gate.tools), ...Object.keys(gate.policy.tools ?? {})];
96
+ return { gate, tool: known.find((name) => name === called) ?? known.find((name) => normalized(name) === called) ?? called };
98
97
  }
99
98
 
100
- export function connectorToolOf(toolName: string): { gate: ConnectorGate; tool: string } | undefined {
99
+ export function connectorToolOf(toolName: string, entries: readonly ConnectorGate[] = gates): { gate: ConnectorGate; tool: string } | undefined {
101
100
  if (toolName.startsWith('mcp__metro__')) return undefined;
102
- for (const gate of gates)
103
- for (const prefix of prefixesOf(gate))
104
- if (toolName.startsWith(prefix) && toolName.length > prefix.length) {
105
- const called = toolName.slice(prefix.length);
106
- const known = [...Object.keys(gate.tools), ...Object.keys(gate.policy.tools ?? {})];
107
- return { gate, tool: known.find((name) => name === called) ?? known.find((name) => normalized(name) === called) ?? called };
108
- }
109
- return undefined;
101
+ for (const gate of entries)
102
+ for (const prefix of [`mcp__plugin_metro_${gate.server}__`, `mcp__${gate.server}__`])
103
+ if (toolName.startsWith(prefix) && toolName.length > prefix.length) return resolvedTool(gate, toolName.slice(prefix.length));
104
+ return legacyToolOf(toolName, entries);
105
+ }
106
+
107
+ function legacyToolOf(toolName: string, entries: readonly ConnectorGate[]): { gate: ConnectorGate; tool: string } | undefined {
108
+ if (!toolName.startsWith('mcp__metro_box_')) return undefined;
109
+ const legacy = entries.filter((gate) => !isOrgRelayId(gate.id) && toolName.startsWith(`mcp__${normalized(`metro.box ${gate.name}`)}__`));
110
+ const gate = legacy.length === 1 ? legacy[0] : undefined;
111
+ if (gate === undefined) return undefined;
112
+ const called = toolName.slice(`mcp__${normalized(`metro.box ${gate.name}`)}__`.length);
113
+ return called === '' ? undefined : resolvedTool(gate, called);
110
114
  }
@@ -62,7 +62,7 @@ function checkResponse(record: MigrationRecord, remote: RemoteMigration): void {
62
62
  }
63
63
 
64
64
  function callFailure(error: unknown, action: string): ApiError {
65
- if (error instanceof MetroApiRefusal && error.code === 'migration_conflict') return new MigrationBlocked('destination_changed', 'The destination already has credentials, a name conflict or changed settings. Inspect it and reconcile; Metro will not overwrite credentials or create a duplicate.');
65
+ if (error instanceof MetroApiRefusal && error.code === 'migration_conflict') return new MigrationBlocked('destination_changed', 'The mapped destination already has credentials or changed settings. Inspect it and reconcile; Metro will not overwrite credentials or create a duplicate.');
66
66
  if (action === 'prepare' && error instanceof MetroApiRefusal && error.upstreamStatus === 400 && error.code === 'oauth_resource_required') {
67
67
  return new ApiError("Enter the provider's public OAuth resource. Roll back this preparation, then prepare again with that public value; never enter credentials.", 400);
68
68
  }
@@ -210,7 +210,8 @@ export class ConnectorMigrations {
210
210
 
211
211
  private waiting(context: Context, record: MigrationRecord, includeOrganization = false): boolean {
212
212
  const uncertain = migrationUncertaintyReceipt(record.sourceId, this.dir) !== null;
213
- if (!uncertain && this.localIdle(record.sourceId) && (!includeOrganization || this.orgIdle(record.sourceId))) return false;
213
+ const localIdle = this.localIdle(record.sourceId);
214
+ if (!uncertain && localIdle && (!includeOrganization || this.orgIdle(record.sourceId))) return false;
214
215
  this.save(context, { ...record, reason: uncertain ? migrationRefreshUncertain(record.sourceId, this.dir) ? 'local_refresh_uncertain' : 'requests_uncertain' : 'waiting_for_local' });
215
216
  return true;
216
217
  }
@@ -300,6 +301,7 @@ export class ConnectorMigrations {
300
301
  private async resolve(context: Context, record: MigrationRecord, receipt: string | null): Promise<void> {
301
302
  this.check(context, record);
302
303
  if (receipt === null || receipt !== migrationUncertaintyReceipt(record.sourceId, this.dir)) throw conflict('The outstanding request receipt changed. Refresh before confirming that vendor requests have settled.');
304
+ localMigrationIdle(record.sourceId, this.dir, true);
303
305
  if (migrationRefreshUncertain(record.sourceId, this.dir)) return this.recoverRefresh(context, record, receipt);
304
306
  if (!resolveMigrationUncertainty(record.sourceId, receipt, this.dir)) throw conflict('Requests are still running or their receipt changed. Wait for vendor completion, then refresh before confirming settlement.');
305
307
  this.save(context, { ...record, reason: record.phase === 'prepared' ? 'not_verified' : null });
@@ -3,7 +3,7 @@ import type { FetchLike } from '@metro-labs/core/stations/oauth';
3
3
  import { agentsDir } from '../agents/files.js';
4
4
  import type { AgentKey } from './agent-key.js';
5
5
  import { migrationRecord, migrationRecords } from './migration-state.js';
6
- import { assertMigrationOutcomeKnown, beginMigrationOperation, migrationOperationsIdle, migrationRequestEffectful, type MigrationOperation } from './migration-operations.js';
6
+ import { assertMigrationOutcomeKnown, beginMigrationOperation, drainMigrationListeners, migrationOperationsIdle, migrationRequestEffectful, type MigrationOperation } from './migration-operations.js';
7
7
 
8
8
  const locked = (phase: string): boolean => phase !== 'rolled_back';
9
9
  const retired = (record: NonNullable<ReturnType<typeof migrationRecord>>): boolean => record.move === true || record.phase === 'cutover' || record.phase === 'active' || record.phase === 'rollback';
@@ -26,17 +26,19 @@ export function assertLocalMigrationUse(id: string, dir = agentsDir()): void {
26
26
  assertMigrationOutcomeKnown(id, dir);
27
27
  }
28
28
 
29
- export function assertLocalMigrationEdit(id: string, name: string | undefined, dir = agentsDir()): void {
30
- if (migrationRecords(dir).some((record) => locked(record.phase) && (record.sourceId === id || record.name === name))) throw refused();
29
+ export function assertLocalMigrationEdit(id: string, dir = agentsDir()): void {
30
+ if (migrationRecords(dir).some((record) => locked(record.phase) && record.sourceId === id)) throw refused();
31
31
  }
32
32
 
33
33
  export function migrationExportableRows<T extends { id: string; name: string }>(rows: readonly T[], dir = agentsDir()): T[] {
34
34
  const records = migrationRecords(dir).filter((record) => locked(record.phase));
35
- return rows.filter((row) => !records.some((record) => record.sourceId === row.id || record.name === row.name));
35
+ return rows.filter((row) => !records.some((record) => record.sourceId === row.id));
36
36
  }
37
37
 
38
38
  export function localMigrationIdle(id: string, dir = agentsDir(), allowUncertain = false): boolean {
39
- return migrationOperationsIdle(id, dir, !allowUncertain && migrationSource(id, dir) !== null);
39
+ const record = migrationSource(id, dir);
40
+ if (record !== null && retired(record)) drainMigrationListeners(id, dir);
41
+ return migrationOperationsIdle(id, dir, !allowUncertain && record !== null);
40
42
  }
41
43
 
42
44
  export interface LocalMigrationOperation extends MigrationOperation {
@@ -45,11 +47,11 @@ export interface LocalMigrationOperation extends MigrationOperation {
45
47
  close: () => void;
46
48
  }
47
49
 
48
- export function beginLocalMigrationOperation(id: string, dir = agentsDir(), edit = false, name?: string): LocalMigrationOperation {
50
+ export function beginLocalMigrationOperation(id: string, dir = agentsDir(), edit = false): LocalMigrationOperation {
49
51
  let closed = false;
50
52
  const check = (): void => {
51
53
  if (closed) throw refused();
52
- if (edit) assertLocalMigrationEdit(id, name, dir);
54
+ if (edit) assertLocalMigrationEdit(id, dir);
53
55
  else assertLocalMigrationUse(id, dir);
54
56
  };
55
57
  check();
@@ -61,14 +63,15 @@ export function beginLocalMigrationOperation(id: string, dir = agentsDir(), edit
61
63
  return {
62
64
  check,
63
65
  dispatch,
66
+ passiveListener: tracked.passiveListener,
64
67
  persistedRefresh: tracked.persistedRefresh,
65
68
  fetch: (input, init) => dispatch(() => fetch(input, init), { effectful: migrationRequestEffectful(init?.method, init?.body) }),
66
69
  close: () => { closed = true; tracked.close(); },
67
70
  };
68
71
  }
69
72
 
70
- export async function withLocalMigrationOperation<T>(id: string, dir: string, run: (operation: LocalMigrationOperation) => Promise<T>, edit = false, name?: string): Promise<T> {
71
- const operation = beginLocalMigrationOperation(id, dir, edit, name);
73
+ export async function withLocalMigrationOperation<T>(id: string, dir: string, run: (operation: LocalMigrationOperation) => Promise<T>, edit = false): Promise<T> {
74
+ const operation = beginLocalMigrationOperation(id, dir, edit);
72
75
  try { return await run(operation); }
73
76
  finally { operation.close(); }
74
77
  }
@@ -17,13 +17,20 @@ interface RequestOperation {
17
17
  refresh: boolean;
18
18
  }
19
19
 
20
+ interface PassiveListener {
21
+ idle: () => boolean;
22
+ stop: () => void;
23
+ }
24
+
20
25
  export interface MigrationOperation {
26
+ passiveListener: (listener: PassiveListener) => void;
21
27
  dispatch: (send: () => Promise<Response>, options?: { protectedSource?: boolean; effectful?: boolean; refresh?: boolean }) => Promise<Response>;
22
28
  close: () => void;
23
29
  persistedRefresh: () => void;
24
30
  }
25
31
 
26
32
  const leases = new Map<string, Set<string>>();
33
+ const listeners = new Map<string, PassiveListener>();
27
34
  const requests = new Map<string, RequestOperation>();
28
35
  const held = new Map<string, RequestOperation>();
29
36
  const keyOf = (source: string, dir: string): string => JSON.stringify([resolve(dir), source]);
@@ -103,6 +110,17 @@ export function assertMigrationOutcomeKnown(source: string, dir = agentsDir()):
103
110
  if (migrationUncertaintyReceipt(source, dir) !== null) throw uncertain();
104
111
  }
105
112
 
113
+ export function drainMigrationListeners(source: string, dir: string): void {
114
+ const active = [...(leases.get(keyOf(source, dir)) ?? [])];
115
+ if (active.length === 0 || active.some((lease) => listeners.get(lease)?.idle() !== true)) return;
116
+ if (readPending(source, dir).some((id) => requests.has(id))) return;
117
+ for (const lease of active) {
118
+ const listener = listeners.get(lease);
119
+ listeners.delete(lease);
120
+ listener?.stop();
121
+ }
122
+ }
123
+
106
124
  export function migrationOperationsIdle(source: string, dir: string, protectedSource: boolean): boolean {
107
125
  if (leases.has(keyOf(source, dir))) return false;
108
126
  return !protectedSource || readPending(source, dir).length === 0;
@@ -181,6 +199,7 @@ export function beginMigrationOperation(source: string, dir: string, protectedSo
181
199
  leases.set(key, active);
182
200
  let closed = false;
183
201
  return {
202
+ passiveListener: (listener) => { if (!closed) listeners.set(lease, listener); },
184
203
  dispatch: (send, options = {}) => {
185
204
  if (closed) return Promise.reject(uncertain());
186
205
  return dispatch(source, dir, lease, options.protectedSource ?? protectedSource, options.effectful ?? true, options.refresh ?? false, send);
@@ -191,6 +210,7 @@ export function beginMigrationOperation(source: string, dir: string, protectedSo
191
210
  close: () => {
192
211
  if (closed) return;
193
212
  closed = true;
213
+ listeners.delete(lease);
194
214
  for (const request of held.values()) if (request.lease === lease) {
195
215
  finish(request, false);
196
216
  held.delete(request.id);
@@ -201,13 +201,11 @@ function previewRows(dir: string): MigrationConnector[] {
201
201
  const rows = readRows(dir).map(previewRow);
202
202
  const seen = new Map<string, MigrationConnector>();
203
203
  for (const row of rows) {
204
- for (const key of [`id:${row.id}`, `name:${row.name}`]) {
205
- const prior = seen.get(key);
206
- if (prior !== undefined) {
207
- for (const duplicate of [prior, row]) if (!duplicate.issues.includes('invalid_config')) duplicate.issues.push('invalid_config');
208
- }
209
- seen.set(key, row);
204
+ const prior = seen.get(row.id);
205
+ if (prior !== undefined) {
206
+ for (const duplicate of [prior, row]) if (!duplicate.issues.includes('invalid_config')) duplicate.issues.push('invalid_config');
210
207
  }
208
+ seen.set(row.id, row);
211
209
  }
212
210
  return rows;
213
211
  }
@@ -51,7 +51,7 @@ export async function startOAuth(
51
51
  returnTo,
52
52
  }, operation.fetch);
53
53
  const row = await deps.createPendingConnector({
54
- name: bodyField(body, 'name'),
54
+ name,
55
55
  url: bodyField(body, 'url'),
56
56
  clientId,
57
57
  clientSecret,
@@ -69,7 +69,7 @@ export async function startOAuth(
69
69
  'connector-api: stored unconnected, sending the user to sign in',
70
70
  );
71
71
  sendJson(req, res, 201, { ...payload(row), authorizeUrl: authorize });
72
- }, true, name);
72
+ }, true);
73
73
  }
74
74
 
75
75
  export async function handleConnect(
@@ -33,21 +33,48 @@ export function serverKey(name: string): string {
33
33
  return slug === '' ? 'connector' : slug;
34
34
  }
35
35
 
36
- export function serverKeysOf(rows: PluginRow[]): Map<string, string> {
36
+ function legacyOwners(rows: PluginRow[], published: PluginRow[]): Map<string, string> {
37
+ const visible = new Set(published.map((row) => row.id));
37
38
  const taken = new Map<string, number>();
38
- const keys = new Map<string, string>();
39
- for (const row of rows.filter((row) => !isOrgRelayId(row.id))) {
40
- const wanted = serverKey(row.name);
41
- const seen = taken.get(wanted) ?? 0;
42
- taken.set(wanted, seen + 1);
43
- keys.set(row.id, seen === 0 ? wanted : `${wanted}-${row.id.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 4)}`);
39
+ const owners = new Map<string, string>();
40
+ const reserved = new Set<string>();
41
+ for (const row of rows.filter((entry) => !isOrgRelayId(entry.id))) {
42
+ const slug = serverKey(row.name);
43
+ const seen = taken.get(slug) ?? 0;
44
+ taken.set(slug, seen + 1);
45
+ const key = seen === 0 ? slug : `${slug}-${row.id.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 4)}`;
46
+ reserved.add(key);
47
+ if (visible.has(row.id)) owners.set(key, row.id);
48
+ }
49
+ for (const row of rows.filter((entry) => isOrgRelayId(entry.id) && visible.has(entry.id)).sort((a, b) => a.id.localeCompare(b.id))) {
50
+ const key = availableKey(orgKey(row), reserved);
51
+ reserved.add(key);
52
+ owners.set(key, row.id);
44
53
  }
54
+ return new Map([...owners].map(([key, id]) => [id, key]));
55
+ }
56
+
57
+ const orgKey = (row: PluginRow): string => `org-${serverKey(row.name).slice(0, 35)}-${Buffer.from(row.id.slice(4)).toString('hex')}`;
58
+
59
+ function availableKey(wanted: string, reserved: ReadonlySet<string>): string {
60
+ let key = wanted;
61
+ let suffix = 1;
62
+ while (reserved.has(key)) key = `${wanted}-${String(suffix++)}`;
63
+ return key;
64
+ }
65
+
66
+ export function serverKeysOf(rows: PluginRow[], saved?: ReadonlyMap<string, string>, published: PluginRow[] = rows): Map<string, string> {
67
+ const keys = new Map(saved ?? legacyOwners(rows, published));
45
68
  const reserved = new Set(keys.values());
46
- for (const row of rows.filter((row) => isOrgRelayId(row.id)).sort((a, b) => a.id.localeCompare(b.id))) {
47
- const wanted = `org-${serverKey(row.name).slice(0, 35)}-${Buffer.from(row.id.slice(4)).toString('hex')}`;
48
- let key = wanted;
49
- let suffix = 1;
50
- while (reserved.has(key)) key = `${wanted}-${String(suffix++)}`;
69
+ const local = rows.filter((row) => !isOrgRelayId(row.id));
70
+ const organization = rows.filter((row) => isOrgRelayId(row.id)).sort((a, b) => a.id.localeCompare(b.id));
71
+ for (const row of [...local, ...organization]) {
72
+ if (keys.has(row.id)) continue;
73
+ const slug = serverKey(row.name);
74
+ const wanted = isOrgRelayId(row.id)
75
+ ? orgKey(row)
76
+ : reserved.has(slug) ? `${slug}-${row.id.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 4)}` : slug;
77
+ const key = availableKey(wanted, reserved);
51
78
  reserved.add(key);
52
79
  keys.set(row.id, key);
53
80
  }
@@ -1,5 +1,6 @@
1
1
  import { registerConnectors, type GateRow } from './gates.js';
2
- import { serverKeysOf, syncPluginServers } from './plugin-sync.js';
2
+ import { syncPluginServers } from './plugin-sync.js';
3
+ import { persistentServerKeys } from './server-keys.js';
3
4
  import { isOrgRelayId } from './relay-id.js';
4
5
  import { migrationRecords } from './migration-state.js';
5
6
 
@@ -17,14 +18,7 @@ function metadata(rows: readonly GateRow[]): GateRow[] {
17
18
  }));
18
19
  }
19
20
 
20
- export function publishConnectors(): void {
21
- let records: ReturnType<typeof migrationRecords>;
22
- try { records = migrationRecords().filter((record) => record.phase !== 'rolled_back'); }
23
- catch (error) {
24
- registerConnectors([], local.map((row) => row.id));
25
- syncPluginServers([]);
26
- throw error;
27
- }
21
+ function publishedRows(records: ReturnType<typeof migrationRecords>): GateRow[] {
28
22
  const retired = new Set(records.filter((record) => !['preparing', 'prepared'].includes(record.phase)).map((record) => record.sourceId));
29
23
  const aliases = new Map(organization.filter((row) => records.some((record) => record.phase === 'active' && record.sourceId === row.id && record.name === row.name)).map((row) => [row.id, row]));
30
24
  const rows = local.flatMap((row) => {
@@ -32,8 +26,25 @@ export function publishConnectors(): void {
32
26
  return alias !== undefined ? [alias] : retired.has(row.id) ? [] : [row];
33
27
  });
34
28
  rows.push(...organization.filter((row) => isOrgRelayId(row.id) && !records.some((record) => row.id === `org-${record.destinationId ?? ''}`)));
35
- const keys = serverKeysOf([...local, ...rows.filter((row) => isOrgRelayId(row.id))]);
36
- registerConnectors(rows, [...retired, ...aliases.keys()], keys);
29
+ return rows;
30
+ }
31
+
32
+ export function publishConnectors(): void {
33
+ let rows: GateRow[];
34
+ let keys: Map<string, string>;
35
+ try {
36
+ const records = migrationRecords().filter((record) => record.phase !== 'rolled_back');
37
+ rows = publishedRows(records);
38
+ const active = new Set(records.filter((record) => record.phase === 'active').map((record) => record.sourceId));
39
+ const reserved = [...rows, ...local.filter((row) => active.has(row.id))];
40
+ keys = persistentServerKeys([...local, ...organization.filter((row) => isOrgRelayId(row.id))], undefined, reserved);
41
+ }
42
+ catch (error) {
43
+ registerConnectors([]);
44
+ syncPluginServers([]);
45
+ throw error;
46
+ }
47
+ registerConnectors(rows, keys);
37
48
  syncPluginServers(rows, { keys });
38
49
  }
39
50
 
@@ -0,0 +1,96 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { JSONRPCNotificationSchema, ServerNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
3
+ import { isRecord } from '@metro-labs/core/is-record';
4
+ import type { MigrationOperation } from './migration-operations.js';
5
+
6
+ export const isRelaySse = (contentType: string | null | undefined): boolean => /^text\/event-stream\s*(?:;|$)/i.test(contentType ?? '');
7
+ const MAX_FRAME = 64 * 1024;
8
+ const NOTIFICATIONS = new Set(['notifications/tools/list_changed', 'notifications/resources/list_changed', 'notifications/prompts/list_changed', 'notifications/resources/updated', 'notifications/message']);
9
+
10
+ export class PassiveSseListener {
11
+ forwarding = false;
12
+ private pending: Buffer[] = [];
13
+ private length = 0;
14
+ private data: string[] = [];
15
+ private fields = false;
16
+ private size = 0;
17
+ private unsafe = false;
18
+ private skipLf = false;
19
+ private first = true;
20
+ private readonly decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
21
+
22
+ idle(): boolean { return !this.unsafe && this.length === 0 && !this.fields && !this.forwarding; }
23
+ keepaliveAllowed(): boolean { return this.unsafe || this.idle(); }
24
+
25
+ observe(chunk: Uint8Array): void {
26
+ if (this.unsafe) return;
27
+ this.scan(chunk);
28
+ if (this.unsafe) { this.pending = []; this.length = 0; this.data = []; }
29
+ }
30
+
31
+ private append(bytes: Uint8Array): void {
32
+ this.length += bytes.length;
33
+ if (this.length + this.size > MAX_FRAME) this.unsafe = true;
34
+ else if (bytes.length > 0) this.pending.push(Buffer.from(bytes));
35
+ }
36
+
37
+ private scan(bytes: Uint8Array): void {
38
+ let start = 0;
39
+ for (let at = 0; at < bytes.length; at += 1) {
40
+ if (at - start + this.length + this.size > MAX_FRAME) { this.unsafe = true; return; }
41
+ const byte = bytes[at];
42
+ if (this.skipLf && byte === 10) { this.skipLf = false; start = at + 1; continue; }
43
+ this.skipLf = false;
44
+ if (byte !== 10 && byte !== 13) continue;
45
+ this.append(bytes.subarray(start, at));
46
+ try { this.line(this.decoder.decode(Buffer.concat(this.pending, this.length)), this.length); }
47
+ catch { this.unsafe = true; }
48
+ this.pending = []; this.length = 0;
49
+ this.skipLf = byte === 13;
50
+ start = at + 1;
51
+ if (this.unsafe) return;
52
+ }
53
+ this.append(bytes.subarray(start));
54
+ }
55
+
56
+ private line(raw: string, length: number): void {
57
+ const line = this.first && raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
58
+ this.first = false;
59
+ if (line === '') {
60
+ if (this.data.length > 0 && !this.notification()) this.unsafe = true;
61
+ this.data = []; this.fields = false; this.size = 0;
62
+ return;
63
+ }
64
+ if (line.startsWith(':')) return;
65
+ this.size += length;
66
+ if (this.size > MAX_FRAME) { this.unsafe = true; return; }
67
+ this.fields = true;
68
+ const colon = line.indexOf(':');
69
+ const field = colon < 0 ? line : line.slice(0, colon);
70
+ const value = colon < 0 ? '' : line.slice(colon + 1).replace(/^ /, '');
71
+ this.field(field, value);
72
+ }
73
+
74
+ private field(field: string, value: string): void {
75
+ if (field === 'data') this.data.push(value);
76
+ else if (field === 'event') { if (value !== '' && value !== 'message') this.unsafe = true; }
77
+ else if (field === 'retry') { if (!/^\d+$/.test(value)) this.unsafe = true; }
78
+ else this.unsafe = true;
79
+ }
80
+
81
+ private notification(): boolean {
82
+ const text = this.data.join('\n');
83
+ if (text === '') return true;
84
+ const value: unknown = JSON.parse(text);
85
+ return isRecord(value) && typeof value.method === 'string' && NOTIFICATIONS.has(value.method)
86
+ && JSONRPCNotificationSchema.safeParse(value).success && ServerNotificationSchema.safeParse(value).success;
87
+ }
88
+ }
89
+
90
+ export function passiveRelayListener(req: IncomingMessage, res: ServerResponse, upstream: Response, operation: MigrationOperation, stop: () => void): PassiveSseListener | undefined {
91
+ if (req.method !== 'GET' || req.headers['last-event-id'] !== undefined || req.headers['transfer-encoding'] !== undefined || Number(req.headers['content-length'] ?? 0) !== 0) return undefined;
92
+ if (upstream.status !== 200 || upstream.body === null || !isRelaySse(upstream.headers.get('content-type'))) return undefined;
93
+ const listener = new PassiveSseListener();
94
+ operation.passiveListener({ idle: () => listener.idle() && res.writableLength === 0 && !res.destroyed, stop });
95
+ return listener;
96
+ }
@@ -3,6 +3,7 @@ import { whyUnreachable } from '@metro-labs/http/connector-reach';
3
3
  import { noteHealth } from './health.js';
4
4
  import type { RelayTarget } from './relay-target.js';
5
5
  import type { RelaySession } from './relay-session.js';
6
+ import { isRelaySse, passiveRelayListener, type PassiveSseListener } from './relay-passive.js';
6
7
  import { isOrgRelayId } from './relay-id.js';
7
8
  import { exchangeOrg } from './org-relay.js';
8
9
  import { assertLocalMigrationUse, migrationSource, withLocalMigrationOperation } from './migration-guard.js';
@@ -131,12 +132,12 @@ interface Keepalive {
131
132
  stop: () => void;
132
133
  }
133
134
 
134
- function startKeepalive(res: ServerResponse): Keepalive {
135
+ function startKeepalive(res: ServerResponse, listener?: PassiveSseListener): Keepalive {
135
136
  let timer: ReturnType<typeof setTimeout> | undefined;
136
137
  const arm = (): void => {
137
138
  timer = setTimeout(() => {
138
139
  try {
139
- res.write(': keepalive\n\n');
140
+ if (listener?.keepaliveAllowed() !== false) res.write(': keepalive\n\n');
140
141
  } catch {
141
142
  log.debug('relay: keepalive write failed');
142
143
  }
@@ -172,13 +173,17 @@ async function pumpBody(
172
173
  res: ServerResponse,
173
174
  body: ReadableStream<Uint8Array>,
174
175
  touch: () => void,
176
+ listener?: PassiveSseListener,
175
177
  ): Promise<void> {
176
178
  const reader = body.getReader();
177
179
  for (;;) {
178
180
  const { done, value } = await reader.read();
179
181
  if (done) return;
182
+ listener?.observe(value);
180
183
  touch();
184
+ if (listener !== undefined) listener.forwarding = true;
181
185
  if (!res.write(value)) await drainedOrClosed(res);
186
+ if (listener !== undefined) listener.forwarding = false;
182
187
  }
183
188
  }
184
189
 
@@ -191,22 +196,23 @@ function passedHeaders(upstream: Response): Record<string, string> {
191
196
  return headers;
192
197
  }
193
198
 
194
- async function pipe(res: ServerResponse, upstream: Response, answers: Record<string, unknown>[]): Promise<void> {
199
+ async function pipe(res: ServerResponse, upstream: Response, answers: Record<string, unknown>[], listener?: PassiveSseListener): Promise<void> {
195
200
  const headers = passedHeaders(upstream);
196
201
  if (answers.length > 0 && (upstream.status === 200 || upstream.status === 202)) {
197
202
  await mergeBlocked(res, upstream, headers, answers);
198
203
  return;
199
204
  }
200
- const sse = (headers['content-type'] ?? '').includes('text/event-stream');
205
+ const sse = isRelaySse(headers['content-type']);
201
206
  if (sse) headers['x-accel-buffering'] = 'no';
202
207
  res.writeHead(upstream.status, headers);
208
+ if (sse) res.flushHeaders();
203
209
  if (upstream.body === null) {
204
210
  res.end();
205
211
  return;
206
212
  }
207
- const keepalive = sse ? startKeepalive(res) : null;
213
+ const keepalive = sse ? startKeepalive(res, listener) : null;
208
214
  try {
209
- await pumpBody(res, upstream.body, () => keepalive?.touch());
215
+ await pumpBody(res, upstream.body, () => keepalive?.touch(), listener);
210
216
  } finally {
211
217
  keepalive?.stop();
212
218
  }
@@ -262,7 +268,7 @@ async function relayExchange(
262
268
  res.socket?.removeListener('close', bail);
263
269
  });
264
270
  try {
265
- await relayOnce(req, res, connectorId, deps, control.signal);
271
+ await relayOnce(req, res, connectorId, deps, control);
266
272
  } catch (err) {
267
273
  if (!control.signal.aborted) throw err;
268
274
  log.debug({ connector: connectorId }, 'relay: the client left before the answer ended');
@@ -276,6 +282,7 @@ async function deliver(
276
282
  deps: RelayApiDeps,
277
283
  out: Exchanged,
278
284
  answers: Record<string, unknown>[],
285
+ listener?: PassiveSseListener,
279
286
  ): Promise<void> {
280
287
  if (out.kind === 'missing') {
281
288
  answer(res, 404, { error: 'no such connector' });
@@ -295,7 +302,7 @@ async function deliver(
295
302
  }
296
303
  const { status } = out.upstream;
297
304
  noteHealth(connectorId, status < 400, status < 400 ? null : `the connector answered ${String(status)}`);
298
- await pipe(res, out.upstream, answers);
305
+ await pipe(res, out.upstream, answers, listener);
299
306
  }
300
307
 
301
308
  async function relayOrg(req: IncomingMessage, res: ServerResponse, id: string, deps: RelayApiDeps, read: Uint8Array<ArrayBuffer> | null, signal: AbortSignal): Promise<void> {
@@ -316,8 +323,9 @@ async function relayOnce(
316
323
  res: ServerResponse,
317
324
  connectorId: string,
318
325
  deps: RelayApiDeps,
319
- signal: AbortSignal,
326
+ control: AbortController,
320
327
  ): Promise<void> {
328
+ const { signal } = control;
321
329
  const read = req.method === 'POST' ? await readCapped(req) : null;
322
330
  const dir = deps.migrationDir ?? agentsDir();
323
331
  if (isOrgRelayId(connectorId) || migrationSource(connectorId, dir)?.phase === 'active') return relayOrg(req, res, connectorId, deps, read, signal);
@@ -328,7 +336,8 @@ async function relayOnce(
328
336
  return;
329
337
  }
330
338
  const out = await exchange(req, connectorId, deps, screened === null ? read : screened.forward, signal, operation.dispatch);
331
- await deliver(res, connectorId, deps, out, screened?.answers ?? []);
339
+ const listener = out.kind === 'response' ? passiveRelayListener(req, res, out.upstream, operation, () => { control.abort(); }) : undefined;
340
+ await deliver(res, connectorId, deps, out, screened?.answers ?? [], listener);
332
341
  });
333
342
  }
334
343
 
@@ -0,0 +1,46 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { isRecord } from '@metro-labs/core/is-record';
4
+ import { writeSecure } from '@metro-labs/core/secure-fs';
5
+ import { agentsDir } from '../agents/files.js';
6
+ import { serverKeysOf, type PluginRow } from './plugin-sync.js';
7
+
8
+ const invalid = (): Error => new Error('Invalid connector server keys. Restore the saved namespace mapping and initialization marker.');
9
+
10
+ function readKeys(path: string, marker: string): Map<string, string> | undefined {
11
+ try {
12
+ const marked = existsSync(marker);
13
+ if (marked && readFileSync(marker, 'utf8') !== '1\n') throw invalid();
14
+ if (!existsSync(path)) {
15
+ if (marked) throw invalid();
16
+ return undefined;
17
+ }
18
+ if (!marked) throw invalid();
19
+ return parseKeys(readFileSync(path, 'utf8'));
20
+ } catch { throw invalid(); }
21
+ }
22
+
23
+ function parseKeys(text: string): Map<string, string> {
24
+ const raw: unknown = JSON.parse(text);
25
+ if (!isRecord(raw) || raw.version !== 1 || !isRecord(raw.keys)) throw invalid();
26
+ const keys = new Map<string, string>();
27
+ const reserved = new Set<string>();
28
+ for (const [id, key] of Object.entries(raw.keys)) {
29
+ if (typeof key !== 'string' || !/^[a-z0-9-]+$/.test(key) || reserved.has(key)) throw invalid();
30
+ reserved.add(key);
31
+ keys.set(id, key);
32
+ }
33
+ return keys;
34
+ }
35
+
36
+ export function persistentServerKeys(rows: PluginRow[], dir = agentsDir(), published: PluginRow[] = rows): Map<string, string> {
37
+ const path = join(dir, 'connector-server-keys.json');
38
+ const marker = join(dir, 'connector-server-keys.initialized');
39
+ const saved = readKeys(path, marker);
40
+ const keys = serverKeysOf(rows, saved, published);
41
+ if (keys.size > 0 && keys.size !== saved?.size) {
42
+ if (saved === undefined) writeSecure(marker, '1\n');
43
+ writeSecure(path, `${JSON.stringify({ version: 1, keys: Object.fromEntries(keys) }, null, 2)}\n`);
44
+ }
45
+ return keys;
46
+ }
@@ -1,6 +1,7 @@
1
1
  import { join } from 'node:path';
2
2
  import { closeSync, fsyncSync, openSync } from 'node:fs';
3
3
  import { publishLocalConnectors } from './publication.js';
4
+ import { persistentServerKeys } from './server-keys.js';
4
5
  import { assertLocalMigrationEdit, localMigrationEnabled, localMigrationIdle, migrationSource, withLocalMigrationOperation } from './migration-guard.js';
5
6
  import type { FetchLike } from '@metro-labs/core/stations/oauth';
6
7
  import type { RelayTarget } from './relay-target.js';
@@ -116,6 +117,8 @@ export function readLocalConnectors(dir = agentsDir()): LocalConnectorRow[] {
116
117
  }
117
118
 
118
119
  function writeRows(dir: string, rows: LocalConnectorRow[]): void {
120
+ persistentServerKeys(readLocalConnectors(dir), dir);
121
+ persistentServerKeys(rows, dir);
119
122
  writeSecure(filePath(dir), `${JSON.stringify({ version: 1, connectors: rows }, null, 2)}\n`);
120
123
  if (dir !== agentsDir()) return;
121
124
  publishLocalConnectors(rows);
@@ -130,7 +133,7 @@ function rowOrThrow(id: string, dir: string): LocalConnectorRow {
130
133
  }
131
134
 
132
135
  function replace(dir: string, row: LocalConnectorRow, refresh = false): Connector {
133
- if (!refresh) assertLocalMigrationEdit(row.id, row.name, dir);
136
+ if (!refresh) assertLocalMigrationEdit(row.id, dir);
134
137
  writeRows(dir, readLocalConnectors(dir).map((r) => (r.id === row.id ? row : r)));
135
138
  return connectorFromRow(row);
136
139
  }
@@ -150,17 +153,17 @@ export function localImportConnectors(
150
153
  const fresh =
151
154
  mode === 'overwrite'
152
155
  ? rows
153
- : rows.filter((row) => !current.some((r) => r.id === row.id || r.name === row.name));
156
+ : rows.filter((row) => !current.some((r) => r.id === row.id));
154
157
  const imported: LocalConnectorRow[] = fresh.map((row) => ({
155
158
  id: row.id,
156
159
  name: row.name,
157
160
  url: row.url,
158
161
  config: readConfig(row.config),
159
162
  }));
160
- for (const row of imported) assertLocalMigrationEdit(row.id, row.name, dir);
163
+ for (const row of imported) assertLocalMigrationEdit(row.id, dir);
161
164
  const ids = new Set(imported.map((r) => r.id));
162
- const names = new Set(imported.map((r) => r.name));
163
- writeRows(dir, [...current.filter((r) => !ids.has(r.id) && !names.has(r.name)), ...imported]);
165
+ if (ids.size !== imported.length) throw new ConnectorError('The import contains duplicate connector IDs.', 400);
166
+ writeRows(dir, [...current.filter((r) => !ids.has(r.id)), ...imported]);
164
167
  return imported.length;
165
168
  }
166
169
 
@@ -172,14 +175,7 @@ export async function localGetConnector(id: string, dir = agentsDir()): Promise<
172
175
  return Promise.resolve(connectorFromRow(rowOrThrow(id, dir)));
173
176
  }
174
177
 
175
- function assertNameFree(name: string, exceptId: string | null, dir: string): void {
176
- if (readLocalConnectors(dir).some((r) => r.id !== exceptId && r.name === name))
177
- throw new ConnectorError(`a connector named '${name}' already exists on this daemon`, 409);
178
- }
179
-
180
178
  function insert(dir: string, name: string, url: URL, config: ConnectorConfig): Connector {
181
- assertLocalMigrationEdit('', name, dir);
182
- assertNameFree(name, null, dir);
183
179
  const row: LocalConnectorRow = { id: newId(), name, url: connectorUrlText(url), config };
184
180
  writeRows(dir, [...readLocalConnectors(dir), row]);
185
181
  return connectorFromRow(row);
@@ -193,7 +189,7 @@ export async function localCreateConnector(input: ConnectorInput, dir = agentsDi
193
189
  return withLocalMigrationOperation('', dir, async (operation) => {
194
190
  const verified = stamp(await verifyRemoteMcp(url, auth, operation.fetch));
195
191
  return insert(dir, name, url, { auth, createdAt: new Date().toISOString(), verified, oauth: await oauthCapable(url, auth, operation.fetch), client });
196
- }, true, name);
192
+ }, true);
197
193
  }
198
194
 
199
195
  export async function localCreatePendingConnector(input: PendingConnectorInput, dir = agentsDir()): Promise<Connector> {
@@ -309,13 +305,12 @@ export async function loadConnectorPolicies(dir = agentsDir()): Promise<void> {
309
305
  export async function localRenameConnector(id: string, raw: string, dir = agentsDir()): Promise<Connector> {
310
306
  const name = connectorName(raw);
311
307
  const row = rowOrThrow(id, dir);
312
- assertNameFree(name, id, dir);
313
308
  return Promise.resolve(replace(dir, { ...row, name }));
314
309
  }
315
310
 
316
311
  export async function localDeleteConnector(id: string, dir = agentsDir()): Promise<DeletedConnector> {
317
312
  const row = rowOrThrow(id, dir);
318
- assertLocalMigrationEdit(id, row.name, dir);
313
+ assertLocalMigrationEdit(id, dir);
319
314
  writeRows(dir, readLocalConnectors(dir).filter((r) => r.id !== id));
320
315
  return Promise.resolve({ id: row.id, name: row.name });
321
316
  }
@@ -6,7 +6,7 @@ import { connectorAuth, connectorName } from '@metro-labs/http/connector-input';
6
6
  import { readConfig } from './config.js';
7
7
  import { parseConnectorUrl } from '@metro-labs/http/connector-url';
8
8
  import { normalizePolicy } from '../policy/policy.js';
9
- import { localImportConnectors, readLocalConnectors, type LocalConnectorRow } from './store.js';
9
+ import { localImportConnectors, type LocalConnectorRow } from './store.js';
10
10
 
11
11
  export interface ConnectorCopyResult {
12
12
  sourceId: string | null;
@@ -28,7 +28,6 @@ function copyRow(raw: unknown): LocalConnectorRow {
28
28
  export function copyConnectorRows(value: unknown, dir?: string): ConnectorCopyResult[] {
29
29
  if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.connectors) || value.connectors.length > 500)
30
30
  throw new ApiError('invalid connector transfer', 400);
31
- const names = new Set(readLocalConnectors(dir).map((row) => row.name));
32
31
  const imported: LocalConnectorRow[] = [];
33
32
  const results: ConnectorCopyResult[] = value.connectors.map((raw: unknown) => {
34
33
  const sourceId = isRecord(raw) && typeof raw.id === 'string' ? parseId(raw.id) : null;
@@ -38,8 +37,6 @@ export function copyConnectorRows(value: unknown, dir?: string): ConnectorCopyRe
38
37
  } catch {
39
38
  return { sourceId, status: 'invalid' };
40
39
  }
41
- if (names.has(row.name)) return { sourceId, status: 'skipped' };
42
- names.add(row.name);
43
40
  const id = newId();
44
41
  imported.push({ ...row, id });
45
42
  return { sourceId, id, status: 'copied' };
@@ -1,6 +1,6 @@
1
1
  import { str } from '@metro-labs/core/str';
2
2
  import { readPreview, shownValue } from '../approvals/preview.js';
3
- import { connectorToolOf } from '../connectors/gates.js';
3
+ import { connectorGates, connectorToolOf } from '../connectors/gates.js';
4
4
  import { stationByName } from '../stations/registry.js';
5
5
  import { stationOfAccount } from './read-tool.js';
6
6
 
@@ -92,7 +92,11 @@ function bodyLines(params: PermissionParams): string[] {
92
92
  const raw = params.input_preview.trim() === '' ? [] : [shorten(params.input_preview, RAW_MAX)];
93
93
  if (metro !== undefined) return input === undefined ? [`Approval needed: ${metro}`, ...raw] : metroLines(metro, input);
94
94
  const connector = connectorToolOf(params.tool_name);
95
- if (connector !== undefined) return connectorLines(connector.gate.name, connector.tool, input);
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
+ }
96
100
  return [`Claude wants to run ${params.tool_name}: ${shorten(params.description)}`, '', ...raw];
97
101
  }
98
102
 
@@ -4,7 +4,6 @@ import { accountFromLine, knownAccounts } from '../agents/map.js';
4
4
  import { policyFor, storedPolicy, strictest, type Access, type PolicyTarget } from '../policy/policy.js';
5
5
  import { errResult } from './ctx.js';
6
6
  import { NEEDS_APPROVAL, takeGrant } from '../approvals/pending.js';
7
- import { connectorToolOf } from '../connectors/gates.js';
8
7
  import { profileScopeLine } from './profile-lookup.js';
9
8
  import { stationOfAccount } from './read-tool.js';
10
9
  import { stationForTool, toolGroupOf } from './tool-catalog.js';
@@ -56,7 +55,7 @@ export function channelDecision(name: string, a: Record<string, unknown>): Polic
56
55
  return verdict;
57
56
  }
58
57
 
59
- const isChannelTool = (name: string) => (tool: string): boolean => tool.endsWith(`__${name}`) && connectorToolOf(tool) === undefined;
58
+ const isChannelTool = (name: string) => (tool: string): boolean => tool === `mcp__metro__${name}`;
60
59
 
61
60
  export function policyGate(name: string, a: Record<string, unknown>): ToolResult | undefined {
62
61
  const { access, target } = channelDecision(name, a);
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.292"
2
+ "version": "0.1.0-beta.293"
3
3
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.292",
2
+ "version": "0.1.0-beta.293",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",