@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.
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +2 -2
- 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 +20 -2
- package/runtime/node_modules/@metro-labs/daemon/src/agent-user/view.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +3 -11
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +10 -3
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +16 -8
- 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 +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +1 -11
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-adopt.ts +57 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +39 -95
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +27 -45
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +2 -17
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +12 -37
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +8 -29
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +33 -138
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +22 -9
- 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 +27 -8
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +6 -4
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +109 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +29 -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/routes/local-mode.ts +4 -58
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -12
- package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +0 -7
- package/runtime/node_modules/@metro-labs/http/package.json +2 -1
- 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/runner.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +0 -316
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +0 -105
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/health.ts +0 -18
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +0 -60
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +0 -366
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +0 -95
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +0 -226
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +0 -235
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +0 -42
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +0 -142
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-url.ts +0 -9
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +0 -178
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +0 -171
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-passive.ts +0 -96
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +0 -393
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-api.ts +0 -116
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-crypto.ts +0 -44
- 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 =
|
|
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:
|
|
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
|
+
}
|
package/runtime/runtime.json
CHANGED