@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.
- package/dist/cli.js +1 -1
- package/dist/memory.js +2 -3
- package/dist/runtime-install.js +3 -11
- package/dist/voice.js +1 -1
- package/{runtime/marketplace/plugin/bin → hooks}/guard.mjs +9 -4
- package/{runtime/marketplace/plugin/bin → hooks}/session-start.mjs +2 -2
- package/package.json +4 -2
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/home-fs.ts +2 -29
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +2 -15
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/user.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +13 -10
- package/runtime/node_modules/@metro-labs/daemon/src/claude/cli-files.ts +28 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/hooks.ts +60 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-cleanup.ts +121 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +37 -30
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +6 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +4 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +4 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-catalog.ts +20 -8
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +14 -8
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/connector-tools.ts +114 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/link.ts +12 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/rpc.ts +67 -0
- package/runtime/node_modules/@metro-labs/http/package.json +2 -1
- package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +9 -4
- package/runtime/node_modules/@metro-labs/http/src/mcp-server-tools.ts +93 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/{runtime/marketplace/plugin → skills}/METRO.md +8 -3
- package/runtime/marketplace/.claude-plugin/marketplace.json +0 -12
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +0 -16
- package/runtime/marketplace/plugin/.mcp.json +0 -1
- package/runtime/marketplace/plugin/bin/metro-plugin.mjs +0 -24
- package/runtime/marketplace/plugin/hooks/hooks.json +0 -25
- package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-install.ts +0 -149
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +0 -100
- /package/{runtime/marketplace/plugin/bin → hooks}/policy.mjs +0 -0
- /package/{runtime/marketplace/plugin → skills}/MEMORY.md +0 -0
- /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 {
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
224
|
-
|
|
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,
|
|
231
|
-
const source = join(
|
|
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
|
|
275
|
+
const cli = deps.cli ?? cliPackageDir(deps.env);
|
|
276
|
+
const hooks = hooksDir(cli);
|
|
269
277
|
tryMoveRenamedSkill(dir);
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
.digest('hex')
|
|
72
|
-
|
|
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
|
}
|
|
@@ -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('
|
|
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(
|
|
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(`
|
|
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
|
|