@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.
- 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 +15 -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/connectors/agent-key.ts +9 -12
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/leftovers.ts +59 -0
- 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/agent-signature.ts +5 -20
- 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,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
|
|
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 ===
|
|
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,
|
|
46
|
+
function agentKeyOf(signing: KeyObject, enrollment: Enrollment | null): AgentKey {
|
|
49
47
|
const signingKey = rawPublicKey(createPublicKey(signing));
|
|
50
|
-
return { keyId: agentKeyId(signingKey), signingKey,
|
|
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 =
|
|
71
|
-
|
|
72
|
-
|
|
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,
|
|
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)
|
|
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
|
|
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
|
|
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
|
+
}
|