@stage-labs/metro 0.1.0-beta.305 → 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 (45) 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 +13 -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/mcp/index.ts +6 -0
  19. package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +4 -2
  20. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +2 -1
  21. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +2 -2
  22. package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +1 -1
  23. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +4 -0
  24. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-catalog.ts +20 -8
  25. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +14 -8
  26. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +0 -2
  27. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/connector-tools.ts +114 -0
  28. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/link.ts +12 -1
  29. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/rpc.ts +67 -0
  30. package/runtime/node_modules/@metro-labs/http/package.json +2 -1
  31. package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +9 -4
  32. package/runtime/node_modules/@metro-labs/http/src/mcp-server-tools.ts +93 -0
  33. package/runtime/runtime.json +1 -1
  34. package/runtime/sdk-runner/runner.json +1 -1
  35. package/{runtime/marketplace/plugin → skills}/METRO.md +8 -3
  36. package/runtime/marketplace/.claude-plugin/marketplace.json +0 -12
  37. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +0 -16
  38. package/runtime/marketplace/plugin/.mcp.json +0 -1
  39. package/runtime/marketplace/plugin/bin/metro-plugin.mjs +0 -24
  40. package/runtime/marketplace/plugin/hooks/hooks.json +0 -25
  41. package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-install.ts +0 -149
  42. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +0 -100
  43. /package/{runtime/marketplace/plugin/bin → hooks}/policy.mjs +0 -0
  44. /package/{runtime/marketplace/plugin → skills}/MEMORY.md +0 -0
  45. /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,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
+ }
@@ -24,6 +24,7 @@ export interface McpLinkDeps {
24
24
  key: () => AgentKey | null;
25
25
  dir: () => string;
26
26
  hold: HoldApproval;
27
+ registered?: () => void;
27
28
  fetch?: AgentFetch;
28
29
  timing?: Partial<McpTiming>;
29
30
  }
@@ -61,6 +62,10 @@ export class McpServerLink {
61
62
  await this.running;
62
63
  }
63
64
 
65
+ reregister(): void {
66
+ this.registered = null;
67
+ }
68
+
64
69
  async answer(id: string, decision: McpDecision): Promise<AnswerOutcome> {
65
70
  for (let attempt = 0; !this.stopped; attempt += 1) {
66
71
  if (attempt > 0) await this.pause(this.delay(attempt));
@@ -124,6 +129,7 @@ export class McpServerLink {
124
129
  await agentCall(this.api, key, MCP_PATHS.token, { tokenHash: token.hash }, { signal: this.abort.signal });
125
130
  this.registered = binding;
126
131
  log.info({ server: this.deps.url, keyId: key.keyId }, 'mcp-server: the MCP token is registered');
132
+ this.deps.registered?.();
127
133
  }
128
134
 
129
135
  private async poll(key: AgentKey): Promise<void> {
@@ -168,17 +174,22 @@ let active: McpServerLink | null = null;
168
174
  export interface McpServerWatch {
169
175
  owner: () => string | null;
170
176
  hold: HoldApproval;
177
+ registered?: () => void;
171
178
  }
172
179
 
173
180
  export function watchMcpServer(watch: McpServerWatch, env: NodeJS.ProcessEnv = process.env): boolean {
174
181
  const url = mcpServerUrl(env);
175
182
  if (url === null || active !== null) return false;
176
- active = new McpServerLink({ url, owner: watch.owner, key: () => readAgentKey(), dir: agentsDir, hold: watch.hold });
183
+ active = new McpServerLink({ url, owner: watch.owner, key: () => readAgentKey(), dir: agentsDir, hold: watch.hold, ...(watch.registered === undefined ? {} : { registered: watch.registered }) });
177
184
  active.start();
178
185
  log.info({ server: url }, 'mcp-server: connector approvals relay from this server once the agent is enrolled');
179
186
  return true;
180
187
  }
181
188
 
189
+ export function reregisterMcpToken(): void {
190
+ active?.reregister();
191
+ }
192
+
182
193
  export async function stopMcpServer(): Promise<void> {
183
194
  const link = active;
184
195
  active = null;
@@ -0,0 +1,67 @@
1
+ import { isRecord } from '@metro-labs/core/is-record';
2
+ import { errMsg } from '@metro-labs/core/log';
3
+ import { agentsDir } from '../agents/files.js';
4
+ import { mcpServerUrl } from './config.js';
5
+ import { reregisterMcpToken } from './link.js';
6
+ import { readMcpToken } from './token.js';
7
+
8
+ export interface ServerDeps {
9
+ url?: string | null;
10
+ token?: string | null;
11
+ fetch?: (url: string, init: RequestInit) => Promise<Response>;
12
+ reregister?: () => void;
13
+ }
14
+
15
+ export type ServerAnswer =
16
+ | { ok: true; result: unknown }
17
+ | { ok: false; reason: 'unset' }
18
+ | { ok: false; reason: 'unavailable'; detail: string }
19
+ | { ok: false; reason: 'refused'; message: string };
20
+
21
+ let nextId = 1;
22
+
23
+ const unavailable = (detail: string): ServerAnswer => ({ ok: false, reason: 'unavailable', detail });
24
+
25
+ async function bodyOf(res: Response): Promise<ServerAnswer> {
26
+ let body: unknown;
27
+ try {
28
+ body = JSON.parse(await res.text());
29
+ } catch (err) {
30
+ return unavailable(`unreadable answer: ${errMsg(err)}`);
31
+ }
32
+ if (isRecord(body) && isRecord(body.error))
33
+ return { ok: false, reason: 'refused', message: typeof body.error.message === 'string' ? body.error.message.slice(0, 500) : 'refused' };
34
+ return isRecord(body) && Object.hasOwn(body, 'result') ? { ok: true, result: body.result } : unavailable('no JSON-RPC result');
35
+ }
36
+
37
+ interface Target {
38
+ url: string;
39
+ token: string;
40
+ }
41
+
42
+ function targetOf(deps: ServerDeps): Target | null {
43
+ const url = deps.url === undefined ? mcpServerUrl() : deps.url;
44
+ const token = deps.token === undefined ? readMcpToken(agentsDir()) : deps.token;
45
+ return url === null || token === null ? null : { url, token };
46
+ }
47
+
48
+ export async function askServer(method: string, params: Record<string, unknown>, timeoutMs: number, deps: ServerDeps = {}): Promise<ServerAnswer> {
49
+ const target = targetOf(deps);
50
+ if (target === null) return { ok: false, reason: 'unset' };
51
+ const { url, token } = target;
52
+ let res: Response;
53
+ try {
54
+ res = await (deps.fetch ?? fetch)(`${url}/`, {
55
+ method: 'POST',
56
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
57
+ body: JSON.stringify({ jsonrpc: '2.0', id: nextId++, method, params }),
58
+ redirect: 'manual',
59
+ signal: AbortSignal.timeout(timeoutMs),
60
+ });
61
+ } catch (err) {
62
+ return unavailable(errMsg(err));
63
+ }
64
+ if (res.status === 401) (deps.reregister ?? reregisterMcpToken)();
65
+ if (!res.ok) return unavailable(`HTTP ${String(res.status)}`);
66
+ return bodyOf(res);
67
+ }
@@ -24,7 +24,8 @@
24
24
  "./connector-input": "./src/connector-input.ts",
25
25
  "./connector-policy": "./src/connector-policy.ts",
26
26
  "./connector-grant": "./src/connector-grant.ts",
27
- "./mcp-server-protocol": "./src/mcp-server-protocol.ts"
27
+ "./mcp-server-protocol": "./src/mcp-server-protocol.ts",
28
+ "./mcp-server-tools": "./src/mcp-server-tools.ts"
28
29
  },
29
30
  "scripts": {
30
31
  "build": "tsc",
@@ -7,6 +7,11 @@ export const MCP_CALLS_MAX = 10;
7
7
  export const MCP_QUERY_MAX = 500;
8
8
  export const MCP_APPROVAL_WAIT_MS = 25_000;
9
9
  export const MCP_APPROVAL_ID_RE = /^[a-km-z]{5}$/;
10
+ export const TOOL_SEARCH = 'tool_search';
11
+ export const TOOL_CALL = 'tool_call';
12
+ export const TOOL_CALL_EXAMPLE = '{"calls":[{"tool":"notion.search","arguments":{"query":"roadmap"}}]}';
13
+
14
+ const EXPECTED = ` Expected: ${TOOL_CALL_EXAMPLE}, with names as tool_search returns them.`;
10
15
  export const MCP_TOKEN_RE = /^mcp_[A-Za-z0-9_-]{43}$/;
11
16
 
12
17
  export type McpErrorCode = 'blocked' | 'needs_approval' | 'denied' | 'not_found' | 'signin' | 'unavailable' | 'vendor_error' | 'invalid';
@@ -40,23 +45,23 @@ export function splitMcpTool(name: string): { connector: string; tool: string }
40
45
  }
41
46
 
42
47
  export function mcpFindRequestOf(raw: unknown): McpFindRequest {
43
- if (!isRecord(raw) || typeof raw.query !== 'string' || raw.query.trim() === '' || raw.query.length > MCP_QUERY_MAX) throw new Error('find_tools needs a query of 1 to 500 characters.');
48
+ if (!isRecord(raw) || typeof raw.query !== 'string' || raw.query.trim() === '' || raw.query.length > MCP_QUERY_MAX) throw new Error('tool_search needs a query of 1 to 500 characters.');
44
49
  const wanted = raw.max_results ?? MCP_FIND_DEFAULT;
45
50
  if (typeof wanted !== 'number' || !Number.isInteger(wanted) || wanted < 1) throw new Error('max_results must be a whole number from 1.');
46
51
  return { query: raw.query.trim(), max_results: Math.min(wanted, MCP_FIND_MAX) };
47
52
  }
48
53
 
49
54
  function callOf(raw: unknown): McpCall {
50
- if (!isRecord(raw) || typeof raw.tool !== 'string' || splitMcpTool(raw.tool) === null) throw new Error('Each call needs a tool named <connector>.<tool>, as find_tools returns it.');
55
+ if (!isRecord(raw) || typeof raw.tool !== 'string' || splitMcpTool(raw.tool) === null) throw new Error(`Each call needs a tool named <connector>.<tool>, as tool_search returns it.${EXPECTED}`);
51
56
  const args = raw.arguments ?? {};
52
- if (!isRecord(args)) throw new Error(`The arguments of ${raw.tool} must be an object.`);
57
+ if (!isRecord(args)) throw new Error(`The arguments of ${raw.tool} must be an object.${EXPECTED}`);
53
58
  if (raw.approval !== undefined && (typeof raw.approval !== 'string' || !MCP_APPROVAL_ID_RE.test(raw.approval))) throw new Error('approval must be the 5-letter id the approval request gave.');
54
59
  return { tool: raw.tool, arguments: args, ...(typeof raw.approval === 'string' ? { approval: raw.approval } : {}) };
55
60
  }
56
61
 
57
62
  export function mcpCallsOf(raw: unknown): McpCall[] {
58
63
  const calls = isRecord(raw) ? raw.calls : undefined;
59
- if (!Array.isArray(calls) || calls.length === 0 || calls.length > MCP_CALLS_MAX) throw new Error(`call_tool needs 1 to ${String(MCP_CALLS_MAX)} calls.`);
64
+ if (!Array.isArray(calls) || calls.length === 0 || calls.length > MCP_CALLS_MAX) throw new Error(`tool_call needs 1 to ${String(MCP_CALLS_MAX)} calls in "calls".${EXPECTED}`);
60
65
  return calls.map(callOf);
61
66
  }
62
67