@stage-labs/metro 0.1.0-beta.303 → 0.1.0-beta.305
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/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +3 -4
- package/runtime/marketplace/plugin/METRO.md +2 -0
- package/runtime/marketplace/plugin/bin/guard.mjs +2 -1
- package/runtime/marketplace/plugin/bin/metro-plugin.mjs +9 -15
- package/runtime/marketplace/plugin/bin/policy.mjs +1 -32
- package/runtime/node_modules/@metro-labs/core/package.json +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +3 -5
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +11 -10
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +16 -9
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +6 -3
- package/runtime/node_modules/@metro-labs/daemon/src/channels/notice.ts +22 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +1 -3
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +11 -51
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +22 -15
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +6 -4
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +0 -14
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +108 -0
- 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 +31 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/link.ts +186 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/token.ts +50 -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/connector-tools.ts +2 -1
- package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +81 -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/node_modules/@metro-labs/core/src/connector-sync.ts +0 -17
- 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/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
|
@@ -24,7 +24,7 @@ type PermissionRequest = z.infer<typeof PermissionRequestSchema>;
|
|
|
24
24
|
|
|
25
25
|
const trainOf = (line: string): string => line.split('/')[2] ?? '';
|
|
26
26
|
|
|
27
|
-
async function metroSend(line: string, text: string): Promise<void> {
|
|
27
|
+
export async function metroSend(line: string, text: string): Promise<void> {
|
|
28
28
|
await metroCall(trainOf(line), 'send', { line, text });
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -36,15 +36,17 @@ export interface PermissionRelayDeps {
|
|
|
36
36
|
log: (...a: unknown[]) => void;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
export type PromptLineDeps = Pick<PermissionRelayDeps, 'relay' | 'inScope' | 'live' | 'log'>;
|
|
40
|
+
|
|
41
|
+
const callChat = (deps: PromptLineDeps): string | undefined => {
|
|
40
42
|
const route = sharedCalls.snapshot();
|
|
41
43
|
return route !== null && deps.inScope(route.line) ? route.line : undefined;
|
|
42
44
|
};
|
|
43
45
|
|
|
44
|
-
const ownerChat = (deps:
|
|
46
|
+
const ownerChat = (deps: PromptLineDeps, owner: OwnerLine | undefined): string | undefined =>
|
|
45
47
|
owner !== undefined && mayApprove(owner.line, owner.from) && deps.inScope(owner.line) && lineReceives(owner.line) ? owner.line : undefined;
|
|
46
48
|
|
|
47
|
-
function relayLine(deps:
|
|
49
|
+
export function relayLine(deps: PromptLineDeps, requestId: string, callLine?: string): string | undefined {
|
|
48
50
|
if (!deps.live()) {
|
|
49
51
|
deps.log('permission_request: live events are off, so no chat answer can arrive; held for the page only', requestId);
|
|
50
52
|
return undefined;
|
|
@@ -7,7 +7,6 @@ import { agentsDir } from '../agents/files.js';
|
|
|
7
7
|
import { knownAccounts } from '../agents/map.js';
|
|
8
8
|
import { onPoliciesChanged, storedPolicies, type ToolPolicy } from '../policy/policy.js';
|
|
9
9
|
import { UNGATED } from './policy-gate.js';
|
|
10
|
-
import { connectorGates } from '../connectors/gates.js';
|
|
11
10
|
import { stationToolOwners, TOOL_DEFS, toolGroupOf } from './tool-catalog.js';
|
|
12
11
|
|
|
13
12
|
interface PolicySnapshot {
|
|
@@ -17,14 +16,6 @@ interface PolicySnapshot {
|
|
|
17
16
|
ungated: string[];
|
|
18
17
|
accounts: Record<string, ToolPolicy>;
|
|
19
18
|
stations: Record<string, string[]>;
|
|
20
|
-
connectors: Record<string, SnapshotConnector>;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
interface SnapshotConnector {
|
|
24
|
-
id: string;
|
|
25
|
-
name: string;
|
|
26
|
-
policy: ToolPolicy;
|
|
27
|
-
tools: Record<string, ToolGroup>;
|
|
28
19
|
}
|
|
29
20
|
|
|
30
21
|
const FILE = 'policy.json';
|
|
@@ -44,11 +35,6 @@ function policySnapshot(): PolicySnapshot {
|
|
|
44
35
|
ungated: [...UNGATED],
|
|
45
36
|
accounts,
|
|
46
37
|
stations,
|
|
47
|
-
connectors: Object.fromEntries(
|
|
48
|
-
connectorGates()
|
|
49
|
-
.filter((gate) => gate.server !== '')
|
|
50
|
-
.map((gate) => [gate.server, { id: gate.id, name: gate.name, policy: gate.policy, tools: gate.tools }]),
|
|
51
|
-
),
|
|
52
38
|
};
|
|
53
39
|
}
|
|
54
40
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { MetroEvent } from '@metro-labs/core/events';
|
|
2
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
import type { McpApproval, McpDecision } from '@metro-labs/http/mcp-server-protocol';
|
|
4
|
+
import { holdPrompt, isPromptHeld, type Behavior } from '../approvals/pending.js';
|
|
5
|
+
import { ownerLine } from '../approvals/owner-line.js';
|
|
6
|
+
import { metroNotice } from '../channels/notice.js';
|
|
7
|
+
import { announcedLine, noteAnnounced } from '../mcp-server/announced.js';
|
|
8
|
+
import type { AnswerOutcome } from '../mcp-server/link.js';
|
|
9
|
+
import { serverPromptBody } from './permission-prompt.js';
|
|
10
|
+
import { metroSend, relayLine } from './permission-relay.js';
|
|
11
|
+
import { channelLog, type McpSession } from './session.js';
|
|
12
|
+
|
|
13
|
+
const RECENT_MS = 10 * 60_000;
|
|
14
|
+
const RECENT_MAX = 1_000;
|
|
15
|
+
|
|
16
|
+
const owner = {};
|
|
17
|
+
const recent = new Map<string, number>();
|
|
18
|
+
let current: () => McpSession | undefined = () => undefined;
|
|
19
|
+
|
|
20
|
+
export function connectServerApprovals(session: () => McpSession | undefined): void {
|
|
21
|
+
current = session;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function promptLineFor(requestId: string): string | undefined {
|
|
25
|
+
const session = current();
|
|
26
|
+
if (session === undefined) return undefined;
|
|
27
|
+
return relayLine({ relay: session.relay, inScope: (line) => session.inScope(line), live: session.live, log: channelLog }, requestId);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const noticeLineFor = (line: string | undefined): string | undefined => line ?? current()?.relay.knownLine ?? ownerLine()?.line;
|
|
31
|
+
|
|
32
|
+
function remember(id: string, now = Date.now()): void {
|
|
33
|
+
for (const [seen, at] of recent) if (now - at >= RECENT_MS) recent.delete(seen);
|
|
34
|
+
recent.set(id, now);
|
|
35
|
+
while (recent.size > RECENT_MAX) {
|
|
36
|
+
const oldest = recent.keys().next();
|
|
37
|
+
if (oldest.done) break;
|
|
38
|
+
recent.delete(oldest.value);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const answeredRecently = (id: string, now = Date.now()): boolean => now - (recent.get(id) ?? -Infinity) < RECENT_MS;
|
|
43
|
+
|
|
44
|
+
const noticeText = (tool: string, id: string, behavior: Behavior): string =>
|
|
45
|
+
behavior === 'allow' ? `Approved: ${tool} (${id}). Call call_tool again with the same arguments and approval: "${id}".` : `Denied: ${tool} (${id}).`;
|
|
46
|
+
|
|
47
|
+
const requestedAt = (at: number, now = Date.now()): number => (Number.isFinite(at) && at > 0 ? Math.min(at, now) : now);
|
|
48
|
+
|
|
49
|
+
interface Held {
|
|
50
|
+
id: string;
|
|
51
|
+
tool: string;
|
|
52
|
+
line: string | undefined;
|
|
53
|
+
answer: (decision: McpDecision) => Promise<AnswerOutcome>;
|
|
54
|
+
publish: (event: MetroEvent) => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function deliver(held: Held, behavior: Behavior): Promise<void> {
|
|
58
|
+
const outcome = await held.answer(behavior);
|
|
59
|
+
if (outcome !== 'acknowledged') {
|
|
60
|
+
log.info({ id: held.id, outcome }, 'mcp-server: the answer was not taken, so the agent is not told');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const line = noticeLineFor(held.line);
|
|
64
|
+
if (line === undefined) {
|
|
65
|
+
log.warn({ id: held.id }, 'mcp-server: no chat is known to tell the agent the answer');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
held.publish(metroNotice(line, noticeText(held.tool, held.id, behavior)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sender(held: Held): (behavior: Behavior) => Promise<void> {
|
|
72
|
+
return (behavior) => {
|
|
73
|
+
remember(held.id);
|
|
74
|
+
deliver(held, behavior).catch((err: unknown) => {
|
|
75
|
+
log.warn({ id: held.id, err: errMsg(err) }, 'mcp-server: the answer could not be delivered');
|
|
76
|
+
});
|
|
77
|
+
return Promise.resolve();
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function announce(id: string, line: string, text: string): void {
|
|
82
|
+
metroSend(line, text).then(() => {
|
|
83
|
+
noteAnnounced(id, line);
|
|
84
|
+
}).catch((err: unknown) => {
|
|
85
|
+
log.warn({ id, err: errMsg(err) }, 'mcp-server: the approval prompt could not be posted');
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function holdServerApproval(approval: McpApproval, answer: Held['answer'], publish: Held['publish']): boolean {
|
|
90
|
+
if (isPromptHeld(approval.id) || answeredRecently(approval.id)) return false;
|
|
91
|
+
const tool = `${approval.connector}.${approval.tool}`;
|
|
92
|
+
const announced = announcedLine(approval.id);
|
|
93
|
+
const line = announced ?? promptLineFor(approval.id);
|
|
94
|
+
const held: Held = { id: approval.id, tool, line, answer, publish };
|
|
95
|
+
holdPrompt(
|
|
96
|
+
{ requestId: approval.id, tool, description: approval.connector, preview: approval.preview, line, at: requestedAt(approval.requestedAt), origin: 'mcp-server' },
|
|
97
|
+
owner,
|
|
98
|
+
sender(held),
|
|
99
|
+
line === undefined ? undefined : async (text) => metroSend(line, text),
|
|
100
|
+
);
|
|
101
|
+
log.info({ id: approval.id, tool, inChat: line !== undefined, announced: announced !== undefined }, 'mcp-server: an approval is waiting for the owner');
|
|
102
|
+
if (line !== undefined && announced === undefined) announce(approval.id, line, serverPromptBody({ requestId: approval.id, tool, preview: approval.preview }));
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function forgetServerApprovals(): void {
|
|
107
|
+
recent.clear();
|
|
108
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { log } from '@metro-labs/core/log';
|
|
2
|
+
|
|
3
|
+
export const MCP_SERVER_KEY = 'mcp';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_URL = 'https://mcp.metro.box';
|
|
6
|
+
|
|
7
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
8
|
+
|
|
9
|
+
let refused: string | undefined;
|
|
10
|
+
|
|
11
|
+
function usable(raw: string): boolean {
|
|
12
|
+
let url: URL;
|
|
13
|
+
try {
|
|
14
|
+
url = new URL(raw);
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
const secure = url.protocol === 'https:' || (url.protocol === 'http:' && LOOPBACK.has(url.hostname));
|
|
19
|
+
return secure && url.username === '' && url.password === '' && url.search === '' && url.hash === '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function mcpServerUrl(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
23
|
+
const raw = env.METRO_MCP_SERVER_URL?.trim() ?? DEFAULT_URL;
|
|
24
|
+
if (raw === '') return null;
|
|
25
|
+
if (usable(raw)) return raw.replace(/\/+$/, '');
|
|
26
|
+
if (refused !== raw) {
|
|
27
|
+
refused = raw;
|
|
28
|
+
log.warn({ url: raw }, 'mcp-server: METRO_MCP_SERVER_URL must be an https url (http only on this machine), so it is ignored');
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
2
|
+
import { MCP_APPROVAL_WAIT_MS, MCP_PATHS, mcpApprovalsOf, type McpApproval, type McpDecision } from '@metro-labs/http/mcp-server-protocol';
|
|
3
|
+
import { agentsDir } from '../agents/files.js';
|
|
4
|
+
import { readAgentKey, type AgentKey } from '../connectors/agent-key.js';
|
|
5
|
+
import { agentCall, MetroApiRefusal, type AgentFetch, type MetroApi } from '../connectors/metro-api.js';
|
|
6
|
+
import { mcpServerUrl } from './config.js';
|
|
7
|
+
import { ensureMcpToken } from './token.js';
|
|
8
|
+
|
|
9
|
+
export type AnswerOutcome = 'acknowledged' | 'gone' | 'stopped';
|
|
10
|
+
|
|
11
|
+
export type HoldApproval = (approval: McpApproval, answer: (decision: McpDecision) => Promise<AnswerOutcome>) => void;
|
|
12
|
+
|
|
13
|
+
export interface McpTiming {
|
|
14
|
+
retryMs: number;
|
|
15
|
+
retryMaxMs: number;
|
|
16
|
+
idleMs: number;
|
|
17
|
+
minPollMs: number;
|
|
18
|
+
pollTimeoutMs: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface McpLinkDeps {
|
|
22
|
+
url: string;
|
|
23
|
+
owner: () => string | null;
|
|
24
|
+
key: () => AgentKey | null;
|
|
25
|
+
dir: () => string;
|
|
26
|
+
hold: HoldApproval;
|
|
27
|
+
fetch?: AgentFetch;
|
|
28
|
+
timing?: Partial<McpTiming>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const TIMING: McpTiming = { retryMs: 2_000, retryMaxMs: 60_000, idleMs: 30_000, minPollMs: 1_000, pollTimeoutMs: MCP_APPROVAL_WAIT_MS + 15_000 };
|
|
32
|
+
const APPROVALS_MAX = 256 * 1024;
|
|
33
|
+
|
|
34
|
+
const refusedWith = (err: unknown, status: number): boolean => err instanceof MetroApiRefusal && err.upstreamStatus === status;
|
|
35
|
+
|
|
36
|
+
export class McpServerLink {
|
|
37
|
+
private readonly api: MetroApi;
|
|
38
|
+
private readonly timing: McpTiming;
|
|
39
|
+
private readonly abort = new AbortController();
|
|
40
|
+
private registered: string | null = null;
|
|
41
|
+
private failures = 0;
|
|
42
|
+
private running: Promise<void> | null = null;
|
|
43
|
+
|
|
44
|
+
constructor(private readonly deps: McpLinkDeps) {
|
|
45
|
+
this.api = { base: deps.url, fetch: deps.fetch ?? ((url, init) => fetch(url, init)), now: () => Date.now() };
|
|
46
|
+
this.timing = { ...TIMING, ...deps.timing };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private get stopped(): boolean {
|
|
50
|
+
return this.abort.signal.aborted;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
start(): void {
|
|
54
|
+
this.running ??= this.run().catch((err: unknown) => {
|
|
55
|
+
log.error({ err: errMsg(err) }, 'mcp-server: the token and approval loop stopped');
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async stop(): Promise<void> {
|
|
60
|
+
this.abort.abort();
|
|
61
|
+
await this.running;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async answer(id: string, decision: McpDecision): Promise<AnswerOutcome> {
|
|
65
|
+
for (let attempt = 0; !this.stopped; attempt += 1) {
|
|
66
|
+
if (attempt > 0) await this.pause(this.delay(attempt));
|
|
67
|
+
const key = this.enrolledKey();
|
|
68
|
+
if (key === null || this.stopped) continue;
|
|
69
|
+
try {
|
|
70
|
+
await agentCall(this.api, key, MCP_PATHS.answer, { id, decision }, { signal: this.abort.signal });
|
|
71
|
+
log.info({ id, decision }, 'mcp-server: the owner answer reached the server');
|
|
72
|
+
return 'acknowledged';
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (refusedWith(err, 404)) return 'gone';
|
|
75
|
+
if (refusedWith(err, 401)) this.registered = null;
|
|
76
|
+
if (!this.stopped) log.warn({ id, attempt: attempt + 1, err: errMsg(err) }, 'mcp-server: the owner answer did not reach the server; trying again');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return 'stopped';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private enrolledKey(): AgentKey | null {
|
|
83
|
+
try {
|
|
84
|
+
const key = this.deps.key();
|
|
85
|
+
const owner = this.deps.owner();
|
|
86
|
+
return key !== null && owner !== null && key.enrollment?.organization === owner ? key : null;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
log.warn({ err: errMsg(err) }, 'mcp-server: the agent key could not be read');
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private async run(): Promise<void> {
|
|
94
|
+
while (!this.stopped) {
|
|
95
|
+
const key = this.enrolledKey();
|
|
96
|
+
if (key === null) {
|
|
97
|
+
this.registered = null;
|
|
98
|
+
await this.pause(this.timing.idleMs);
|
|
99
|
+
} else if (await this.step(key)) this.failures = 0;
|
|
100
|
+
else await this.pause(this.delay(++this.failures));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async step(key: AgentKey): Promise<boolean> {
|
|
105
|
+
let doing = 'register the MCP token';
|
|
106
|
+
try {
|
|
107
|
+
await this.register(key);
|
|
108
|
+
doing = 'ask for approvals';
|
|
109
|
+
await this.poll(key);
|
|
110
|
+
return true;
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (this.stopped) return true;
|
|
113
|
+
if (refusedWith(err, 401)) this.registered = null;
|
|
114
|
+
log.warn({ server: this.deps.url, failures: this.failures + 1, err: errMsg(err) }, `mcp-server: could not ${doing}; trying again`);
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private async register(key: AgentKey): Promise<void> {
|
|
120
|
+
const token = ensureMcpToken(key.keyId, this.deps.dir());
|
|
121
|
+
if (token.fresh) log.info({ keyId: key.keyId }, 'mcp-server: made a new MCP token for this agent');
|
|
122
|
+
const binding = `${key.keyId} ${token.hash}`;
|
|
123
|
+
if (this.registered === binding) return;
|
|
124
|
+
await agentCall(this.api, key, MCP_PATHS.token, { tokenHash: token.hash }, { signal: this.abort.signal });
|
|
125
|
+
this.registered = binding;
|
|
126
|
+
log.info({ server: this.deps.url, keyId: key.keyId }, 'mcp-server: the MCP token is registered');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private async poll(key: AgentKey): Promise<void> {
|
|
130
|
+
const started = Date.now();
|
|
131
|
+
const answer = await agentCall(this.api, key, MCP_PATHS.approvals, {}, { signal: this.abort.signal, timeoutMs: this.timing.pollTimeoutMs, answerMax: APPROVALS_MAX });
|
|
132
|
+
const approvals = mcpApprovalsOf(answer);
|
|
133
|
+
for (const approval of approvals) this.receive(approval);
|
|
134
|
+
const rest = this.timing.minPollMs - (Date.now() - started);
|
|
135
|
+
if (approvals.length === 0 && rest > 0) await this.pause(rest);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private receive(approval: McpApproval): void {
|
|
139
|
+
try {
|
|
140
|
+
this.deps.hold(approval, (decision) => this.answer(approval.id, decision));
|
|
141
|
+
} catch (err) {
|
|
142
|
+
log.warn({ id: approval.id, err: errMsg(err) }, 'mcp-server: an approval could not be held');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private delay(attempt: number): number {
|
|
147
|
+
return Math.min(this.timing.retryMs * 2 ** Math.min(attempt - 1, 20), this.timing.retryMaxMs);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private pause(ms: number): Promise<void> {
|
|
151
|
+
if (this.stopped) return Promise.resolve();
|
|
152
|
+
const signal = this.abort.signal;
|
|
153
|
+
return new Promise((resolve) => {
|
|
154
|
+
const done = (): void => {
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
signal.removeEventListener('abort', done);
|
|
157
|
+
resolve();
|
|
158
|
+
};
|
|
159
|
+
const timer = setTimeout(done, ms);
|
|
160
|
+
timer.unref();
|
|
161
|
+
signal.addEventListener('abort', done, { once: true });
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let active: McpServerLink | null = null;
|
|
167
|
+
|
|
168
|
+
export interface McpServerWatch {
|
|
169
|
+
owner: () => string | null;
|
|
170
|
+
hold: HoldApproval;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function watchMcpServer(watch: McpServerWatch, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
174
|
+
const url = mcpServerUrl(env);
|
|
175
|
+
if (url === null || active !== null) return false;
|
|
176
|
+
active = new McpServerLink({ url, owner: watch.owner, key: () => readAgentKey(), dir: agentsDir, hold: watch.hold });
|
|
177
|
+
active.start();
|
|
178
|
+
log.info({ server: url }, 'mcp-server: connector approvals relay from this server once the agent is enrolled');
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function stopMcpServer(): Promise<void> {
|
|
183
|
+
const link = active;
|
|
184
|
+
active = null;
|
|
185
|
+
await link?.stop();
|
|
186
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
+
import { writeSecure } from '@metro-labs/core/secure-fs';
|
|
6
|
+
import { MCP_TOKEN_RE } from '@metro-labs/http/mcp-server-protocol';
|
|
7
|
+
|
|
8
|
+
export const MCP_TOKEN_FILE = 'mcp-token';
|
|
9
|
+
const BINDING_FILE = 'mcp-token.json';
|
|
10
|
+
|
|
11
|
+
export interface McpToken {
|
|
12
|
+
token: string;
|
|
13
|
+
hash: string;
|
|
14
|
+
fresh: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const mcpTokenHash = (token: string): string => createHash('sha256').update(token).digest('base64url');
|
|
18
|
+
|
|
19
|
+
const readText = (path: string): string | null => {
|
|
20
|
+
try {
|
|
21
|
+
return readFileSync(path, 'utf8');
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function readMcpToken(dir: string): string | null {
|
|
28
|
+
const text = readText(join(dir, MCP_TOKEN_FILE))?.trim() ?? '';
|
|
29
|
+
return MCP_TOKEN_RE.test(text) ? text : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function boundKeyId(dir: string): string | null {
|
|
33
|
+
const text = readText(join(dir, BINDING_FILE));
|
|
34
|
+
if (text === null) return null;
|
|
35
|
+
try {
|
|
36
|
+
const parsed: unknown = JSON.parse(text);
|
|
37
|
+
return isRecord(parsed) && parsed.version === 1 && typeof parsed.keyId === 'string' ? parsed.keyId : null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function ensureMcpToken(keyId: string, dir: string): McpToken {
|
|
44
|
+
const kept = readMcpToken(dir);
|
|
45
|
+
if (kept !== null && boundKeyId(dir) === keyId) return { token: kept, hash: mcpTokenHash(kept), fresh: false };
|
|
46
|
+
const token = `mcp_${randomBytes(32).toString('base64url')}`;
|
|
47
|
+
writeSecure(join(dir, MCP_TOKEN_FILE), `${token}\n`);
|
|
48
|
+
writeSecure(join(dir, BINDING_FILE), `${JSON.stringify({ version: 1, keyId })}\n`);
|
|
49
|
+
return { token, hash: mcpTokenHash(token), fresh: true };
|
|
50
|
+
}
|
|
@@ -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,9 +22,9 @@
|
|
|
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-
|
|
27
|
-
"./
|
|
25
|
+
"./connector-policy": "./src/connector-policy.ts",
|
|
26
|
+
"./connector-grant": "./src/connector-grant.ts",
|
|
27
|
+
"./mcp-server-protocol": "./src/mcp-server-protocol.ts"
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
30
|
"build": "tsc",
|