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

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 (81) hide show
  1. package/dist/cli.js +1 -1
  2. package/dist/memory.js +2 -3
  3. package/dist/runtime-install.js +3 -11
  4. package/dist/voice.js +1 -1
  5. package/{runtime/marketplace/plugin/bin → hooks}/guard.mjs +9 -4
  6. package/{runtime/marketplace/plugin/bin → hooks}/policy.mjs +1 -32
  7. package/{runtime/marketplace/plugin/bin → hooks}/session-start.mjs +2 -2
  8. package/package.json +4 -2
  9. package/runtime/node_modules/@metro-labs/core/package.json +0 -1
  10. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/home-fs.ts +2 -29
  11. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +2 -15
  12. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +0 -1
  13. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +3 -3
  14. package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +3 -5
  15. package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +1 -7
  16. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +16 -17
  17. package/runtime/node_modules/@metro-labs/daemon/src/claude/cli-files.ts +28 -0
  18. package/runtime/node_modules/@metro-labs/daemon/src/claude/hooks.ts +60 -0
  19. package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +121 -0
  20. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +2 -4
  21. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +38 -30
  22. package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +0 -2
  23. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +6 -0
  24. package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +4 -2
  25. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +1 -13
  26. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +2 -1
  27. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +2 -16
  28. package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +15 -16
  29. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +4 -0
  30. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-catalog.ts +20 -8
  31. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +14 -8
  32. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/announced.ts +68 -0
  33. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +2 -2
  34. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/connector-tools.ts +114 -0
  35. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/link.ts +12 -1
  36. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/rpc.ts +67 -0
  37. package/runtime/node_modules/@metro-labs/daemon/src/policy/policy.ts +6 -5
  38. package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +0 -2
  39. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -27
  40. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -5
  41. package/runtime/node_modules/@metro-labs/http/package.json +3 -3
  42. package/runtime/node_modules/@metro-labs/http/src/connector-grant.ts +0 -51
  43. package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +9 -4
  44. package/runtime/node_modules/@metro-labs/http/src/mcp-server-tools.ts +93 -0
  45. package/runtime/runtime.json +1 -1
  46. package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +0 -1
  47. package/runtime/sdk-runner/runner.json +1 -1
  48. package/{runtime/marketplace/plugin → skills}/METRO.md +8 -3
  49. package/runtime/marketplace/.claude-plugin/marketplace.json +0 -12
  50. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +0 -17
  51. package/runtime/marketplace/plugin/.mcp.json +0 -1
  52. package/runtime/marketplace/plugin/bin/metro-plugin.mjs +0 -48
  53. package/runtime/marketplace/plugin/hooks/hooks.json +0 -25
  54. package/runtime/node_modules/@metro-labs/core/src/connector-sync.ts +0 -17
  55. package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-install.ts +0 -149
  56. package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +0 -104
  57. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-adopt.ts +0 -57
  58. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-relay.ts +0 -61
  59. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +0 -115
  60. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +0 -271
  61. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-api.ts +0 -25
  62. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +0 -173
  63. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-setting.ts +0 -60
  64. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +0 -38
  65. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +0 -208
  66. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +0 -145
  67. package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +0 -40
  68. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-id.ts +0 -2
  69. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-policy.ts +0 -90
  70. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +0 -10
  71. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-target.ts +0 -4
  72. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +0 -287
  73. package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +0 -59
  74. package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +0 -1
  75. package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +0 -116
  76. package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +0 -19
  77. package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +0 -178
  78. package/runtime/node_modules/@metro-labs/http/src/transfer-crypto.ts +0 -44
  79. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/connector-sync.ts +0 -17
  80. /package/{runtime/marketplace/plugin → skills}/MEMORY.md +0 -0
  81. /package/{runtime/marketplace/plugin → skills}/STAGE.md +0 -0
@@ -0,0 +1,121 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { isRecord } from '@metro-labs/core/is-record';
5
+ import { errMsg, log } from '@metro-labs/core/log';
6
+ import { existsSync } from '../agent-user/agent-fs.js';
7
+ import { removeHome } from '../agent-user/home-fs.js';
8
+ import { agentUser, asAgent, claudeBin } from '../agent-user/user.js';
9
+ import { agentsDir } from '../agents/files.js';
10
+ import { readSetupState, writeSetupState } from './setup-state.js';
11
+
12
+ const PLUGIN = 'metro@metro';
13
+ const MARKETPLACE = 'metro';
14
+ const DONE = 'pluginRemoved';
15
+ const STEP_MS = 60_000;
16
+ const SAID_MAX = 300;
17
+
18
+ export interface Answer {
19
+ status: number;
20
+ stdout: string;
21
+ stderr: string;
22
+ }
23
+
24
+ export type Run = (args: string[]) => Promise<Answer>;
25
+
26
+ export interface CleanupDeps {
27
+ run?: Run | null;
28
+ agents?: string;
29
+ leftover?: string | null;
30
+ }
31
+
32
+ export type CleanupOutcome = 'done' | 'clean' | 'removed' | 'skipped' | 'failed';
33
+
34
+ export const claudeAt =
35
+ (bin: string): Run =>
36
+ (args) =>
37
+ new Promise((resolve) => {
38
+ execFile(...asAgent(bin, args), { encoding: 'utf8', timeout: STEP_MS, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
39
+ const code = (err as { code?: unknown } | null)?.code;
40
+ resolve({ status: err === null ? 0 : typeof code === 'number' ? code : 1, stdout, stderr });
41
+ });
42
+ });
43
+
44
+ async function findClaude(): Promise<Run | null> {
45
+ const candidates = agentUser() === null ? ['claude', join(homedir(), '.local', 'bin', 'claude')] : [claudeBin()];
46
+ for (const bin of candidates) {
47
+ const run = claudeAt(bin);
48
+ if ((await run(['--version'])).status === 0) return run;
49
+ }
50
+ return null;
51
+ }
52
+
53
+ function listOf(stdout: string): Record<string, unknown>[] {
54
+ try {
55
+ const parsed: unknown = JSON.parse(stdout);
56
+ return Array.isArray(parsed) ? parsed.filter(isRecord) : [];
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+
62
+ async function listed(run: Run, args: string[], key: string, wanted: string): Promise<boolean | null> {
63
+ const answer = await run(args);
64
+ if (answer.status !== 0) {
65
+ log.warn({ args, said: `${answer.stderr}${answer.stdout}`.trim().slice(0, SAID_MAX) }, 'claude-plugin: claude could not list');
66
+ return null;
67
+ }
68
+ return listOf(answer.stdout).some((entry) => entry[key] === wanted);
69
+ }
70
+
71
+ async function step(run: Run, args: string[]): Promise<boolean> {
72
+ const answer = await run(args);
73
+ if (answer.status === 0) {
74
+ log.info({ args }, 'claude-plugin: removed what the old metro plugin left');
75
+ return true;
76
+ }
77
+ log.warn({ args, said: `${answer.stderr}${answer.stdout}`.trim().slice(0, SAID_MAX) }, 'claude-plugin: claude refused');
78
+ return false;
79
+ }
80
+
81
+ async function removeIfListed(run: Run, list: string[], key: string, wanted: string, remove: string[]): Promise<'gone' | 'removed' | 'failed'> {
82
+ const present = await listed(run, list, key, wanted);
83
+ if (present === null) return 'failed';
84
+ if (!present) return 'gone';
85
+ return (await step(run, remove)) ? 'removed' : 'failed';
86
+ }
87
+
88
+ function leftoverDir(): string | null {
89
+ const user = agentUser();
90
+ return user === null ? null : join(user.home, '.metro', 'marketplace');
91
+ }
92
+
93
+ function removeLeftover(dir: string | null): boolean {
94
+ if (dir === null || !existsSync(dir)) return true;
95
+ try {
96
+ removeHome(dir, true);
97
+ log.info({ dir }, 'claude-plugin: removed the old plugin marketplace copy');
98
+ return true;
99
+ } catch (err) {
100
+ log.warn({ dir, err: errMsg(err) }, 'claude-plugin: could not remove the old plugin marketplace copy');
101
+ return false;
102
+ }
103
+ }
104
+
105
+ async function cleanUp(run: Run, leftover: string | null): Promise<'removed' | 'clean' | 'failed'> {
106
+ const plugin = await removeIfListed(run, ['plugin', 'list', '--json'], 'id', PLUGIN, ['plugin', 'uninstall', PLUGIN]);
107
+ const market = await removeIfListed(run, ['plugin', 'marketplace', 'list', '--json'], 'name', MARKETPLACE, ['plugin', 'marketplace', 'remove', MARKETPLACE]);
108
+ const folder = removeLeftover(leftover);
109
+ if (plugin === 'failed' || market === 'failed' || !folder) return 'failed';
110
+ return plugin === 'removed' || market === 'removed' ? 'removed' : 'clean';
111
+ }
112
+
113
+ export async function removeMetroPlugin(deps: CleanupDeps = {}): Promise<CleanupOutcome> {
114
+ const agents = deps.agents ?? agentsDir();
115
+ if (readSetupState(agents)[DONE] === true) return 'done';
116
+ const run = deps.run === undefined ? await findClaude() : deps.run;
117
+ if (run === null) return 'skipped';
118
+ const outcome = await cleanUp(run, deps.leftover === undefined ? leftoverDir() : deps.leftover);
119
+ if (outcome !== 'failed') writeSetupState(agents, { [DONE]: true });
120
+ return outcome;
121
+ }
@@ -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;
@@ -354,7 +352,7 @@ export function ensureSession(deps: SessionDeps = {}): Ensured {
354
352
  const held = holdsBack(deps, r.now);
355
353
  if (held !== null) return held;
356
354
  if (found === 'stale') {
357
- log.info({ was: startedVersion(r.agents), now: r.version }, 'claude-session: restarting the session an older metro started, so the new plugin and flags load');
355
+ log.info({ was: startedVersion(r.agents), now: r.version }, 'claude-session: restarting the session an older metro started, so the new hooks and flags load');
358
356
  stopSession(deps);
359
357
  }
360
358
  startSession(deps);
@@ -2,7 +2,6 @@ import { createHash } from 'node:crypto';
2
2
  import { rmSync } from 'node:fs';
3
3
  import { existsSync, readFileSync, statSync } from '../agent-user/agent-fs.js';
4
4
  import { join } from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
5
  import { errMsg, log } from '@metro-labs/core/log';
7
6
  import { isRecord } from '@metro-labs/core/is-record';
8
7
  import { writeAtomic } from '@metro-labs/core/secure-fs';
@@ -10,7 +9,8 @@ import { agentsDir } from '../agents/files.js';
10
9
  import { moveHome, removeHome, writeHomeText } from '../agent-user/home-fs.js';
11
10
  import { claudeDir } from './files.js';
12
11
  import { memoryJobStatus, type MemoryJob } from './memory-routine.js';
13
- import { stagedMarketplaceDir } from './plugin-install.js';
12
+ import { cliPackageDir, hooksDir, skillsDir } from './cli-files.js';
13
+ import { hasMetroGuard, withMetroHooks } from './hooks.js';
14
14
  import { readModelConfig, routedConnection, type ModelConfig } from '../gateway/model-config.js';
15
15
  import { harnessRunner, runnerModel, sdkAllowed, sdkOnLogin, type HarnessRunner } from './runner.js';
16
16
  import { readSetupState, writeSetupState } from './setup-state.js';
@@ -49,7 +49,7 @@ Your final message is a report to the orchestrator, not a message to a human. It
49
49
  export interface SetupDeps {
50
50
  dir?: string;
51
51
  agents?: string;
52
- plugin?: string;
52
+ cli?: string;
53
53
  env?: NodeJS.ProcessEnv;
54
54
  }
55
55
 
@@ -58,7 +58,7 @@ export type SettingsOutcome = 'written' | 'unchanged' | 'unreadable';
58
58
 
59
59
  export interface SetupReport {
60
60
  privacy: boolean;
61
- guard: 'plugin';
61
+ guard: boolean;
62
62
  worker: Placed;
63
63
  skill: Placed;
64
64
  stage: Placed;
@@ -76,7 +76,7 @@ export interface SetupStatus {
76
76
  liveEvents: boolean;
77
77
  memoryRoutine: boolean;
78
78
  memoryJob: MemoryJob;
79
- guard: 'plugin';
79
+ guard: boolean;
80
80
  worker: boolean;
81
81
  skill: boolean;
82
82
  stage: boolean;
@@ -134,15 +134,6 @@ export function setSystemPrompt(text: string, agents = agentsDir()): void {
134
134
  writeAtomic(promptPath(agents), `${trimmed}\n`, 0o600);
135
135
  }
136
136
 
137
- function pluginDir(env: NodeJS.ProcessEnv = process.env): string {
138
- const staged = stagedMarketplaceDir(env);
139
- if (staged !== null) {
140
- const dir = join(staged, 'plugin');
141
- if (existsSync(join(dir, RULES_FILE))) return dir;
142
- }
143
- return fileURLToPath(new URL('../../../../plugin', import.meta.url));
144
- }
145
-
146
137
  const PRIOR_WORKER: ReadonlySet<string> = new Set(['08a0cd8710df285d6512245246bb8b658b7cfc7f3e89490514ee5376779eb2ef']);
147
138
  const PRIOR_METRO: ReadonlySet<string> = new Set([
148
139
  '0b3d122ed95beff09d579cf912cd4238e1db524c41fce4b314de57d6ff5908ad',
@@ -152,6 +143,9 @@ const PRIOR_METRO: ReadonlySet<string> = new Set([
152
143
  '5ce3f76d610aae91adc922d0edc6c8be1f27aa2fec4a5e3a6d6231a04fb9310b',
153
144
  'b910597d24f31fa479b130a2545f94dd23684d802d99e36b637fc57c818a33be',
154
145
  'd3f7ed0f5175b9a7a77f06f6204ae0020369d0c8f6e2724196bb3c5822d4d8ba',
146
+ '587a1334f91a8b61541573cef16eaf44fbe63049a8d8b461fa9f5cc55bacbcb8',
147
+ '256c711a6175266c216e32c88b7190d0f98f5e6b702abe03220b422aba426574',
148
+ '9e164a956437d60d30ea1984115a17d303940d191f5efed04954f244fc89d48f',
155
149
  ]);
156
150
  const PRIOR_STAGE: ReadonlySet<string> = new Set([
157
151
  'dd7d30474765de3865ddc87c1cf3220161ded09502027cdff18597e5df0aab43',
@@ -219,15 +213,29 @@ function mergeSettings(dir: string, change: (settings: Record<string, unknown>)
219
213
  return 'written';
220
214
  }
221
215
 
222
- function applyPrivacy(dir: string, enabled: boolean): SettingsOutcome {
223
- return mergeSettings(dir, (current) => withPrivacy(current, enabled));
216
+ interface SettingsApplied {
217
+ settings: SettingsOutcome;
218
+ guard: boolean;
219
+ }
220
+
221
+ function applySettings(dir: string, privacy: boolean, hooks: string | null): SettingsApplied {
222
+ let guard = false;
223
+ const settings = mergeSettings(dir, (current) => {
224
+ const next = withPrivacy(current, privacy);
225
+ const hooked = hooks === null ? null : withMetroHooks(next, hooks);
226
+ if (hooks !== null && hooked === null)
227
+ log.warn({ path: join(dir, 'settings.json') }, 'claude-setup: the hooks in settings.json are not in the shape Claude Code reads, so metro did not add its own');
228
+ guard = hasMetroGuard(hooked ?? next);
229
+ return hooked ?? next;
230
+ });
231
+ return { settings, guard };
224
232
  }
225
233
 
226
234
  const skillPath = (dir: string, name: string): string => join(dir, 'skills', name, 'SKILL.md');
227
235
  const workerPath = (dir: string): string => join(dir, 'agents', 'worker.md');
228
236
 
229
- function placeSkill(dir: string, plugin: string, skill: ShippedSkill): Placed {
230
- const source = join(plugin, skill.file);
237
+ function placeSkill(dir: string, skills: string, skill: ShippedSkill): Placed {
238
+ const source = join(skills, skill.file);
231
239
  if (!existsSync(source)) return 'missing';
232
240
  return placeFile(skillPath(dir, skill.name), readFileSync(source, 'utf8'), skill.prior);
233
241
  }
@@ -264,21 +272,21 @@ export function ensureClaudeSetup(deps: SetupDeps = {}): SetupReport {
264
272
  const dir = deps.dir ?? claudeDir();
265
273
  const agents = deps.agents ?? agentsDir();
266
274
  const privacy = privacyEnabled(agents);
267
- const plugin = deps.plugin ?? pluginDir(deps.env);
275
+ const cli = deps.cli ?? cliPackageDir(deps.env);
276
+ const hooks = hooksDir(cli);
268
277
  tryMoveRenamedSkill(dir);
269
- const report: SetupReport = {
270
- privacy,
271
- guard: 'plugin',
272
- worker: placeFile(workerPath(dir), WORKER_AGENT, PRIOR_WORKER),
273
- skill: placeSkill(dir, plugin, METRO_SKILL),
274
- stage: placeSkill(dir, plugin, STAGE_SKILL),
275
- memory: placeSkill(dir, plugin, MEMORY_SKILL),
276
- settings: applyPrivacy(dir, privacy),
277
- };
278
+ const worker = placeFile(workerPath(dir), WORKER_AGENT, PRIOR_WORKER);
279
+ const skills = skillsDir(cli);
280
+ const skill = placeSkill(dir, skills, METRO_SKILL);
281
+ const stage = placeSkill(dir, skills, STAGE_SKILL);
282
+ const memory = placeSkill(dir, skills, MEMORY_SKILL);
283
+ const applied = applySettings(dir, privacy, hooks);
284
+ const report: SetupReport = { privacy, guard: applied.guard, worker, skill, stage, memory, settings: applied.settings };
278
285
  syncAvailableModelsQuietly({ ...deps, dir, agents });
279
286
  if ([report.worker, report.skill, report.stage, report.memory].some(written) || report.settings === 'written')
280
287
  log.info(report, 'claude-setup: applied the Claude Code setup for a metro box');
281
- if (report.settings === 'unreadable') log.warn({ path: join(dir, 'settings.json') }, 'claude-setup: settings.json is not valid JSON, so the privacy settings were not written');
288
+ if (report.settings === 'unreadable') log.warn({ path: join(dir, 'settings.json') }, 'claude-setup: settings.json is not valid JSON, so the privacy settings and metro hooks were not written');
289
+ if (hooks === null) log.warn({ cli }, 'claude-setup: metro hooks were not found next to the CLI, so the guard and the standing rules are not in settings.json');
282
290
  return report;
283
291
  }
284
292
 
@@ -298,7 +306,7 @@ export function claudeSetupStatus(deps: SetupDeps = {}): SetupStatus {
298
306
  liveEvents: liveEvents(agents),
299
307
  memoryRoutine: memoryRoutine(agents),
300
308
  memoryJob: memoryJobStatus(),
301
- guard: 'plugin',
309
+ guard: hasMetroGuard(settings),
302
310
  worker: existsSync(workerPath(dir)),
303
311
  skill: existsSync(skillPath(dir, METRO_SKILL.name)),
304
312
  stage: existsSync(skillPath(dir, STAGE_SKILL.name)),
@@ -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);
@@ -21,6 +21,7 @@ import { SessionSlot } from './session-slot.js';
21
21
  import { connectCallBridge } from './call-bridge.js';
22
22
  import { connectServerApprovals } from './server-approvals.js';
23
23
  import { handleIncomingDiagnostic } from './diagnostics.js';
24
+ import { onConnectorToolsChanged } from '../mcp-server/connector-tools.js';
24
25
 
25
26
  const isInitialize = (b: unknown): boolean =>
26
27
  !!b &&
@@ -99,6 +100,7 @@ async function serveGet(
99
100
  const isVoicePath = (req: IncomingMessage): boolean => (req.url ?? '').split('?')[0] === VOICE_MCP_PATH;
100
101
 
101
102
  let activeSlots: SessionSlot[] = [];
103
+ let stopToolNotices: (() => void) | undefined;
102
104
 
103
105
  const MODEL_NOTICE = 'notifications/metro/model';
104
106
 
@@ -120,6 +122,10 @@ export async function createMetroMcp(options: { liveEvents?: boolean } = {}): Pr
120
122
  const slot = new SessionSlot(options.liveEvents);
121
123
  const voiceSlot = new SessionSlot(false, 'voice');
122
124
  activeSlots = [slot, voiceSlot];
125
+ stopToolNotices?.();
126
+ stopToolNotices = onConnectorToolsChanged(() => {
127
+ for (const each of [slot, voiceSlot]) each.current?.toolsChanged();
128
+ });
123
129
  connectCallBridge(() => slot.current);
124
130
  connectServerApprovals(() => slot.current);
125
131
 
@@ -16,7 +16,9 @@ export const MCP_INSTRUCTIONS =
16
16
  'it need approval (`list_accounts` shows each `policy`). A blocked call errors. A call ' +
17
17
  'needing approval must run in a background worker: on the main thread it is refused, so ' +
18
18
  'delegate that exact call and keep answering; the worker waits for the owner. Approval ' +
19
- 'prompts are relayed to the chat; the owner answers "yes <id>"/"no <id>".';
19
+ 'prompts are relayed to the chat; the owner answers "yes <id>"/"no <id>". Connectors ' +
20
+ '(third-party tools like Notion, n8n, Gmail) are reached only through `tool_search` then ' +
21
+ '`tool_call`, from a worker.';
20
22
 
21
23
  export const VOICE_MCP_INSTRUCTIONS =
22
24
  "Metro's chat tools, opened for a live voice call. The caller hears what you say, so do not " +
@@ -27,4 +29,4 @@ export const VOICE_MCP_INSTRUCTIONS =
27
29
  '(`list_accounts` shows each `policy`). A blocked call errors. A call needing approval must ' +
28
30
  'run in a background worker: on the main thread it is refused, so delegate that exact call ' +
29
31
  'and keep talking; the worker waits while the owner is asked in the chat of the call, where ' +
30
- 'they answer "yes <id>"/"no <id>".';
32
+ 'they answer "yes <id>"/"no <id>". Connectors are reached through `tool_search` then `tool_call`.';
@@ -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,10 +7,11 @@ import { NEEDS_APPROVAL, takeGrant } from '../approvals/pending.js';
7
7
  import { profileScopeLine } from './profile-lookup.js';
8
8
  import { stationOfAccount } from './read-tool.js';
9
9
  import { stationForTool, toolGroupOf } from './tool-catalog.js';
10
+ import { CONNECTOR_TOOLS } from '../mcp-server/connector-tools.js';
10
11
 
11
12
  type ChannelTarget = Extract<PolicyTarget, { kind: 'channel' }>;
12
13
 
13
- export const UNGATED = new Set<string>(['list_accounts', 'create_upload']);
14
+ export const UNGATED = new Set<string>(['list_accounts', 'create_upload', ...CONNECTOR_TOOLS]);
14
15
 
15
16
  function fromLine(line: string, override = ''): ChannelTarget[] {
16
17
  const acct = accountFromLine(line);
@@ -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
 
@@ -65,9 +51,9 @@ function writePolicySnapshot(path = policySnapshotPath()): void {
65
51
  if (current(path) === text) return;
66
52
  try {
67
53
  writeSecure(path, text);
68
- log.info({ path }, 'policy: snapshot for the plugin hook written');
54
+ log.info({ path }, 'policy: snapshot for the guard hook written');
69
55
  } catch (err) {
70
- log.warn({ path, err: errMsg(err) }, 'policy: the snapshot for the plugin hook could not be written');
56
+ log.warn({ path, err: errMsg(err) }, 'policy: the snapshot for the guard hook could not be written');
71
57
  }
72
58
  }
73
59
 
@@ -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,13 +41,8 @@ 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
- behavior === 'allow' ? `Approved: ${tool} (${id}). Call call_tool again with the same arguments and approval: "${id}".` : `Denied: ${tool} (${id}).`;
45
+ behavior === 'allow' ? `Approved: ${tool} (${id}). Call tool_call again with the same arguments and approval: "${id}".` : `Denied: ${tool} (${id}).`;
52
46
 
53
47
  const requestedAt = (at: number, now = Date.now()): number => (Number.isFinite(at) && at > 0 ? Math.min(at, now) : now);
54
48
 
@@ -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
 
@@ -227,6 +227,10 @@ export class McpSession {
227
227
  return this.issuedSchema !== toolSchemaSignature();
228
228
  }
229
229
 
230
+ toolsChanged(): void {
231
+ this.announceToolSchema();
232
+ }
233
+
230
234
  private announceToolSchema(): void {
231
235
  if (!this.streamAttached) return;
232
236
  this.deliverSchemaNotice(
@@ -1,4 +1,6 @@
1
1
  import type { GroupOp, Station, StationTool, ToolGroup } from '@metro-labs/core/stations/types';
2
+ import { TOOL_CALL, TOOL_SEARCH } from '@metro-labs/http/mcp-server-protocol';
3
+ import { connectorTools } from '../mcp-server/connector-tools.js';
2
4
  import { STATIONS } from '../stations/registry.js';
3
5
  import { COMMON_TOOLS, LIST_ACCOUNTS_TOOL } from './tool-schemas.js';
4
6
  import { SET_PROFILE_TOOL } from './profile-tool.js';
@@ -14,7 +16,7 @@ const defOf = (tool: StationTool): ToolDef => ({
14
16
  ...(tool.destructive === true ? { destructive: true } : {}),
15
17
  });
16
18
 
17
- export const TOOL_DEFS: ToolDef[] = [
19
+ const LOCAL_DEFS: ToolDef[] = [
18
20
  ...COMMON_TOOLS,
19
21
  ...STATIONS.flatMap((s) => s.tools.map(defOf)),
20
22
  LIST_ACCOUNTS_TOOL,
@@ -23,19 +25,27 @@ export const TOOL_DEFS: ToolDef[] = [
23
25
  GET_PROFILE_TOOL,
24
26
  ];
25
27
 
28
+ const CONNECTOR_GROUPS: Record<string, Pick<ToolDef, 'group' | 'destructive'>> = {
29
+ [TOOL_SEARCH]: { group: 'read' },
30
+ [TOOL_CALL]: { group: 'write', destructive: true },
31
+ };
32
+
33
+ export const TOOL_DEFS: ToolDef[] = [
34
+ ...LOCAL_DEFS,
35
+ ...connectorTools().map((tool) => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema, ...CONNECTOR_GROUPS[tool.name] })),
36
+ ];
37
+
26
38
  const BY_NAME = new Map(TOOL_DEFS.map((d) => [d.name, d] as const));
27
39
 
28
40
  export const declaredGroup = (name: string): ToolGroup | undefined => BY_NAME.get(name)?.group;
29
41
 
30
42
  export const toolGroupOf = (name: string): ToolGroup => declaredGroup(name) ?? 'write';
31
43
 
32
- export interface ToolAnnotations {
33
- readOnlyHint: boolean;
34
- destructiveHint?: boolean;
35
- }
44
+ export type ToolAnnotations = Record<string, unknown>;
36
45
 
37
46
  export interface PublishedTool {
38
47
  name: string;
48
+ title?: string;
39
49
  description: string;
40
50
  inputSchema: Record<string, unknown>;
41
51
  annotations: ToolAnnotations;
@@ -50,14 +60,16 @@ const annotationsOf = (def: ToolDef): ToolAnnotations =>
50
60
  ? { readOnlyHint: true }
51
61
  : { readOnlyHint: false, destructiveHint: def.destructive === true };
52
62
 
53
- export const listedTools = (): PublishedTool[] =>
54
- TOOL_DEFS.map((def) => ({
63
+ export const listedTools = (): PublishedTool[] => [
64
+ ...LOCAL_DEFS.map((def) => ({
55
65
  name: def.name,
56
66
  description: def.description,
57
67
  inputSchema: def.inputSchema,
58
68
  annotations: annotationsOf(def),
59
69
  ...(ALWAYS_LOADED.has(def.name) ? { _meta: ALWAYS_LOAD } : {}),
60
- }));
70
+ })),
71
+ ...connectorTools().map((tool) => ({ ...tool, _meta: ALWAYS_LOAD })),
72
+ ];
61
73
 
62
74
  const STATION_OF_TOOL = new Map(STATIONS.flatMap((s) => s.tools.map((t) => [t.name, s.name] as const)));
63
75
 
@@ -38,6 +38,7 @@ import {
38
38
  import { str } from '@metro-labs/core/str';
39
39
  import { linelessRead, stationOfAccount } from './read-tool.js';
40
40
  import { channelArgsError, channelScopeDenied, dispatchListChannels } from './channel-tool.js';
41
+ import { callConnectorTool, CONNECTOR_TOOLS, connectorToolsVersion, type ConnectorResult } from '../mcp-server/connector-tools.js';
41
42
 
42
43
  const STATION_TOOLS = new Map<
43
44
  string,
@@ -63,14 +64,13 @@ const CORE_DISPATCH: Record<
63
64
 
64
65
  export const toolList = (): { tools: PublishedTool[] } => ({ tools: listedTools() });
65
66
 
66
- let schemaSignature: string | undefined;
67
+ let schemaSignature: { version: number; value: string } | undefined;
67
68
 
68
69
  export const toolSchemaSignature = (): string => {
69
- schemaSignature ??= createHash('sha256')
70
- .update(JSON.stringify(toolList()))
71
- .digest('hex')
72
- .slice(0, 16);
73
- return schemaSignature;
70
+ const version = connectorToolsVersion();
71
+ if (schemaSignature?.version !== version)
72
+ schemaSignature = { version, value: createHash('sha256').update(JSON.stringify(toolList())).digest('hex').slice(0, 16) };
73
+ return schemaSignature.value;
74
74
  };
75
75
 
76
76
  async function handleListAccounts(
@@ -197,6 +197,12 @@ export async function callToolHandler(
197
197
  return result;
198
198
  }
199
199
 
200
+ function connectorToolHandler(req: { params: { name: string; arguments?: Record<string, unknown> } }): Promise<ConnectorResult> {
201
+ const args = req.params.arguments ?? {};
202
+ settlePromptsFor(req.params.name, args);
203
+ return callConnectorTool(req.params.name, args);
204
+ }
205
+
200
206
  const TOOL_LIST_CHANGED = 'notifications/tools/list_changed';
201
207
 
202
208
  export interface SchemaNotice {
@@ -222,11 +228,11 @@ export function registerToolHandlers(
222
228
  ((
223
229
  req: { params: { name: string; arguments?: Record<string, unknown> } },
224
230
  extra: NotifyingExtra,
225
- ): Promise<ToolResult> => {
231
+ ): Promise<ToolResult | ConnectorResult> => {
226
232
  notice?.deliver(() =>
227
233
  extra.sendNotification({ method: TOOL_LIST_CHANGED }),
228
234
  );
229
- return callToolHandler(req, hooks);
235
+ return CONNECTOR_TOOLS.has(req.params.name) ? connectorToolHandler(req) : callToolHandler(req, hooks);
230
236
  }) as Parameters<typeof server.setRequestHandler>[1],
231
237
  );
232
238
  }