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

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 (49) 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}/session-start.mjs +2 -2
  7. package/package.json +4 -2
  8. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/home-fs.ts +2 -29
  9. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +2 -15
  10. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +0 -1
  11. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +3 -3
  12. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +15 -10
  13. package/runtime/node_modules/@metro-labs/daemon/src/claude/cli-files.ts +28 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/claude/hooks.ts +60 -0
  15. package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +121 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +1 -1
  17. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +37 -30
  18. package/runtime/node_modules/@metro-labs/daemon/src/connectors/agent-key.ts +9 -12
  19. package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +1 -1
  20. package/runtime/node_modules/@metro-labs/daemon/src/connectors/leftovers.ts +59 -0
  21. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +6 -0
  22. package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +4 -2
  23. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +2 -1
  24. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +2 -2
  25. package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +1 -1
  26. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +4 -0
  27. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-catalog.ts +20 -8
  28. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +14 -8
  29. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +0 -2
  30. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/connector-tools.ts +114 -0
  31. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/link.ts +12 -1
  32. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/rpc.ts +67 -0
  33. package/runtime/node_modules/@metro-labs/http/package.json +2 -1
  34. package/runtime/node_modules/@metro-labs/http/src/agent-signature.ts +5 -20
  35. package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +9 -4
  36. package/runtime/node_modules/@metro-labs/http/src/mcp-server-tools.ts +93 -0
  37. package/runtime/runtime.json +1 -1
  38. package/runtime/sdk-runner/runner.json +1 -1
  39. package/{runtime/marketplace/plugin → skills}/METRO.md +8 -3
  40. package/runtime/marketplace/.claude-plugin/marketplace.json +0 -12
  41. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +0 -16
  42. package/runtime/marketplace/plugin/.mcp.json +0 -1
  43. package/runtime/marketplace/plugin/bin/metro-plugin.mjs +0 -24
  44. package/runtime/marketplace/plugin/hooks/hooks.json +0 -25
  45. package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-install.ts +0 -149
  46. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +0 -100
  47. /package/{runtime/marketplace/plugin/bin → hooks}/policy.mjs +0 -0
  48. /package/{runtime/marketplace/plugin → skills}/MEMORY.md +0 -0
  49. /package/{runtime/marketplace/plugin → skills}/STAGE.md +0 -0
@@ -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',
@@ -153,6 +144,8 @@ const PRIOR_METRO: ReadonlySet<string> = new Set([
153
144
  'b910597d24f31fa479b130a2545f94dd23684d802d99e36b637fc57c818a33be',
154
145
  'd3f7ed0f5175b9a7a77f06f6204ae0020369d0c8f6e2724196bb3c5822d4d8ba',
155
146
  '587a1334f91a8b61541573cef16eaf44fbe63049a8d8b461fa9f5cc55bacbcb8',
147
+ '256c711a6175266c216e32c88b7190d0f98f5e6b702abe03220b422aba426574',
148
+ '9e164a956437d60d30ea1984115a17d303940d191f5efed04954f244fc89d48f',
156
149
  ]);
157
150
  const PRIOR_STAGE: ReadonlySet<string> = new Set([
158
151
  'dd7d30474765de3865ddc87c1cf3220161ded09502027cdff18597e5df0aab43',
@@ -220,15 +213,29 @@ function mergeSettings(dir: string, change: (settings: Record<string, unknown>)
220
213
  return 'written';
221
214
  }
222
215
 
223
- function applyPrivacy(dir: string, enabled: boolean): SettingsOutcome {
224
- 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 };
225
232
  }
226
233
 
227
234
  const skillPath = (dir: string, name: string): string => join(dir, 'skills', name, 'SKILL.md');
228
235
  const workerPath = (dir: string): string => join(dir, 'agents', 'worker.md');
229
236
 
230
- function placeSkill(dir: string, plugin: string, skill: ShippedSkill): Placed {
231
- const source = join(plugin, skill.file);
237
+ function placeSkill(dir: string, skills: string, skill: ShippedSkill): Placed {
238
+ const source = join(skills, skill.file);
232
239
  if (!existsSync(source)) return 'missing';
233
240
  return placeFile(skillPath(dir, skill.name), readFileSync(source, 'utf8'), skill.prior);
234
241
  }
@@ -265,21 +272,21 @@ export function ensureClaudeSetup(deps: SetupDeps = {}): SetupReport {
265
272
  const dir = deps.dir ?? claudeDir();
266
273
  const agents = deps.agents ?? agentsDir();
267
274
  const privacy = privacyEnabled(agents);
268
- const plugin = deps.plugin ?? pluginDir(deps.env);
275
+ const cli = deps.cli ?? cliPackageDir(deps.env);
276
+ const hooks = hooksDir(cli);
269
277
  tryMoveRenamedSkill(dir);
270
- const report: SetupReport = {
271
- privacy,
272
- guard: 'plugin',
273
- worker: placeFile(workerPath(dir), WORKER_AGENT, PRIOR_WORKER),
274
- skill: placeSkill(dir, plugin, METRO_SKILL),
275
- stage: placeSkill(dir, plugin, STAGE_SKILL),
276
- memory: placeSkill(dir, plugin, MEMORY_SKILL),
277
- settings: applyPrivacy(dir, privacy),
278
- };
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 };
279
285
  syncAvailableModelsQuietly({ ...deps, dir, agents });
280
286
  if ([report.worker, report.skill, report.stage, report.memory].some(written) || report.settings === 'written')
281
287
  log.info(report, 'claude-setup: applied the Claude Code setup for a metro box');
282
- 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');
283
290
  return report;
284
291
  }
285
292
 
@@ -299,7 +306,7 @@ export function claudeSetupStatus(deps: SetupDeps = {}): SetupStatus {
299
306
  liveEvents: liveEvents(agents),
300
307
  memoryRoutine: memoryRoutine(agents),
301
308
  memoryJob: memoryJobStatus(),
302
- guard: 'plugin',
309
+ guard: hasMetroGuard(settings),
303
310
  worker: existsSync(workerPath(dir)),
304
311
  skill: existsSync(skillPath(dir, METRO_SKILL.name)),
305
312
  stage: existsSync(skillPath(dir, STAGE_SKILL.name)),
@@ -21,19 +21,17 @@ export interface Enrollment {
21
21
  export interface AgentKey {
22
22
  keyId: string;
23
23
  signingKey: string;
24
- sealingKey: string;
25
24
  signing: KeyObject;
26
- sealing: KeyObject;
27
25
  enrollment: Enrollment | null;
28
26
  }
29
27
 
30
28
  const pathOf = (dir: string): string => join(dir, FILE);
31
29
 
32
- function privateKeyOf(raw: unknown, type: 'ed25519' | 'x25519'): KeyObject | null {
30
+ function privateSigningKey(raw: unknown): KeyObject | null {
33
31
  if (typeof raw !== 'string' || raw === '') return null;
34
32
  try {
35
33
  const key = createPrivateKey({ key: Buffer.from(raw, 'base64'), format: 'der', type: 'pkcs8' });
36
- return key.asymmetricKeyType === type ? key : null;
34
+ return key.asymmetricKeyType === 'ed25519' ? key : null;
37
35
  } catch {
38
36
  return null;
39
37
  }
@@ -45,9 +43,9 @@ function enrollmentOf(raw: unknown): Enrollment | null {
45
43
  return server !== null && isOrganizationId(raw.organization) ? { server, organization: raw.organization, at: raw.at } : null;
46
44
  }
47
45
 
48
- function agentKeyOf(signing: KeyObject, sealing: KeyObject, enrollment: Enrollment | null): AgentKey {
46
+ function agentKeyOf(signing: KeyObject, enrollment: Enrollment | null): AgentKey {
49
47
  const signingKey = rawPublicKey(createPublicKey(signing));
50
- return { keyId: agentKeyId(signingKey), signingKey, sealingKey: rawPublicKey(createPublicKey(sealing)), signing, sealing, enrollment };
48
+ return { keyId: agentKeyId(signingKey), signingKey, signing, enrollment };
51
49
  }
52
50
 
53
51
  const unreadable = (): null => {
@@ -67,10 +65,9 @@ function storedOf(path: string): Record<string, unknown> | null {
67
65
 
68
66
  function keyOf(stored: Record<string, unknown> | null): AgentKey | null {
69
67
  if (stored === null) return null;
70
- const signing = privateKeyOf(stored.signing, 'ed25519');
71
- const sealing = privateKeyOf(stored.sealing, 'x25519');
72
- if (stored.version !== 1 || signing === null || sealing === null) return unreadable();
73
- return agentKeyOf(signing, sealing, enrollmentOf(stored.enrollment));
68
+ const signing = privateSigningKey(stored.signing);
69
+ if (stored.version !== 1 || signing === null) return unreadable();
70
+ return agentKeyOf(signing, enrollmentOf(stored.enrollment));
74
71
  }
75
72
 
76
73
  export function readAgentKey(dir = agentsDir()): AgentKey | null {
@@ -132,12 +129,12 @@ export function clearPendingAgentKey(dir: string): void {
132
129
  }
133
130
 
134
131
  export function newAgentKey(): AgentKey {
135
- return agentKeyOf(generateKeyPairSync('ed25519').privateKey, generateKeyPairSync('x25519').privateKey, null);
132
+ return agentKeyOf(generateKeyPairSync('ed25519').privateKey, null);
136
133
  }
137
134
 
138
135
  const exported = (key: KeyObject): string => key.export({ format: 'der', type: 'pkcs8' }).toString('base64');
139
136
 
140
- const privateKeys = (key: AgentKey): Record<string, unknown> => ({ version: 1, signing: exported(key.signing), sealing: exported(key.sealing) });
137
+ const privateKeys = (key: AgentKey): Record<string, unknown> => ({ version: 1, signing: exported(key.signing) });
141
138
 
142
139
  export function savePendingAgentKey(key: AgentKey, owner: string, ticketHash: string, dir: string, expectedServer?: string): void {
143
140
  writeKey(pendingPath(dir), { ...privateKeys(key), owner, ticketHash, ...(expectedServer === undefined ? {} : { expectedServer }) });
@@ -66,7 +66,7 @@ async function exchange(ticket: string, owner: string, dir: string, deps: Enroll
66
66
  if (finished !== null) return finished;
67
67
  const key = pending?.ticketHash === ticketHash ? pending : newAgentKey();
68
68
  if (key !== pending) savePendingAgentKey(key, owner, ticketHash, dir);
69
- const enrolled = enrolledOf(await agentCall(api, key, '/api/agents/enroll', { ticket, organization: owner, signingKey: key.signingKey, sealingKey: key.sealingKey }), key, owner);
69
+ const enrolled = enrolledOf(await agentCall(api, key, '/api/agents/enroll', { ticket, organization: owner, signingKey: key.signingKey }), key, owner);
70
70
  sameOwner(deps, owner);
71
71
  savePendingAgentKey(key, owner, ticketHash, dir, enrolled.server);
72
72
  const confirmed = enrolledOf(await agentCall(api, key, '/api/agents/session'), key, owner);
@@ -0,0 +1,59 @@
1
+ import { lstatSync, readdirSync, unlinkSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { errMsg, log } from '@metro-labs/core/log';
4
+ import { agentsDir } from '../agents/files.js';
5
+
6
+ const WRITTEN = new Set([
7
+ 'connectors.json',
8
+ 'connector-migrations.json',
9
+ 'connector-migrations.initialized',
10
+ 'connector-server-keys.json',
11
+ 'connector-server-keys.initialized',
12
+ 'org-connector-sync.json',
13
+ ]);
14
+
15
+ const OPERATIONS = 'connector-migration-operations-[A-Za-z0-9][A-Za-z0-9_-]{10}\\.json';
16
+
17
+ const PATTERNS = [
18
+ new RegExp(`^${OPERATIONS}(?:\\.initialized)?$`),
19
+ new RegExp(`^(?:connectors\\.json|connector-server-keys\\.json|connector-server-keys\\.initialized|org-connector-sync\\.json|${OPERATIONS})\\.tmp-[0-9]+$`),
20
+ /^connector-migrations\.json\.[0-9a-f]{24}\.tmp$/,
21
+ ];
22
+
23
+ const isConnectorLeftover =(name: string): boolean => WRITTEN.has(name) || PATTERNS.some((pattern) => pattern.test(name));
24
+
25
+ const codeOf = (err: unknown): unknown => (err instanceof Error && 'code' in err ? err.code : undefined);
26
+
27
+ function namesIn(dir: string): string[] {
28
+ try {
29
+ return readdirSync(dir).filter(isConnectorLeftover);
30
+ } catch (err) {
31
+ if (codeOf(err) !== 'ENOENT') log.warn({ err: errMsg(err), dir }, 'connectors: could not list the old connector files');
32
+ return [];
33
+ }
34
+ }
35
+
36
+ function removeOne(path: string): boolean {
37
+ try {
38
+ if (!lstatSync(path).isFile()) {
39
+ log.warn({ path }, 'connectors: left an old connector file alone, it is not a regular file');
40
+ return false;
41
+ }
42
+ unlinkSync(path);
43
+ return true;
44
+ } catch (err) {
45
+ if (codeOf(err) !== 'ENOENT') log.warn({ err: errMsg(err), path }, 'connectors: could not remove an old connector file');
46
+ return false;
47
+ }
48
+ }
49
+
50
+ export function removeConnectorLeftovers(dir = agentsDir()): number {
51
+ try {
52
+ const removed = namesIn(dir).filter((name) => removeOne(join(dir, name))).length;
53
+ log.info({ removed }, 'connectors: removed the files of the old connector path');
54
+ return removed;
55
+ } catch (err) {
56
+ log.warn({ err: errMsg(err) }, 'connectors: could not remove the files of the old connector path');
57
+ return 0;
58
+ }
59
+ }
@@ -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`.';
@@ -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);
@@ -51,9 +51,9 @@ function writePolicySnapshot(path = policySnapshotPath()): void {
51
51
  if (current(path) === text) return;
52
52
  try {
53
53
  writeSecure(path, text);
54
- log.info({ path }, 'policy: snapshot for the plugin hook written');
54
+ log.info({ path }, 'policy: snapshot for the guard hook written');
55
55
  } catch (err) {
56
- 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');
57
57
  }
58
58
  }
59
59
 
@@ -42,7 +42,7 @@ function remember(id: string, now = Date.now()): void {
42
42
  const answeredRecently = (id: string, now = Date.now()): boolean => now - (recent.get(id) ?? -Infinity) < RECENT_MS;
43
43
 
44
44
  const noticeText = (tool: string, id: string, behavior: Behavior): string =>
45
- 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}).`;
46
46
 
47
47
  const requestedAt = (at: number, now = Date.now()): number => (Number.isFinite(at) && at > 0 ? Math.min(at, now) : now);
48
48
 
@@ -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
  }
@@ -1,7 +1,5 @@
1
1
  import { log } from '@metro-labs/core/log';
2
2
 
3
- export const MCP_SERVER_KEY = 'mcp';
4
-
5
3
  const DEFAULT_URL = 'https://mcp.metro.box';
6
4
 
7
5
  const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
@@ -0,0 +1,114 @@
1
+ import { isRecord } from '@metro-labs/core/is-record';
2
+ import { errMsg, log } from '@metro-labs/core/log';
3
+ import { TOOL_CALL, TOOL_SEARCH } from '@metro-labs/http/mcp-server-protocol';
4
+ import { mcpServerTools, type McpServerTool } from '@metro-labs/http/mcp-server-tools';
5
+ import { askServer, type ServerAnswer, type ServerDeps } from './rpc.js';
6
+
7
+ export const CONNECTOR_TOOLS: ReadonlySet<string> = new Set([TOOL_SEARCH, TOOL_CALL]);
8
+
9
+ export const CALL_TIMEOUT_MS = 120_000;
10
+ const LIST_TIMEOUT_MS = 15_000;
11
+ const REFRESH_MS = 5 * 60_000;
12
+
13
+ export const NOT_SET_UP = 'Connectors are not set up on this agent: enroll it in its organization on the Server page of metro.box, then try again.';
14
+ export const UNAVAILABLE = 'mcp.metro.box is unavailable, try again.';
15
+
16
+ export interface ConnectorResult {
17
+ [key: string]: unknown;
18
+ content: unknown[];
19
+ isError?: boolean;
20
+ }
21
+
22
+ const FALLBACK = mcpServerTools(null);
23
+ let current: McpServerTool[] = FALLBACK;
24
+ let version = 0;
25
+ const listeners = new Set<() => void>();
26
+
27
+ export const connectorTools = (): McpServerTool[] => current;
28
+
29
+ export const connectorToolsVersion = (): number => version;
30
+
31
+ export function onConnectorToolsChanged(listener: () => void): () => void {
32
+ listeners.add(listener);
33
+ return () => {
34
+ listeners.delete(listener);
35
+ };
36
+ }
37
+
38
+ function toolOf(raw: unknown, fallback: McpServerTool): McpServerTool | null {
39
+ if (!isRecord(raw) || typeof raw.description !== 'string' || !isRecord(raw.inputSchema)) return null;
40
+ return {
41
+ name: fallback.name,
42
+ title: typeof raw.title === 'string' ? raw.title : fallback.title,
43
+ description: raw.description,
44
+ inputSchema: raw.inputSchema,
45
+ annotations: isRecord(raw.annotations) ? raw.annotations : fallback.annotations,
46
+ };
47
+ }
48
+
49
+ function servedTools(result: unknown): McpServerTool[] | null {
50
+ const tools: unknown[] = isRecord(result) && Array.isArray(result.tools) ? result.tools : [];
51
+ const picked = FALLBACK.map((fallback) => toolOf(tools.find((tool) => isRecord(tool) && tool.name === fallback.name), fallback));
52
+ return picked.every((tool): tool is McpServerTool => tool !== null) ? picked : null;
53
+ }
54
+
55
+ function changed(): void {
56
+ for (const listener of listeners)
57
+ try {
58
+ listener();
59
+ } catch (err) {
60
+ log.warn({ err: errMsg(err) }, 'mcp-server: a tool list listener failed');
61
+ }
62
+ }
63
+
64
+ export async function refreshConnectorTools(deps: ServerDeps = {}): Promise<boolean> {
65
+ const answer = await askServer('tools/list', {}, LIST_TIMEOUT_MS, deps);
66
+ const tools = answer.ok ? servedTools(answer.result) : null;
67
+ if (tools === null) {
68
+ if (!answer.ok && answer.reason !== 'unset') log.debug({ reason: answer.reason }, 'mcp-server: the connector tool list could not be read');
69
+ return false;
70
+ }
71
+ if (JSON.stringify(tools) === JSON.stringify(current)) return false;
72
+ current = tools;
73
+ version += 1;
74
+ log.info({ version }, 'mcp-server: the connector tools changed, the session is told');
75
+ changed();
76
+ return true;
77
+ }
78
+
79
+ let timer: ReturnType<typeof setInterval> | null = null;
80
+
81
+ export function watchConnectorTools(everyMs = REFRESH_MS): () => void {
82
+ const tick = (): void => {
83
+ refreshConnectorTools().catch((err: unknown) => {
84
+ log.warn({ err: errMsg(err) }, 'mcp-server: the connector tool refresh failed');
85
+ });
86
+ };
87
+ if (timer === null) {
88
+ tick();
89
+ timer = setInterval(tick, everyMs);
90
+ timer.unref();
91
+ }
92
+ return () => {
93
+ if (timer !== null) clearInterval(timer);
94
+ timer = null;
95
+ };
96
+ }
97
+
98
+ const errorResult = (text: string): ConnectorResult => ({ content: [{ type: 'text', text }], isError: true });
99
+
100
+ function resultOf(name: string, answer: ServerAnswer): ConnectorResult {
101
+ if (answer.ok) {
102
+ if (isRecord(answer.result) && Array.isArray(answer.result.content)) return answer.result as ConnectorResult;
103
+ log.warn({ tool: name }, 'mcp-server: mcp.metro.box answered a tool call without content');
104
+ return errorResult(UNAVAILABLE);
105
+ }
106
+ if (answer.reason === 'unset') return errorResult(NOT_SET_UP);
107
+ if (answer.reason === 'refused') return errorResult(`mcp.metro.box refused the call: ${answer.message}`);
108
+ log.warn({ tool: name, detail: answer.detail }, 'mcp-server: a connector tool call did not reach mcp.metro.box');
109
+ return errorResult(UNAVAILABLE);
110
+ }
111
+
112
+ export async function callConnectorTool(name: string, args: Record<string, unknown>, deps: ServerDeps = {}): Promise<ConnectorResult> {
113
+ return resultOf(name, await askServer('tools/call', { name, arguments: args }, CALL_TIMEOUT_MS, deps));
114
+ }