@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.
Files changed (61) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +3 -4
  3. package/runtime/marketplace/plugin/METRO.md +2 -0
  4. package/runtime/marketplace/plugin/bin/guard.mjs +2 -1
  5. package/runtime/marketplace/plugin/bin/metro-plugin.mjs +9 -15
  6. package/runtime/marketplace/plugin/bin/policy.mjs +1 -32
  7. package/runtime/node_modules/@metro-labs/core/package.json +0 -1
  8. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +4 -1
  9. package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +3 -5
  10. package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +11 -10
  11. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +16 -9
  12. package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +6 -3
  13. package/runtime/node_modules/@metro-labs/daemon/src/channels/notice.ts +22 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +1 -3
  15. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +2 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +0 -2
  17. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +11 -51
  18. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +2 -0
  19. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +22 -15
  20. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +6 -4
  21. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +0 -14
  22. package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +108 -0
  23. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/announced.ts +68 -0
  24. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +31 -0
  25. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/link.ts +186 -0
  26. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/token.ts +50 -0
  27. package/runtime/node_modules/@metro-labs/daemon/src/policy/policy.ts +6 -5
  28. package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +0 -2
  29. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -27
  30. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -5
  31. package/runtime/node_modules/@metro-labs/http/package.json +3 -3
  32. package/runtime/node_modules/@metro-labs/http/src/connector-grant.ts +0 -51
  33. package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +2 -1
  34. package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +81 -0
  35. package/runtime/runtime.json +1 -1
  36. package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +0 -1
  37. package/runtime/sdk-runner/runner.json +1 -1
  38. package/runtime/node_modules/@metro-labs/core/src/connector-sync.ts +0 -17
  39. package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +0 -104
  40. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-adopt.ts +0 -57
  41. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-relay.ts +0 -61
  42. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +0 -115
  43. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +0 -271
  44. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-api.ts +0 -25
  45. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +0 -173
  46. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-setting.ts +0 -60
  47. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +0 -38
  48. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +0 -208
  49. package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +0 -40
  50. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-id.ts +0 -2
  51. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-policy.ts +0 -90
  52. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +0 -10
  53. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-target.ts +0 -4
  54. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +0 -287
  55. package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +0 -59
  56. package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +0 -1
  57. package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +0 -116
  58. package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +0 -19
  59. package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +0 -178
  60. package/runtime/node_modules/@metro-labs/http/src/transfer-crypto.ts +0 -44
  61. 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
- const callChat = (deps: PermissionRelayDeps): string | undefined => {
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: PermissionRelayDeps, owner: OwnerLine | undefined): string | undefined =>
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: PermissionRelayDeps, requestId: string, callLine?: string): string | undefined {
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 type PolicyTarget =
17
- | { kind: 'channel'; station: string; account: string }
18
- | { kind: 'connector'; id: string };
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
- connectorSyncApi: { status: orgConnectorSyncStatus, set: setOrgConnectorSync },
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: (owner) => {
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-delivery": "./src/connector-delivery.ts",
26
- "./connector-migration": "./src/connector-migration.ts",
27
- "./connector-grant": "./src/connector-grant.ts"
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",