@stage-labs/metro 0.1.0-beta.304 → 0.1.0-beta.306
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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}/policy.mjs +1 -32
- package/{runtime/marketplace/plugin/bin → hooks}/session-start.mjs +2 -2
- package/package.json +4 -2
- package/runtime/node_modules/@metro-labs/core/package.json +0 -1
- 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/agents/api.ts +3 -5
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +1 -7
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +16 -17
- 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 +2 -4
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +38 -30
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +0 -2
- 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/permission-prompt.ts +1 -13
- 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 -16
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +15 -16
- 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/announced.ts +68 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +2 -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/daemon/src/policy/policy.ts +6 -5
- package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -27
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -5
- package/runtime/node_modules/@metro-labs/http/package.json +3 -3
- package/runtime/node_modules/@metro-labs/http/src/connector-grant.ts +0 -51
- 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/node_modules/@metro-labs/core/package.json +0 -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 -17
- package/runtime/marketplace/plugin/.mcp.json +0 -1
- package/runtime/marketplace/plugin/bin/metro-plugin.mjs +0 -48
- package/runtime/marketplace/plugin/hooks/hooks.json +0 -25
- package/runtime/node_modules/@metro-labs/core/src/connector-sync.ts +0 -17
- package/runtime/node_modules/@metro-labs/daemon/src/claude/plugin-install.ts +0 -149
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +0 -104
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-adopt.ts +0 -57
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-relay.ts +0 -61
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +0 -115
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +0 -271
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-api.ts +0 -25
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +0 -173
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-setting.ts +0 -60
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +0 -38
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +0 -208
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +0 -145
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +0 -40
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-id.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-policy.ts +0 -90
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +0 -10
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-target.ts +0 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +0 -287
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +0 -59
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +0 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +0 -116
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +0 -19
- package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +0 -178
- package/runtime/node_modules/@metro-labs/http/src/transfer-crypto.ts +0 -44
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/connector-sync.ts +0 -17
- /package/{runtime/marketplace/plugin → skills}/MEMORY.md +0 -0
- /package/{runtime/marketplace/plugin → skills}/STAGE.md +0 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
2
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
+
import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
|
|
4
|
+
import { MCP_APPROVAL_ID_RE } from '@metro-labs/http/mcp-server-protocol';
|
|
5
|
+
|
|
6
|
+
const KEEP_MS = 24 * 3_600_000;
|
|
7
|
+
const MAX = 200;
|
|
8
|
+
|
|
9
|
+
interface Announced {
|
|
10
|
+
line: string;
|
|
11
|
+
at: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let file: string | undefined;
|
|
15
|
+
let known = new Map<string, Announced>();
|
|
16
|
+
|
|
17
|
+
function entryOf(raw: unknown): Announced | null {
|
|
18
|
+
if (!isRecord(raw) || typeof raw.line !== 'string' || !raw.line.startsWith('metro://') || typeof raw.at !== 'number' || !Number.isFinite(raw.at)) return null;
|
|
19
|
+
return { line: raw.line, at: raw.at };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parse(raw: unknown): Map<string, Announced> {
|
|
23
|
+
const out = new Map<string, Announced>();
|
|
24
|
+
const entries = isRecord(raw) && raw.version === 1 && isRecord(raw.announced) ? raw.announced : {};
|
|
25
|
+
for (const [id, value] of Object.entries(entries)) {
|
|
26
|
+
const entry = entryOf(value);
|
|
27
|
+
if (MCP_APPROVAL_ID_RE.test(id) && entry !== null) out.set(id, entry);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function prune(now: number): void {
|
|
33
|
+
for (const [id, entry] of known) if (now - entry.at >= KEEP_MS || entry.at > now) known.delete(id);
|
|
34
|
+
const oldest = [...known].sort(([, a], [, b]) => a.at - b.at);
|
|
35
|
+
for (const [id] of oldest.slice(0, Math.max(0, known.size - MAX))) known.delete(id);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function save(): void {
|
|
39
|
+
if (file === undefined) return;
|
|
40
|
+
const announced = Object.fromEntries([...known].map(([id, entry]) => [id, { line: entry.line, at: entry.at }]));
|
|
41
|
+
try {
|
|
42
|
+
writeSecure(file, `${JSON.stringify({ version: 1, announced })}\n`);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
log.warn({ err: errMsg(err) }, 'mcp-server: the announced approvals could not be saved, so they are kept in memory only');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function setAnnouncedFile(path: string, now = Date.now()): void {
|
|
49
|
+
file = path;
|
|
50
|
+
known = parse(readJson<unknown>(path, null, { warn: 'mcp-server: the announced approvals file is not valid JSON, so it was ignored' }));
|
|
51
|
+
prune(now);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function announcedLine(id: string, now = Date.now()): string | undefined {
|
|
55
|
+
const entry = known.get(id);
|
|
56
|
+
return entry !== undefined && now - entry.at < KEEP_MS && entry.at <= now ? entry.line : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function noteAnnounced(id: string, line: string, now = Date.now()): void {
|
|
60
|
+
known.set(id, { line, at: now });
|
|
61
|
+
prune(now);
|
|
62
|
+
save();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function forgetAnnounced(): void {
|
|
66
|
+
file = undefined;
|
|
67
|
+
known = new Map();
|
|
68
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { log } from '@metro-labs/core/log';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
const DEFAULT_URL = 'https://mcp.metro.box';
|
|
4
4
|
|
|
5
5
|
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
6
6
|
|
|
@@ -18,7 +18,7 @@ function usable(raw: string): boolean {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export function mcpServerUrl(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
21
|
-
const raw = env.METRO_MCP_SERVER_URL?.trim() ??
|
|
21
|
+
const raw = env.METRO_MCP_SERVER_URL?.trim() ?? DEFAULT_URL;
|
|
22
22
|
if (raw === '') return null;
|
|
23
23
|
if (usable(raw)) return raw.replace(/\/+$/, '');
|
|
24
24
|
if (refused !== raw) {
|
|
@@ -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
|
+
}
|
|
@@ -13,9 +13,11 @@ export interface ToolPolicy {
|
|
|
13
13
|
tools?: Record<string, Access>;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
export interface PolicyTarget {
|
|
17
|
+
kind: 'channel';
|
|
18
|
+
station: string;
|
|
19
|
+
account: string;
|
|
20
|
+
}
|
|
19
21
|
|
|
20
22
|
export interface PolicyTool {
|
|
21
23
|
name: string;
|
|
@@ -28,8 +30,7 @@ const RANK: Record<Access, number> = { allow: 0, ask: 1, deny: 2 };
|
|
|
28
30
|
|
|
29
31
|
const isAccess = (value: unknown): value is Access => typeof value === 'string' && (ACCESS as readonly string[]).includes(value);
|
|
30
32
|
|
|
31
|
-
export const targetKey = (target: PolicyTarget): string =>
|
|
32
|
-
target.kind === 'channel' ? `channel:${target.station}/${target.account}` : `connector:${target.id}`;
|
|
33
|
+
export const targetKey = (target: PolicyTarget): string => `channel:${target.station}/${target.account}`;
|
|
33
34
|
|
|
34
35
|
export function decide(policy: ToolPolicy | undefined, tool: PolicyTool): Access {
|
|
35
36
|
const byTool = policy?.tools !== undefined && Object.hasOwn(policy.tools, tool.name) ? policy.tools[tool.name] : undefined;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { handleSessionApis, type SessionApis } from './session-apis.js';
|
|
2
2
|
import { handleGatewayRequest } from '../gateway/gateway.js';
|
|
3
|
-
import { handleRelayRequest } from '../connectors/relay.js';
|
|
4
3
|
import { handleThreemaCallback } from './threema-callback.js';
|
|
5
4
|
import { BodyTooLargeError, readBody, WEBHOOK_BODY_MAX } from './body.js';
|
|
6
5
|
|
|
@@ -346,7 +345,6 @@ async function handlePreMcpRoutes(
|
|
|
346
345
|
if (handleEarlyRoutes(req, res, apis)) return true;
|
|
347
346
|
if (handleUploadRequest(req, res)) return true;
|
|
348
347
|
if (handleAttachRequest(req, res)) return true;
|
|
349
|
-
if (apis.relayApi && handleRelayRequest(req, res, apis.relayApi)) return true;
|
|
350
348
|
if (await handleWebhookRoute(req, res, emit)) return true;
|
|
351
349
|
if (await handleThreemaCallback(req, res)) return true;
|
|
352
350
|
return monitor && handleMonitorRequest(req, res);
|
|
@@ -6,16 +6,12 @@ import { assertAttachOwner, finishGmailUpgrade, startGmailAttach } from './gmail
|
|
|
6
6
|
import { detachWithGmailRevocation } from './gmail-detach.js';
|
|
7
7
|
import { recentSenders } from '../agents/senders.js';
|
|
8
8
|
import { forwardTrainCall } from '../stations/train-call.js';
|
|
9
|
-
import { invalidateOrgConnectorSync, orgConnectorRelaySession, orgConnectorSyncStatus, setOrgConnectorSync } from '../connectors/org-sync-watch.js';
|
|
10
9
|
import type { AgentApiDeps } from '../agents/api.js';
|
|
11
10
|
import { ATTACHABLE, type AccountApiDeps } from '../agents/accounts-api.js';
|
|
12
11
|
import type { SessionApis } from './session-apis.js';
|
|
13
12
|
import type { ModeInfo } from '@metro-labs/http/mode-api';
|
|
14
13
|
import { loadedAgentOf, type AgentBundle, type BundleApiDeps } from '../agents/bundle.js';
|
|
15
14
|
import { METRO_VERSION } from '@metro-labs/core/version';
|
|
16
|
-
import { authenticate } from '../mcp/request-identity.js';
|
|
17
|
-
import type { RelayApiDeps } from '../connectors/relay.js';
|
|
18
|
-
import { connectorGates } from '../connectors/gates.js';
|
|
19
15
|
import {
|
|
20
16
|
setLocalOwner,
|
|
21
17
|
localAttachAccount,
|
|
@@ -78,7 +74,6 @@ function agentApi(deps: LocalModeDeps): AgentApiDeps {
|
|
|
78
74
|
...(deps.toolGroups === undefined ? {} : { toolGroups: deps.toolGroups }),
|
|
79
75
|
attachable: ATTACHABLE.filter((s) => s !== 'webhook'),
|
|
80
76
|
features: ['gmail-managed'],
|
|
81
|
-
connectorIds: connectorIdsOfLocalAgents,
|
|
82
77
|
prepareAccount: deps.prepareAccount,
|
|
83
78
|
attachAccount: localAttachAccount,
|
|
84
79
|
detachAccount: detachWithGmailRevocation,
|
|
@@ -93,20 +88,6 @@ function agentApi(deps: LocalModeDeps): AgentApiDeps {
|
|
|
93
88
|
};
|
|
94
89
|
}
|
|
95
90
|
|
|
96
|
-
function connectorIdsOfLocalAgents(ids: string[]): Promise<Map<string, string[]>> {
|
|
97
|
-
const all = connectorGates().map((gate) => gate.id);
|
|
98
|
-
return Promise.resolve(new Map(ids.map((id) => [id, all] as const)));
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const relayApi: RelayApiDeps = {
|
|
102
|
-
open: orgConnectorRelaySession,
|
|
103
|
-
identify: (req) => {
|
|
104
|
-
const who = authenticate(req);
|
|
105
|
-
return who?.kind === 'agent' ? { subject: 'agent-key', agentId: who.agentId } : null;
|
|
106
|
-
},
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
|
|
110
91
|
function bundleApi(deps: LocalModeDeps): BundleApiDeps {
|
|
111
92
|
return {
|
|
112
93
|
bundle: (agentId) => {
|
|
@@ -155,17 +136,11 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
155
136
|
return {
|
|
156
137
|
agentApi: agentApi(deps),
|
|
157
138
|
bundleApi: bundleApi(deps),
|
|
158
|
-
|
|
159
|
-
enrollApi: { owner: () => localOwner(), invalidate: invalidateOrgConnectorSync },
|
|
160
|
-
relayApi,
|
|
139
|
+
enrollApi: { owner: () => localOwner() },
|
|
161
140
|
claudeApi: { liveEvents: deps.liveEvents },
|
|
162
141
|
updateApi: { restart: deps.restart },
|
|
163
142
|
controlApi: { restart: deps.restart, stop: deps.stop },
|
|
164
|
-
ownerApi: { setOwner:
|
|
165
|
-
const saved = setLocalOwner(owner);
|
|
166
|
-
invalidateOrgConnectorSync();
|
|
167
|
-
return saved;
|
|
168
|
-
} },
|
|
143
|
+
ownerApi: { setOwner: setLocalOwner },
|
|
169
144
|
machineApi: {},
|
|
170
145
|
modelApi: { switchModel: deps.switchModel, owner: localOwner },
|
|
171
146
|
gateway: { config: readModelConfig },
|
|
@@ -2,8 +2,6 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
2
2
|
import { handleSessionApiRequest } from './session.js';
|
|
3
3
|
import { handleApprovalsRequest } from '../approvals/api.js';
|
|
4
4
|
import { handleAgentApiRequest, type AgentApiDeps } from '../agents/api.js';
|
|
5
|
-
import type { RelayApiDeps } from '../connectors/relay.js';
|
|
6
|
-
import { handleConnectorSyncRequest, type ConnectorSyncApiDeps } from '../connectors/org-sync-api.js';
|
|
7
5
|
import { handleEnrollRequest, type EnrollApiDeps } from '../connectors/enroll-api.js';
|
|
8
6
|
import { handleClaudeRequest, type ClaudeApiDeps } from '../claude/api.js';
|
|
9
7
|
import type { ModeInfo } from '@metro-labs/http/mode-api';
|
|
@@ -33,8 +31,6 @@ export interface SessionApis {
|
|
|
33
31
|
terminalApi?: TerminalApiDeps;
|
|
34
32
|
claudeApi?: ClaudeApiDeps;
|
|
35
33
|
enrollApi?: EnrollApiDeps;
|
|
36
|
-
connectorSyncApi?: ConnectorSyncApiDeps;
|
|
37
|
-
relayApi?: RelayApiDeps;
|
|
38
34
|
mode?: () => ModeInfo;
|
|
39
35
|
}
|
|
40
36
|
|
|
@@ -51,7 +47,6 @@ export function handleSessionApis(
|
|
|
51
47
|
() => handleRunEventsRequest(req, res),
|
|
52
48
|
() => handleApprovalsRequest(req, res),
|
|
53
49
|
...when(apis.enrollApi, (d) => handleEnrollRequest(req, res, d)),
|
|
54
|
-
...when(apis.connectorSyncApi, (d) => handleConnectorSyncRequest(req, res, d)),
|
|
55
50
|
...when(apis.bundleApi, (d) => handleBundleRequest(req, res, d)),
|
|
56
51
|
...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
|
|
57
52
|
...when(apis.controlApi, (d) => handleControlRequest(req, res, d)),
|
|
@@ -22,10 +22,10 @@
|
|
|
22
22
|
"./connector-discovery": "./src/connector-discovery.ts",
|
|
23
23
|
"./connector-oauth": "./src/connector-oauth.ts",
|
|
24
24
|
"./connector-input": "./src/connector-input.ts",
|
|
25
|
-
"./connector-
|
|
26
|
-
"./connector-migration": "./src/connector-migration.ts",
|
|
25
|
+
"./connector-policy": "./src/connector-policy.ts",
|
|
27
26
|
"./connector-grant": "./src/connector-grant.ts",
|
|
28
|
-
"./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"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"build": "tsc",
|
|
@@ -1,59 +1,8 @@
|
|
|
1
|
-
import { deliveryRecord } from './connector-delivery-validation.js';
|
|
2
|
-
import type { OAuthAuth } from './connector-mcp.js';
|
|
3
1
|
import { boundOAuthResource, parsePublicConnectorUrl } from './connector-public-url.js';
|
|
4
2
|
|
|
5
|
-
export interface PortableGrant extends OAuthAuth {
|
|
6
|
-
resource: string;
|
|
7
|
-
tokenEndpointAuthMethod: 'none' | 'client_secret_post';
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
const invalid = (): never => { throw new Error('This OAuth grant cannot be transferred. Its client authentication or provider binding is unsupported. Sign in separately.'); };
|
|
11
|
-
|
|
12
|
-
function text(raw: unknown, max = 16384): string {
|
|
13
|
-
if (typeof raw !== 'string' || raw.length === 0 || raw.length > max || /[^\x21-\x7e]/.test(raw)) return invalid();
|
|
14
|
-
return raw;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function endpoint(raw: unknown): string {
|
|
18
|
-
const value = text(raw, 2048);
|
|
19
|
-
parsePublicConnectorUrl(value);
|
|
20
|
-
return value;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
3
|
export function grantResource(target: string, raw: unknown): string {
|
|
24
4
|
const transport = parsePublicConnectorUrl(target);
|
|
25
5
|
const resource = parsePublicConnectorUrl(raw);
|
|
26
6
|
if (resource.toString() === transport.toString() && typeof raw === 'string') return raw;
|
|
27
7
|
return boundOAuthResource(target, raw);
|
|
28
8
|
}
|
|
29
|
-
|
|
30
|
-
function binding(value: Record<string, unknown>): Pick<PortableGrant, 'clientId' | 'clientSecret' | 'tokenEndpointAuthMethod'> {
|
|
31
|
-
const clientSecret = value.clientSecret === undefined ? undefined : text(value.clientSecret);
|
|
32
|
-
const method = value.tokenEndpointAuthMethod ?? (clientSecret === undefined ? 'none' : 'client_secret_post');
|
|
33
|
-
if (method !== 'none' && method !== 'client_secret_post') return invalid();
|
|
34
|
-
if ((method === 'client_secret_post') !== (clientSecret !== undefined)) return invalid();
|
|
35
|
-
return { clientId: text(value.clientId), tokenEndpointAuthMethod: method, ...(clientSecret === undefined ? {} : { clientSecret }) };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function expiry(raw: unknown): Pick<PortableGrant, 'expiresAt'> {
|
|
39
|
-
if (raw === undefined) return {};
|
|
40
|
-
if (typeof raw !== 'number' || !Number.isSafeInteger(raw) || raw < 0) return invalid();
|
|
41
|
-
return { expiresAt: raw };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function scopeOf(raw: unknown): Pick<PortableGrant, 'scope'> {
|
|
45
|
-
if (raw === undefined) return {};
|
|
46
|
-
if (typeof raw !== 'string' || raw.length > 16384 || /[^\x20-\x7e]/.test(raw)) return invalid();
|
|
47
|
-
return { scope: raw };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function portableGrant(raw: unknown, target: string): PortableGrant {
|
|
51
|
-
const value = deliveryRecord(raw, ['kind', 'accessToken', 'refreshToken', 'expiresAt', 'clientId', 'clientSecret', 'issuer', 'tokenEndpoint', 'scope', 'resource', 'tokenEndpointAuthMethod']);
|
|
52
|
-
if (value.kind !== 'oauth') return invalid();
|
|
53
|
-
return {
|
|
54
|
-
kind: 'oauth', accessToken: text(value.accessToken, 16377), issuer: endpoint(value.issuer), tokenEndpoint: endpoint(value.tokenEndpoint),
|
|
55
|
-
resource: grantResource(target, value.resource ?? parsePublicConnectorUrl(target).toString()),
|
|
56
|
-
...binding(value), ...expiry(value.expiresAt), ...scopeOf(value.scope),
|
|
57
|
-
...(value.refreshToken === undefined ? {} : { refreshToken: text(value.refreshToken) }),
|
|
58
|
-
};
|
|
59
|
-
}
|
|
@@ -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
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { MCP_CALLS_MAX, MCP_FIND_DEFAULT, MCP_FIND_MAX, TOOL_CALL, TOOL_CALL_EXAMPLE, TOOL_SEARCH } from './mcp-server-protocol.js';
|
|
2
|
+
|
|
3
|
+
export interface McpServerTool {
|
|
4
|
+
name: string;
|
|
5
|
+
title: string;
|
|
6
|
+
description: string;
|
|
7
|
+
inputSchema: Record<string, unknown>;
|
|
8
|
+
annotations: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const searchSchema = {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
query: {
|
|
15
|
+
type: 'string',
|
|
16
|
+
description: 'What to look for: "select:<connector>.<tool>" (comma separated) loads exact tools, keywords rank tools, "+word" requires that word in the tool name.',
|
|
17
|
+
},
|
|
18
|
+
max_results: { type: 'number', description: `Maximum number of results to return (default: ${String(MCP_FIND_DEFAULT)}, at most ${String(MCP_FIND_MAX)})`, default: MCP_FIND_DEFAULT },
|
|
19
|
+
},
|
|
20
|
+
required: ['query'],
|
|
21
|
+
additionalProperties: false,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const callSchema = {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: {
|
|
27
|
+
calls: {
|
|
28
|
+
type: 'array',
|
|
29
|
+
minItems: 1,
|
|
30
|
+
maxItems: MCP_CALLS_MAX,
|
|
31
|
+
description: `1 to ${String(MCP_CALLS_MAX)} independent calls. They run in parallel and are answered in order.`,
|
|
32
|
+
items: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
tool: { type: 'string', description: 'The tool name exactly as tool_search returned it: <connector>.<tool>.' },
|
|
36
|
+
arguments: { type: 'object', description: 'The tool\'s arguments, matching the parameters tool_search returned.', additionalProperties: true },
|
|
37
|
+
approval: { type: 'string', pattern: '^[a-km-z]{5}$', description: 'The 5-letter approval id, only when repeating a call the owner approved.' },
|
|
38
|
+
},
|
|
39
|
+
required: ['tool'],
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
required: ['calls'],
|
|
45
|
+
additionalProperties: false,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function connectorsLine(keys: string[] | null): string {
|
|
49
|
+
if (keys === null) return 'The connectors of this agent are named here once Metro reaches mcp.metro.box.';
|
|
50
|
+
return keys.length === 0 ? 'This agent has no connectors it may use yet.' : `This agent's connectors: ${keys.join(', ')}.`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const toolSearchDescription = (keys: string[] | null): string =>
|
|
54
|
+
[
|
|
55
|
+
'Search the tools of every connector assigned to this agent: third-party services such as Notion, n8n, Linear or Gmail, run by mcp.metro.box. Same design as Claude Code\'s ToolSearch, for connectors.',
|
|
56
|
+
connectorsLine(keys),
|
|
57
|
+
'Three query forms, one example each:',
|
|
58
|
+
'- "select:notion.search" loads exact tools by name (comma separated for several).',
|
|
59
|
+
'- "notion create page" ranks tools by the words in their name and description.',
|
|
60
|
+
'- "+linear issue" requires "linear" in the tool name and ranks by the other words.',
|
|
61
|
+
'Results are full definitions (name, description and the JSON schema of the arguments) in a <functions> block. Each name is <connector>.<tool>: pass it exactly as "tool" to tool_call. Tools the owner blocked are never returned.',
|
|
62
|
+
].join('\n');
|
|
63
|
+
|
|
64
|
+
export const TOOL_CALL_DESCRIPTION = [
|
|
65
|
+
'Run connector tools that tool_search found. Always find a tool with tool_search first: its exact name and argument schema come from there.',
|
|
66
|
+
`Shape: ${TOOL_CALL_EXAMPLE}`,
|
|
67
|
+
`Up to ${String(MCP_CALLS_MAX)} independent calls run in parallel in one tool_call and are answered in order. Each result starts with "[n] <connector>.<tool>", then the tool's own content, or one line saying why it did not run:`,
|
|
68
|
+
'- not_found: no such connector or tool here. Run tool_search, then use a name it returned.',
|
|
69
|
+
'- needs_approval: the owner was asked in chat and on metro.box. Tell the person what is waiting, wait for the channel message "Approved: <tool> (<id>)", then call again with the same tool and arguments plus "approval": "<id>". An approval works once.',
|
|
70
|
+
'- denied or blocked: the owner refused or blocked this call. Do not retry it.',
|
|
71
|
+
'- signin: the connector must be signed in again. Tell the owner to do it on the organization\'s Connectors page on metro.box.',
|
|
72
|
+
'- unavailable: the connector or Metro could not be reached. Try again later.',
|
|
73
|
+
'- vendor_error: the connector answered with its own error, shown as it came.',
|
|
74
|
+
].join('\n');
|
|
75
|
+
|
|
76
|
+
export function mcpServerTools(keys: string[] | null): McpServerTool[] {
|
|
77
|
+
return [
|
|
78
|
+
{
|
|
79
|
+
name: TOOL_SEARCH,
|
|
80
|
+
title: 'Search connector tools',
|
|
81
|
+
description: toolSearchDescription(keys),
|
|
82
|
+
inputSchema: searchSchema,
|
|
83
|
+
annotations: { title: 'Search connector tools', readOnlyHint: true, openWorldHint: false },
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: TOOL_CALL,
|
|
87
|
+
title: 'Call connector tools',
|
|
88
|
+
description: TOOL_CALL_DESCRIPTION,
|
|
89
|
+
inputSchema: callSchema,
|
|
90
|
+
annotations: { title: 'Call connector tools', readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
}
|