@stage-labs/metro 0.1.0-beta.302 → 0.1.0-beta.304

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 (56) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +2 -2
  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 +20 -2
  6. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +4 -1
  7. package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +3 -11
  8. package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +10 -3
  9. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +16 -8
  10. package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +6 -3
  11. package/runtime/node_modules/@metro-labs/daemon/src/channels/notice.ts +22 -0
  12. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +3 -1
  13. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +1 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +1 -11
  15. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-adopt.ts +57 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +39 -95
  17. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +27 -45
  18. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +2 -17
  19. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +12 -37
  20. package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +8 -29
  21. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +0 -1
  22. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +33 -138
  23. package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +22 -9
  24. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +2 -0
  25. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +27 -8
  26. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +6 -4
  27. package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +109 -0
  28. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +29 -0
  29. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/link.ts +186 -0
  30. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/token.ts +50 -0
  31. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +4 -58
  32. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -12
  33. package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +0 -7
  34. package/runtime/node_modules/@metro-labs/http/package.json +2 -1
  35. package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +2 -1
  36. package/runtime/node_modules/@metro-labs/http/src/mcp-server-protocol.ts +81 -0
  37. package/runtime/runtime.json +1 -1
  38. package/runtime/sdk-runner/runner.json +1 -1
  39. package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +0 -316
  40. package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +0 -105
  41. package/runtime/node_modules/@metro-labs/daemon/src/connectors/health.ts +0 -18
  42. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +0 -60
  43. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +0 -366
  44. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +0 -95
  45. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +0 -226
  46. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +0 -235
  47. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +0 -42
  48. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +0 -142
  49. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-url.ts +0 -9
  50. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +0 -178
  51. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +0 -171
  52. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-passive.ts +0 -96
  53. package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +0 -393
  54. package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-api.ts +0 -116
  55. package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-crypto.ts +0 -44
  56. package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +0 -46
@@ -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
+ }
@@ -6,7 +6,6 @@ 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 { publishLocalConnectors } from '../connectors/publication.js';
10
9
  import { invalidateOrgConnectorSync, orgConnectorRelaySession, orgConnectorSyncStatus, setOrgConnectorSync } from '../connectors/org-sync-watch.js';
11
10
  import type { AgentApiDeps } from '../agents/api.js';
12
11
  import { ATTACHABLE, type AccountApiDeps } from '../agents/accounts-api.js';
@@ -14,32 +13,9 @@ import type { SessionApis } from './session-apis.js';
14
13
  import type { ModeInfo } from '@metro-labs/http/mode-api';
15
14
  import { loadedAgentOf, type AgentBundle, type BundleApiDeps } from '../agents/bundle.js';
16
15
  import { METRO_VERSION } from '@metro-labs/core/version';
17
- import type { ConnectorApiDeps } from '../connectors/api.js';
18
- import { connectorMigrationPreview } from '../connectors/migration-preview.js';
19
- import { ConnectorMigrations } from '../connectors/migration-control.js';
20
- import { migrationExportableRows } from '../connectors/migration-guard.js';
21
- import { ConnectorTransfers } from '../connectors/transfer-api.js';
22
- import { copyConnectorRows } from '../connectors/transfer-store.js';
23
16
  import { authenticate } from '../mcp/request-identity.js';
24
17
  import type { RelayApiDeps } from '../connectors/relay.js';
25
- import {
26
- readLocalConnectors,
27
- localImportConnectors,
28
- localCreateConnector,
29
- localCreatePendingConnector,
30
- localDeleteConnector,
31
- localDisconnectConnector,
32
- localGetConnector,
33
- localListConnectors,
34
- localReconnectConnector,
35
- localRelayTarget,
36
- localRenameConnector,
37
- localVerifyConnector,
38
- localMarkSignedOut,
39
- localConnectorTools,
40
- localSetConnectorPolicy,
41
- } from '../connectors/store.js';
42
- import { blockedReason } from '../connectors/gates.js';
18
+ import { connectorGates } from '../connectors/gates.js';
43
19
  import {
44
20
  setLocalOwner,
45
21
  localAttachAccount,
@@ -117,43 +93,17 @@ function agentApi(deps: LocalModeDeps): AgentApiDeps {
117
93
  };
118
94
  }
119
95
 
120
- const allConnectorIds = (): string[] => readLocalConnectors().map((c) => c.id);
121
-
122
96
  function connectorIdsOfLocalAgents(ids: string[]): Promise<Map<string, string[]>> {
123
- const all = allConnectorIds();
97
+ const all = connectorGates().map((gate) => gate.id);
124
98
  return Promise.resolve(new Map(ids.map((id) => [id, all] as const)));
125
99
  }
126
100
 
127
- const connectorApi: ConnectorApiDeps = {
128
- migrationPreview: () => connectorMigrationPreview(localOwner()),
129
- listConnectors: async () => {
130
- const rows = await localListConnectors();
131
- publishLocalConnectors(readLocalConnectors());
132
- return rows;
133
- },
134
- createConnector: localCreateConnector,
135
- verifyConnector: localVerifyConnector,
136
- connectorTools: localConnectorTools,
137
- disconnectConnector: localDisconnectConnector,
138
- renameConnector: localRenameConnector,
139
- deleteConnector: localDeleteConnector,
140
- setConnectorPolicy: (id, policy) => localSetConnectorPolicy(id, policy),
141
- createPendingConnector: localCreatePendingConnector,
142
- reconnectConnector: localReconnectConnector,
143
- getConnector: localGetConnector,
144
- };
145
-
146
101
  const relayApi: RelayApiDeps = {
147
- target: (connectorId, force) => localRelayTarget(connectorId, force),
148
102
  open: orgConnectorRelaySession,
149
103
  identify: (req) => {
150
104
  const who = authenticate(req);
151
105
  return who?.kind === 'agent' ? { subject: 'agent-key', agentId: who.agentId } : null;
152
106
  },
153
- signedOut: (id) => {
154
- localMarkSignedOut(id);
155
- },
156
- blocked: blockedReason,
157
107
  };
158
108
 
159
109
 
@@ -164,18 +114,17 @@ function bundleApi(deps: LocalModeDeps): BundleApiDeps {
164
114
  const bundle: AgentBundle = {
165
115
  version: 1,
166
116
  agent: { id: file.id, name: file.name ?? '', stations: file.stations.filter((a) => a.station !== 'gmail' || a.config.managed !== true) },
167
- connectors: migrationExportableRows(readLocalConnectors()).map((c) => ({ id: c.id, name: c.name, url: c.url, config: { ...c.config } })),
117
+ connectors: [],
168
118
  };
169
119
  return Promise.resolve(bundle);
170
120
  },
171
121
  restore: async (bundle, mode) => {
172
122
  const made = await localImportAgent(loadedAgentOf(bundle), undefined, mode);
173
- const connectors = localImportConnectors(bundle.connectors, undefined, mode);
174
123
  for (const station of new Set(bundle.agent.stations.map((a) => a.station)))
175
124
  await deps.syncStations(station).catch((err: unknown) => {
176
125
  log.warn({ station, err: errMsg(err) }, 'restore: station reload failed, the change lands at the next boot');
177
126
  });
178
- return { id: made.id, name: made.name, stations: made.stations, connectors };
127
+ return { id: made.id, name: made.name, stations: made.stations, connectors: 0 };
179
128
  },
180
129
  };
181
130
  }
@@ -206,10 +155,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
206
155
  return {
207
156
  agentApi: agentApi(deps),
208
157
  bundleApi: bundleApi(deps),
209
- connectorApi,
210
158
  connectorSyncApi: { status: orgConnectorSyncStatus, set: setOrgConnectorSync },
211
- connectorMigrationApi: new ConnectorMigrations(),
212
- connectorTransfers: new ConnectorTransfers({ read: () => migrationExportableRows(readLocalConnectors()), copy: copyConnectorRows }),
213
159
  enrollApi: { owner: () => localOwner(), invalidate: invalidateOrgConnectorSync },
214
160
  relayApi,
215
161
  claudeApi: { liveEvents: deps.liveEvents },
@@ -2,14 +2,8 @@ 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 {
6
- handleConnectorApiRequest,
7
- type ConnectorApiDeps,
8
- } from '../connectors/api.js';
9
5
  import type { RelayApiDeps } from '../connectors/relay.js';
10
- import type { ConnectorTransfers } from '../connectors/transfer-api.js';
11
6
  import { handleConnectorSyncRequest, type ConnectorSyncApiDeps } from '../connectors/org-sync-api.js';
12
- import { handleMigrationRequest, type MigrationApiDeps } from '../connectors/migration-api.js';
13
7
  import { handleEnrollRequest, type EnrollApiDeps } from '../connectors/enroll-api.js';
14
8
  import { handleClaudeRequest, type ClaudeApiDeps } from '../claude/api.js';
15
9
  import type { ModeInfo } from '@metro-labs/http/mode-api';
@@ -38,11 +32,8 @@ export interface SessionApis {
38
32
  gateway?: GatewayDeps;
39
33
  terminalApi?: TerminalApiDeps;
40
34
  claudeApi?: ClaudeApiDeps;
41
- connectorApi?: ConnectorApiDeps;
42
- connectorTransfers?: ConnectorTransfers;
43
35
  enrollApi?: EnrollApiDeps;
44
36
  connectorSyncApi?: ConnectorSyncApiDeps;
45
- connectorMigrationApi?: MigrationApiDeps;
46
37
  relayApi?: RelayApiDeps;
47
38
  mode?: () => ModeInfo;
48
39
  }
@@ -59,11 +50,8 @@ export function handleSessionApis(
59
50
  () => handleSessionApiRequest(req, res),
60
51
  () => handleRunEventsRequest(req, res),
61
52
  () => handleApprovalsRequest(req, res),
62
- ...when(apis.connectorTransfers, (d) => d.handle(req, res)),
63
53
  ...when(apis.enrollApi, (d) => handleEnrollRequest(req, res, d)),
64
54
  ...when(apis.connectorSyncApi, (d) => handleConnectorSyncRequest(req, res, d)),
65
- ...when(apis.connectorMigrationApi, (d) => handleMigrationRequest(req, res, d)),
66
- ...when(apis.connectorApi, (d) => handleConnectorApiRequest(req, res, d)),
67
55
  ...when(apis.bundleApi, (d) => handleBundleRequest(req, res, d)),
68
56
  ...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
69
57
  ...when(apis.controlApi, (d) => handleControlRequest(req, res, d)),
@@ -39,13 +39,6 @@ export interface LoadedAccount {
39
39
  config: Record<string, unknown>;
40
40
  }
41
41
 
42
- export interface LoadedConnector {
43
- id: string;
44
- name: string;
45
- url: string;
46
- config: Record<string, unknown>;
47
- }
48
-
49
42
  export interface LoadedAgent {
50
43
  id: string;
51
44
  name: string;
@@ -24,7 +24,8 @@
24
24
  "./connector-input": "./src/connector-input.ts",
25
25
  "./connector-delivery": "./src/connector-delivery.ts",
26
26
  "./connector-migration": "./src/connector-migration.ts",
27
- "./connector-grant": "./src/connector-grant.ts"
27
+ "./connector-grant": "./src/connector-grant.ts",
28
+ "./mcp-server-protocol": "./src/mcp-server-protocol.ts"
28
29
  },
29
30
  "scripts": {
30
31
  "build": "tsc",
@@ -9,6 +9,7 @@ export interface RemoteTool {
9
9
  title: string;
10
10
  description: string;
11
11
  readOnly: boolean;
12
+ inputSchema?: Record<string, unknown>;
12
13
  }
13
14
 
14
15
  const TIMEOUT_MS = 15_000;
@@ -39,7 +40,7 @@ function toTool(raw: unknown): RemoteTool | null {
39
40
  if (!isRecord(raw) || typeof raw.name !== 'string') return null;
40
41
  const annotations = isRecord(raw.annotations) ? raw.annotations : {};
41
42
  const title = typeof raw.title === 'string' && raw.title !== '' ? raw.title : raw.name;
42
- return { name: raw.name, title, description: typeof raw.description === 'string' ? raw.description : '', readOnly: annotations.readOnlyHint === true };
43
+ return { name: raw.name, title, description: typeof raw.description === 'string' ? raw.description : '', readOnly: annotations.readOnlyHint === true, ...(isRecord(raw.inputSchema) ? { inputSchema: raw.inputSchema } : {}) };
43
44
  }
44
45
 
45
46
  async function endSession(url: URL, auth: ConnectorAuth, session: Map<string, string>, fetchImpl?: FetchLike): Promise<void> {
@@ -0,0 +1,81 @@
1
+ import { isRecord } from '@metro-labs/core/is-record';
2
+
3
+ export const MCP_PATHS = { token: '/v1/token', approvals: '/v1/approvals', answer: '/v1/approvals/answer' } as const;
4
+ export const MCP_FIND_DEFAULT = 5;
5
+ export const MCP_FIND_MAX = 20;
6
+ export const MCP_CALLS_MAX = 10;
7
+ export const MCP_QUERY_MAX = 500;
8
+ export const MCP_APPROVAL_WAIT_MS = 25_000;
9
+ export const MCP_APPROVAL_ID_RE = /^[a-km-z]{5}$/;
10
+ export const MCP_TOKEN_RE = /^mcp_[A-Za-z0-9_-]{43}$/;
11
+
12
+ export type McpErrorCode = 'blocked' | 'needs_approval' | 'denied' | 'not_found' | 'signin' | 'unavailable' | 'vendor_error' | 'invalid';
13
+
14
+ export interface McpFindRequest {
15
+ query: string;
16
+ max_results: number;
17
+ }
18
+
19
+ export interface McpCall {
20
+ tool: string;
21
+ arguments: Record<string, unknown>;
22
+ approval?: string;
23
+ }
24
+
25
+ export interface McpApproval {
26
+ id: string;
27
+ connector: string;
28
+ tool: string;
29
+ preview: string;
30
+ requestedAt: number;
31
+ }
32
+
33
+ export type McpDecision = 'allow' | 'deny';
34
+
35
+ const TOOL_RE = /^([a-z0-9][a-z0-9-]{0,62})\.([A-Za-z0-9_][A-Za-z0-9_.:/-]{0,127})$/;
36
+
37
+ export function splitMcpTool(name: string): { connector: string; tool: string } | null {
38
+ const match = TOOL_RE.exec(name);
39
+ return match === null ? null : { connector: match[1] ?? '', tool: match[2] ?? '' };
40
+ }
41
+
42
+ 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('find_tools needs a query of 1 to 500 characters.');
44
+ const wanted = raw.max_results ?? MCP_FIND_DEFAULT;
45
+ if (typeof wanted !== 'number' || !Number.isInteger(wanted) || wanted < 1) throw new Error('max_results must be a whole number from 1.');
46
+ return { query: raw.query.trim(), max_results: Math.min(wanted, MCP_FIND_MAX) };
47
+ }
48
+
49
+ function callOf(raw: unknown): McpCall {
50
+ if (!isRecord(raw) || typeof raw.tool !== 'string' || splitMcpTool(raw.tool) === null) throw new Error('Each call needs a tool named <connector>.<tool>, as find_tools returns it.');
51
+ const args = raw.arguments ?? {};
52
+ if (!isRecord(args)) throw new Error(`The arguments of ${raw.tool} must be an object.`);
53
+ 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
+ return { tool: raw.tool, arguments: args, ...(typeof raw.approval === 'string' ? { approval: raw.approval } : {}) };
55
+ }
56
+
57
+ export function mcpCallsOf(raw: unknown): McpCall[] {
58
+ const calls = isRecord(raw) ? raw.calls : undefined;
59
+ if (!Array.isArray(calls) || calls.length === 0 || calls.length > MCP_CALLS_MAX) throw new Error(`call_tool needs 1 to ${String(MCP_CALLS_MAX)} calls.`);
60
+ return calls.map(callOf);
61
+ }
62
+
63
+ export function mcpTokenHashOf(raw: unknown): string {
64
+ if (!isRecord(raw) || typeof raw.tokenHash !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(raw.tokenHash)) throw new Error('tokenHash must be the base64url SHA-256 of the MCP token.');
65
+ return raw.tokenHash;
66
+ }
67
+
68
+ function approvalOf(raw: unknown): McpApproval | null {
69
+ if (!isRecord(raw) || typeof raw.id !== 'string' || !MCP_APPROVAL_ID_RE.test(raw.id) || typeof raw.connector !== 'string' || typeof raw.tool !== 'string' || typeof raw.preview !== 'string' || typeof raw.requestedAt !== 'number') return null;
70
+ return { id: raw.id, connector: raw.connector, tool: raw.tool, preview: raw.preview, requestedAt: raw.requestedAt };
71
+ }
72
+
73
+ export function mcpApprovalsOf(raw: unknown): McpApproval[] {
74
+ if (!isRecord(raw) || !Array.isArray(raw.approvals)) throw new Error('Mcp sent an unreadable approval list.');
75
+ return raw.approvals.map(approvalOf).filter((row): row is McpApproval => row !== null);
76
+ }
77
+
78
+ export function mcpAnswerOf(raw: unknown): { id: string; decision: McpDecision } {
79
+ if (!isRecord(raw) || typeof raw.id !== 'string' || !MCP_APPROVAL_ID_RE.test(raw.id) || (raw.decision !== 'allow' && raw.decision !== 'deny')) throw new Error('An answer needs the approval id and allow or deny.');
80
+ return { id: raw.id, decision: raw.decision };
81
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.302"
2
+ "version": "0.1.0-beta.304"
3
3
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.302",
2
+ "version": "0.1.0-beta.304",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",