@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
|
@@ -1,28 +1,16 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
-
import { whyUnreachable } from '@metro-labs/http/connector-reach';
|
|
3
|
-
import { noteHealth } from './health.js';
|
|
4
|
-
import type { RelayTarget } from './relay-target.js';
|
|
5
2
|
import type { RelaySession } from './relay-session.js';
|
|
6
|
-
import { isRelaySse, passiveRelayListener, type PassiveSseListener } from './relay-passive.js';
|
|
7
|
-
import { isOrgRelayId } from './relay-id.js';
|
|
8
3
|
import { exchangeOrg } from './org-relay.js';
|
|
9
|
-
import { assertLocalMigrationUse, migrationSource, withLocalMigrationOperation } from './migration-guard.js';
|
|
10
|
-
import { agentsDir } from '../agents/files.js';
|
|
11
|
-
import { migrationRequestEffectful, type MigrationOperation } from './migration-operations.js';
|
|
12
4
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
13
|
-
import {
|
|
14
|
-
import { answerBlocked, mergeBlocked
|
|
5
|
+
import { log } from '@metro-labs/core/log';
|
|
6
|
+
import { answerBlocked, mergeBlocked } from './relay-policy.js';
|
|
15
7
|
|
|
16
8
|
export interface RelayApiDeps {
|
|
17
|
-
migrationDir?: string;
|
|
18
|
-
target: (connectorId: string, force: boolean) => Promise<RelayTarget>;
|
|
19
9
|
identify: (req: IncomingMessage) => { subject: string; agentId: string } | null;
|
|
20
|
-
|
|
21
|
-
blocked?: BlockedReason;
|
|
22
|
-
open?: (connectorId: string) => RelaySession | Promise<RelaySession> | null;
|
|
10
|
+
open: (connectorId: string) => RelaySession | Promise<RelaySession>;
|
|
23
11
|
}
|
|
24
12
|
|
|
25
|
-
const ID_PATH_RE = /^\/relay\/(
|
|
13
|
+
const ID_PATH_RE = /^\/relay\/(org-[A-Za-z0-9][A-Za-z0-9_-]{10})$/;
|
|
26
14
|
const METHODS = new Set(['POST', 'GET', 'DELETE']);
|
|
27
15
|
const RELAY_BODY_MAX = 8 * 1024 * 1024;
|
|
28
16
|
const FIRST_BYTE_MS = 120_000;
|
|
@@ -43,8 +31,7 @@ const PASS_RES = [
|
|
|
43
31
|
const keepaliveMs = (): number =>
|
|
44
32
|
Number(process.env.METRO_RELAY_KEEPALIVE_MS) || 25_000;
|
|
45
33
|
|
|
46
|
-
const
|
|
47
|
-
`https://metro.box/#/connector/${id}`;
|
|
34
|
+
const isRelaySse = (contentType: string | undefined): boolean => /^text\/event-stream\s*(?:;|$)/i.test(contentType ?? '');
|
|
48
35
|
|
|
49
36
|
function answer(
|
|
50
37
|
res: ServerResponse,
|
|
@@ -56,13 +43,6 @@ function answer(
|
|
|
56
43
|
res.end(JSON.stringify(body));
|
|
57
44
|
}
|
|
58
45
|
|
|
59
|
-
const signinAnswer = (res: ServerResponse, id: string): void => {
|
|
60
|
-
answer(res, 424, {
|
|
61
|
-
error: 'this connector needs signing in again',
|
|
62
|
-
reconnect: reconnectUrl(id),
|
|
63
|
-
});
|
|
64
|
-
};
|
|
65
|
-
|
|
66
46
|
function closeIfBodyUnread(req: IncomingMessage, res: ServerResponse): void {
|
|
67
47
|
if (req.readableEnded) return;
|
|
68
48
|
const drop = (): void => {
|
|
@@ -108,10 +88,9 @@ async function forward(
|
|
|
108
88
|
target: { url: string; headers: Record<string, string> },
|
|
109
89
|
body: Uint8Array<ArrayBuffer> | null,
|
|
110
90
|
signal: AbortSignal,
|
|
111
|
-
sensitive = false,
|
|
112
91
|
): Promise<Response> {
|
|
113
92
|
const firstByte = setTimeout(() => {
|
|
114
|
-
log.warn(
|
|
93
|
+
log.warn('relay: upstream sent no response in time');
|
|
115
94
|
}, FIRST_BYTE_MS);
|
|
116
95
|
firstByte.unref?.();
|
|
117
96
|
try {
|
|
@@ -132,12 +111,12 @@ interface Keepalive {
|
|
|
132
111
|
stop: () => void;
|
|
133
112
|
}
|
|
134
113
|
|
|
135
|
-
function startKeepalive(res: ServerResponse
|
|
114
|
+
function startKeepalive(res: ServerResponse): Keepalive {
|
|
136
115
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
137
116
|
const arm = (): void => {
|
|
138
117
|
timer = setTimeout(() => {
|
|
139
118
|
try {
|
|
140
|
-
|
|
119
|
+
res.write(': keepalive\n\n');
|
|
141
120
|
} catch {
|
|
142
121
|
log.debug('relay: keepalive write failed');
|
|
143
122
|
}
|
|
@@ -173,17 +152,13 @@ async function pumpBody(
|
|
|
173
152
|
res: ServerResponse,
|
|
174
153
|
body: ReadableStream<Uint8Array>,
|
|
175
154
|
touch: () => void,
|
|
176
|
-
listener?: PassiveSseListener,
|
|
177
155
|
): Promise<void> {
|
|
178
156
|
const reader = body.getReader();
|
|
179
157
|
for (;;) {
|
|
180
158
|
const { done, value } = await reader.read();
|
|
181
159
|
if (done) return;
|
|
182
|
-
listener?.observe(value);
|
|
183
160
|
touch();
|
|
184
|
-
if (listener !== undefined) listener.forwarding = true;
|
|
185
161
|
if (!res.write(value)) await drainedOrClosed(res);
|
|
186
|
-
if (listener !== undefined) listener.forwarding = false;
|
|
187
162
|
}
|
|
188
163
|
}
|
|
189
164
|
|
|
@@ -196,7 +171,7 @@ function passedHeaders(upstream: Response): Record<string, string> {
|
|
|
196
171
|
return headers;
|
|
197
172
|
}
|
|
198
173
|
|
|
199
|
-
async function pipe(res: ServerResponse, upstream: Response, answers: Record<string, unknown>[]
|
|
174
|
+
async function pipe(res: ServerResponse, upstream: Response, answers: Record<string, unknown>[]): Promise<void> {
|
|
200
175
|
const headers = passedHeaders(upstream);
|
|
201
176
|
if (answers.length > 0 && (upstream.status === 200 || upstream.status === 202)) {
|
|
202
177
|
await mergeBlocked(res, upstream, headers, answers);
|
|
@@ -210,46 +185,38 @@ async function pipe(res: ServerResponse, upstream: Response, answers: Record<str
|
|
|
210
185
|
res.end();
|
|
211
186
|
return;
|
|
212
187
|
}
|
|
213
|
-
const keepalive = sse ? startKeepalive(res
|
|
188
|
+
const keepalive = sse ? startKeepalive(res) : null;
|
|
214
189
|
try {
|
|
215
|
-
await pumpBody(res, upstream.body, () => keepalive?.touch()
|
|
190
|
+
await pumpBody(res, upstream.body, () => keepalive?.touch());
|
|
216
191
|
} finally {
|
|
217
192
|
keepalive?.stop();
|
|
218
193
|
}
|
|
219
194
|
res.end();
|
|
220
195
|
}
|
|
221
196
|
|
|
222
|
-
|
|
223
|
-
status
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
197
|
+
async function deliver(res: ServerResponse, upstream: Response, answers: Record<string, unknown>[]): Promise<void> {
|
|
198
|
+
if (upstream.status >= 300 && upstream.status < 400) {
|
|
199
|
+
await upstream.body?.cancel();
|
|
200
|
+
answer(res, 502, { error: 'the connector redirected; metro does not follow' });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
await pipe(res, upstream, answers);
|
|
204
|
+
}
|
|
229
205
|
|
|
230
|
-
async function
|
|
206
|
+
async function relayOnce(
|
|
231
207
|
req: IncomingMessage,
|
|
208
|
+
res: ServerResponse,
|
|
232
209
|
connectorId: string,
|
|
233
210
|
deps: RelayApiDeps,
|
|
234
|
-
body: Uint8Array<ArrayBuffer> | null,
|
|
235
211
|
signal: AbortSignal,
|
|
236
|
-
|
|
237
|
-
):
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
await upstream.body?.cancel();
|
|
245
|
-
assertLocalMigrationUse(connectorId, deps.migrationDir);
|
|
246
|
-
const fresh = await deps.target(connectorId, true);
|
|
247
|
-
assertLocalMigrationUse(connectorId, deps.migrationDir);
|
|
248
|
-
if (fresh.kind !== 'ok') return { kind: 'signin' };
|
|
249
|
-
upstream = await dispatch(() => forward(req, fresh, body, signal), { effectful: migrationRequestEffectful(req.method, body) });
|
|
250
|
-
if (!authFailed(upstream.status)) return { kind: 'response', upstream };
|
|
251
|
-
await upstream.body?.cancel();
|
|
252
|
-
return { kind: 'signin' };
|
|
212
|
+
): Promise<void> {
|
|
213
|
+
const read = req.method === 'POST' ? await readCapped(req) : null;
|
|
214
|
+
const session = await deps.open(connectorId);
|
|
215
|
+
try {
|
|
216
|
+
const out = await exchangeOrg(connectorId, session, read, signal, (target, body, guard) => forward(req, target, body, guard));
|
|
217
|
+
if (out.kind === 'blocked') answerBlocked(res, out.screened);
|
|
218
|
+
else await deliver(res, out.upstream, out.answers);
|
|
219
|
+
} finally { session.close(); }
|
|
253
220
|
}
|
|
254
221
|
|
|
255
222
|
async function relayExchange(
|
|
@@ -268,7 +235,7 @@ async function relayExchange(
|
|
|
268
235
|
res.socket?.removeListener('close', bail);
|
|
269
236
|
});
|
|
270
237
|
try {
|
|
271
|
-
await relayOnce(req, res, connectorId, deps, control);
|
|
238
|
+
await relayOnce(req, res, connectorId, deps, control.signal);
|
|
272
239
|
} catch (err) {
|
|
273
240
|
if (!control.signal.aborted) throw err;
|
|
274
241
|
log.debug({ connector: connectorId }, 'relay: the client left before the answer ended');
|
|
@@ -276,71 +243,6 @@ async function relayExchange(
|
|
|
276
243
|
}
|
|
277
244
|
}
|
|
278
245
|
|
|
279
|
-
async function deliver(
|
|
280
|
-
res: ServerResponse,
|
|
281
|
-
connectorId: string,
|
|
282
|
-
deps: RelayApiDeps,
|
|
283
|
-
out: Exchanged,
|
|
284
|
-
answers: Record<string, unknown>[],
|
|
285
|
-
listener?: PassiveSseListener,
|
|
286
|
-
): Promise<void> {
|
|
287
|
-
if (out.kind === 'missing') {
|
|
288
|
-
answer(res, 404, { error: 'no such connector' });
|
|
289
|
-
return;
|
|
290
|
-
}
|
|
291
|
-
if (out.kind === 'signin') {
|
|
292
|
-
noteHealth(connectorId, false, 'the sign-in has expired, connect it again');
|
|
293
|
-
deps.signedOut(connectorId);
|
|
294
|
-
signinAnswer(res, connectorId);
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
if (out.upstream.status >= 300 && out.upstream.status < 400) {
|
|
298
|
-
await out.upstream.body?.cancel();
|
|
299
|
-
noteHealth(connectorId, false, 'the connector redirected; metro does not follow');
|
|
300
|
-
answer(res, 502, { error: 'the connector redirected; metro does not follow' });
|
|
301
|
-
return;
|
|
302
|
-
}
|
|
303
|
-
const { status } = out.upstream;
|
|
304
|
-
noteHealth(connectorId, status < 400, status < 400 ? null : `the connector answered ${String(status)}`);
|
|
305
|
-
await pipe(res, out.upstream, answers, listener);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
async function relayOrg(req: IncomingMessage, res: ServerResponse, id: string, deps: RelayApiDeps, read: Uint8Array<ArrayBuffer> | null, signal: AbortSignal): Promise<void> {
|
|
309
|
-
const session = await deps.open?.(id);
|
|
310
|
-
if (session === undefined || session === null) throw new ApiError('Organization connector tools are not switched on yet.', 503);
|
|
311
|
-
try {
|
|
312
|
-
const out = await exchangeOrg(id, session, read, signal, (target, body, guard) => {
|
|
313
|
-
const send = () => forward(req, target, body, guard, true);
|
|
314
|
-
return session.dispatch?.(send, migrationRequestEffectful(req.method, body)) ?? send();
|
|
315
|
-
});
|
|
316
|
-
if (out.kind === 'blocked') answerBlocked(res, out.screened);
|
|
317
|
-
else await deliver(res, id, deps, out, out.answers);
|
|
318
|
-
} finally { session.close(); }
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
async function relayOnce(
|
|
322
|
-
req: IncomingMessage,
|
|
323
|
-
res: ServerResponse,
|
|
324
|
-
connectorId: string,
|
|
325
|
-
deps: RelayApiDeps,
|
|
326
|
-
control: AbortController,
|
|
327
|
-
): Promise<void> {
|
|
328
|
-
const { signal } = control;
|
|
329
|
-
const read = req.method === 'POST' ? await readCapped(req) : null;
|
|
330
|
-
const dir = deps.migrationDir ?? agentsDir();
|
|
331
|
-
if (isOrgRelayId(connectorId) || migrationSource(connectorId, dir)?.phase === 'active') return relayOrg(req, res, connectorId, deps, read, signal);
|
|
332
|
-
await withLocalMigrationOperation(connectorId, dir, async (operation) => {
|
|
333
|
-
const screened = read === null || deps.blocked === undefined ? null : screenCalls(read, connectorId, deps.blocked);
|
|
334
|
-
if (screened?.forward === null) {
|
|
335
|
-
answerBlocked(res, screened);
|
|
336
|
-
return;
|
|
337
|
-
}
|
|
338
|
-
const out = await exchange(req, connectorId, deps, screened === null ? read : screened.forward, signal, operation.dispatch);
|
|
339
|
-
const listener = out.kind === 'response' ? passiveRelayListener(req, res, out.upstream, operation, () => { control.abort(); }) : undefined;
|
|
340
|
-
await deliver(res, connectorId, deps, out, screened?.answers ?? [], listener);
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
|
|
344
246
|
function dispatch(
|
|
345
247
|
req: IncomingMessage,
|
|
346
248
|
res: ServerResponse,
|
|
@@ -354,19 +256,12 @@ function dispatch(
|
|
|
354
256
|
}
|
|
355
257
|
relayExchange(req, res, connectorId, deps)
|
|
356
258
|
.catch((err: unknown) => {
|
|
357
|
-
if (err instanceof ApiError) {
|
|
358
|
-
|
|
359
|
-
if (!res.writableEnded) res.end();
|
|
360
|
-
} else if (isOrgRelayId(connectorId)) {
|
|
259
|
+
if (err instanceof ApiError) answer(res, err.status, { error: err.message });
|
|
260
|
+
else {
|
|
361
261
|
log.warn({ connector: connectorId }, 'organization relay: request failed');
|
|
362
262
|
answer(res, 503, { error: 'The organization connector is unavailable. Try again.' });
|
|
363
|
-
if (!res.writableEnded) res.end();
|
|
364
|
-
} else {
|
|
365
|
-
log.warn({ err: errMsg(err), connector: connectorId }, 'relay: failed');
|
|
366
|
-
noteHealth(connectorId, false, `metro could not reach the connector: ${whyUnreachable(err)}`);
|
|
367
|
-
answer(res, 502, { error: `metro could not reach the connector: ${whyUnreachable(err)}` });
|
|
368
|
-
if (!res.writableEnded) res.end();
|
|
369
263
|
}
|
|
264
|
+
if (!res.writableEnded) res.end();
|
|
370
265
|
closeIfBodyUnread(req, res);
|
|
371
266
|
});
|
|
372
267
|
}
|
|
@@ -33,14 +33,27 @@ function parseKeys(text: string): Map<string, string> {
|
|
|
33
33
|
return keys;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
36
|
+
const sameKeys = (a: ReadonlyMap<string, string>, b: ReadonlyMap<string, string>): boolean =>
|
|
37
|
+
a.size === b.size && [...a].every(([id, key]) => b.get(id) === key);
|
|
38
|
+
|
|
39
|
+
function serverKeyFiles(dir = agentsDir()): { path: string; marker: string } {
|
|
40
|
+
return { path: join(dir, 'connector-server-keys.json'), marker: join(dir, 'connector-server-keys.initialized') };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function readServerKeys(dir = agentsDir()): Map<string, string> | undefined {
|
|
44
|
+
const { path, marker } = serverKeyFiles(dir);
|
|
45
|
+
return readKeys(path, marker);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function writeServerKeys(keys: ReadonlyMap<string, string>, dir = agentsDir()): void {
|
|
49
|
+
const { path, marker } = serverKeyFiles(dir);
|
|
50
|
+
if (!existsSync(marker)) writeSecure(marker, '1\n');
|
|
51
|
+
writeSecure(path, `${JSON.stringify({ version: 1, keys: Object.fromEntries(keys) }, null, 2)}\n`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function persistentServerKeys(rows: PluginRow[], dir = agentsDir()): Map<string, string> {
|
|
55
|
+
const saved = readServerKeys(dir);
|
|
56
|
+
const keys = serverKeysOf(rows, saved);
|
|
57
|
+
if (saved === undefined ? keys.size > 0 : !sameKeys(saved, keys)) writeServerKeys(keys, dir);
|
|
45
58
|
return keys;
|
|
46
59
|
}
|
|
@@ -19,6 +19,7 @@ import { errMsg } from '@metro-labs/core/log';
|
|
|
19
19
|
import { channelLog, type McpSession } from './session.js';
|
|
20
20
|
import { SessionSlot } from './session-slot.js';
|
|
21
21
|
import { connectCallBridge } from './call-bridge.js';
|
|
22
|
+
import { connectServerApprovals } from './server-approvals.js';
|
|
22
23
|
import { handleIncomingDiagnostic } from './diagnostics.js';
|
|
23
24
|
|
|
24
25
|
const isInitialize = (b: unknown): boolean =>
|
|
@@ -120,6 +121,7 @@ export async function createMetroMcp(options: { liveEvents?: boolean } = {}): Pr
|
|
|
120
121
|
const voiceSlot = new SessionSlot(false, 'voice');
|
|
121
122
|
activeSlots = [slot, voiceSlot];
|
|
122
123
|
connectCallBridge(() => slot.current);
|
|
124
|
+
connectServerApprovals(() => slot.current);
|
|
123
125
|
|
|
124
126
|
const httpHandler = async (
|
|
125
127
|
req: IncomingMessage,
|
|
@@ -75,15 +75,16 @@ function metroLines(tool: string, input: Record<string, unknown>): string[] {
|
|
|
75
75
|
|
|
76
76
|
const ARGS_MAX = 8;
|
|
77
77
|
|
|
78
|
-
function
|
|
79
|
-
const lines = [`Approval needed: ${tool}`, `Connector: ${name}`];
|
|
78
|
+
function argLines(input: Record<string, unknown> | undefined): string[] {
|
|
80
79
|
const args = Object.entries(input ?? {}).flatMap(([key, value]) => {
|
|
81
80
|
const text = argText(value);
|
|
82
81
|
return text === undefined ? [] : [`${key}: ${shorten(text)}`];
|
|
83
82
|
});
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
return args.length > ARGS_MAX ? [...args.slice(0, ARGS_MAX), `(${String(args.length - ARGS_MAX)} more)`] : args;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function connectorLines(name: string, tool: string, input: Record<string, unknown> | undefined): string[] {
|
|
87
|
+
return [`Approval needed: ${tool}`, `Connector: ${name}`, ...argLines(input)];
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
function bodyLines(params: PermissionParams): string[] {
|
|
@@ -100,9 +101,27 @@ function bodyLines(params: PermissionParams): string[] {
|
|
|
100
101
|
return [`Claude wants to run ${params.tool_name}: ${shorten(params.description)}`, '', ...raw];
|
|
101
102
|
}
|
|
102
103
|
|
|
103
|
-
|
|
104
|
-
const answer = `Reply "yes ${
|
|
105
|
-
const body =
|
|
104
|
+
function withAnswer(requestId: string, lines: string[]): string {
|
|
105
|
+
const answer = `Reply "yes ${requestId}" or "no ${requestId}"`;
|
|
106
|
+
const body = lines.join('\n');
|
|
106
107
|
const room = PROMPT_MAX - answer.length - 3;
|
|
107
108
|
return `${body.length > room ? `${body.slice(0, room)}…` : body}\n\n${answer}`;
|
|
108
109
|
}
|
|
110
|
+
|
|
111
|
+
export function promptBody(params: PermissionParams): string {
|
|
112
|
+
return withAnswer(params.request_id, bodyLines(params));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface ServerPrompt {
|
|
116
|
+
requestId: string;
|
|
117
|
+
tool: string;
|
|
118
|
+
connector: string | undefined;
|
|
119
|
+
preview: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function serverPromptBody(prompt: ServerPrompt): string {
|
|
123
|
+
const { input } = readPreview(prompt.preview);
|
|
124
|
+
const raw = prompt.preview.trim() === '' ? [] : [shorten(prompt.preview, RAW_MAX)];
|
|
125
|
+
const args = input === undefined ? raw : argLines(input);
|
|
126
|
+
return withAnswer(prompt.requestId, [`Approval needed: ${prompt.tool}`, ...(prompt.connector === undefined ? [] : [`Connector: ${prompt.connector}`]), ...args]);
|
|
127
|
+
}
|
|
@@ -24,7 +24,7 @@ type PermissionRequest = z.infer<typeof PermissionRequestSchema>;
|
|
|
24
24
|
|
|
25
25
|
const trainOf = (line: string): string => line.split('/')[2] ?? '';
|
|
26
26
|
|
|
27
|
-
async function metroSend(line: string, text: string): Promise<void> {
|
|
27
|
+
export async function metroSend(line: string, text: string): Promise<void> {
|
|
28
28
|
await metroCall(trainOf(line), 'send', { line, text });
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -36,15 +36,17 @@ export interface PermissionRelayDeps {
|
|
|
36
36
|
log: (...a: unknown[]) => void;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
export type PromptLineDeps = Pick<PermissionRelayDeps, 'relay' | 'inScope' | 'live' | 'log'>;
|
|
40
|
+
|
|
41
|
+
const callChat = (deps: PromptLineDeps): string | undefined => {
|
|
40
42
|
const route = sharedCalls.snapshot();
|
|
41
43
|
return route !== null && deps.inScope(route.line) ? route.line : undefined;
|
|
42
44
|
};
|
|
43
45
|
|
|
44
|
-
const ownerChat = (deps:
|
|
46
|
+
const ownerChat = (deps: PromptLineDeps, owner: OwnerLine | undefined): string | undefined =>
|
|
45
47
|
owner !== undefined && mayApprove(owner.line, owner.from) && deps.inScope(owner.line) && lineReceives(owner.line) ? owner.line : undefined;
|
|
46
48
|
|
|
47
|
-
function relayLine(deps:
|
|
49
|
+
export function relayLine(deps: PromptLineDeps, requestId: string, callLine?: string): string | undefined {
|
|
48
50
|
if (!deps.live()) {
|
|
49
51
|
deps.log('permission_request: live events are off, so no chat answer can arrive; held for the page only', requestId);
|
|
50
52
|
return undefined;
|
|
@@ -0,0 +1,109 @@
|
|
|
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 { connectorGates } from '../connectors/gates.js';
|
|
8
|
+
import { serverKey } from '../connectors/plugin-sync.js';
|
|
9
|
+
import type { AnswerOutcome } from '../mcp-server/link.js';
|
|
10
|
+
import { serverPromptBody } from './permission-prompt.js';
|
|
11
|
+
import { metroSend, relayLine } from './permission-relay.js';
|
|
12
|
+
import { channelLog, type McpSession } from './session.js';
|
|
13
|
+
|
|
14
|
+
const RECENT_MS = 10 * 60_000;
|
|
15
|
+
const RECENT_MAX = 1_000;
|
|
16
|
+
|
|
17
|
+
const owner = {};
|
|
18
|
+
const recent = new Map<string, number>();
|
|
19
|
+
let current: () => McpSession | undefined = () => undefined;
|
|
20
|
+
|
|
21
|
+
export function connectServerApprovals(session: () => McpSession | undefined): void {
|
|
22
|
+
current = session;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function promptLineFor(requestId: string): string | undefined {
|
|
26
|
+
const session = current();
|
|
27
|
+
if (session === undefined) return undefined;
|
|
28
|
+
return relayLine({ relay: session.relay, inScope: (line) => session.inScope(line), live: session.live, log: channelLog }, requestId);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const noticeLineFor = (line: string | undefined): string | undefined => line ?? current()?.relay.knownLine ?? ownerLine()?.line;
|
|
32
|
+
|
|
33
|
+
function remember(id: string, now = Date.now()): void {
|
|
34
|
+
for (const [seen, at] of recent) if (now - at >= RECENT_MS) recent.delete(seen);
|
|
35
|
+
recent.set(id, now);
|
|
36
|
+
while (recent.size > RECENT_MAX) {
|
|
37
|
+
const oldest = recent.keys().next();
|
|
38
|
+
if (oldest.done) break;
|
|
39
|
+
recent.delete(oldest.value);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const answeredRecently = (id: string, now = Date.now()): boolean => now - (recent.get(id) ?? -Infinity) < RECENT_MS;
|
|
44
|
+
|
|
45
|
+
function connectorName(key: string): string | undefined {
|
|
46
|
+
const names = new Set(connectorGates().filter((gate) => gate.server === key || serverKey(gate.name) === key).map((gate) => gate.name));
|
|
47
|
+
return names.size === 1 ? [...names][0] : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const noticeText = (tool: string, id: string, behavior: Behavior): string =>
|
|
51
|
+
behavior === 'allow' ? `Approved: ${tool} (${id}). Call call_tool again with the same arguments and approval: "${id}".` : `Denied: ${tool} (${id}).`;
|
|
52
|
+
|
|
53
|
+
const requestedAt = (at: number, now = Date.now()): number => (Number.isFinite(at) && at > 0 ? Math.min(at, now) : now);
|
|
54
|
+
|
|
55
|
+
interface Held {
|
|
56
|
+
id: string;
|
|
57
|
+
tool: string;
|
|
58
|
+
line: string | undefined;
|
|
59
|
+
answer: (decision: McpDecision) => Promise<AnswerOutcome>;
|
|
60
|
+
publish: (event: MetroEvent) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function deliver(held: Held, behavior: Behavior): Promise<void> {
|
|
64
|
+
const outcome = await held.answer(behavior);
|
|
65
|
+
if (outcome !== 'acknowledged') {
|
|
66
|
+
log.info({ id: held.id, outcome }, 'mcp-server: the answer was not taken, so the agent is not told');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const line = noticeLineFor(held.line);
|
|
70
|
+
if (line === undefined) {
|
|
71
|
+
log.warn({ id: held.id }, 'mcp-server: no chat is known to tell the agent the answer');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
held.publish(metroNotice(line, noticeText(held.tool, held.id, behavior)));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sender(held: Held): (behavior: Behavior) => Promise<void> {
|
|
78
|
+
return (behavior) => {
|
|
79
|
+
remember(held.id);
|
|
80
|
+
deliver(held, behavior).catch((err: unknown) => {
|
|
81
|
+
log.warn({ id: held.id, err: errMsg(err) }, 'mcp-server: the answer could not be delivered');
|
|
82
|
+
});
|
|
83
|
+
return Promise.resolve();
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function holdServerApproval(approval: McpApproval, answer: Held['answer'], publish: Held['publish']): boolean {
|
|
88
|
+
if (isPromptHeld(approval.id) || answeredRecently(approval.id)) return false;
|
|
89
|
+
const tool = `${approval.connector}.${approval.tool}`;
|
|
90
|
+
const name = connectorName(approval.connector);
|
|
91
|
+
const line = promptLineFor(approval.id);
|
|
92
|
+
const held: Held = { id: approval.id, tool, line, answer, publish };
|
|
93
|
+
holdPrompt(
|
|
94
|
+
{ requestId: approval.id, tool, description: name ?? approval.connector, preview: approval.preview, line, at: requestedAt(approval.requestedAt), origin: 'mcp-server' },
|
|
95
|
+
owner,
|
|
96
|
+
sender(held),
|
|
97
|
+
line === undefined ? undefined : async (text) => metroSend(line, text),
|
|
98
|
+
);
|
|
99
|
+
log.info({ id: approval.id, tool, inChat: line !== undefined }, 'mcp-server: an approval is waiting for the owner');
|
|
100
|
+
if (line !== undefined)
|
|
101
|
+
metroSend(line, serverPromptBody({ requestId: approval.id, tool, connector: name, preview: approval.preview })).catch((err: unknown) => {
|
|
102
|
+
log.warn({ id: approval.id, err: errMsg(err) }, 'mcp-server: the approval prompt could not be posted');
|
|
103
|
+
});
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function forgetServerApprovals(): void {
|
|
108
|
+
recent.clear();
|
|
109
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { log } from '@metro-labs/core/log';
|
|
2
|
+
|
|
3
|
+
export const MCP_SERVER_KEY = 'mcp';
|
|
4
|
+
|
|
5
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
6
|
+
|
|
7
|
+
let refused: string | undefined;
|
|
8
|
+
|
|
9
|
+
function usable(raw: string): boolean {
|
|
10
|
+
let url: URL;
|
|
11
|
+
try {
|
|
12
|
+
url = new URL(raw);
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const secure = url.protocol === 'https:' || (url.protocol === 'http:' && LOOPBACK.has(url.hostname));
|
|
17
|
+
return secure && url.username === '' && url.password === '' && url.search === '' && url.hash === '';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function mcpServerUrl(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
21
|
+
const raw = env.METRO_MCP_SERVER_URL?.trim() ?? '';
|
|
22
|
+
if (raw === '') return null;
|
|
23
|
+
if (usable(raw)) return raw.replace(/\/+$/, '');
|
|
24
|
+
if (refused !== raw) {
|
|
25
|
+
refused = raw;
|
|
26
|
+
log.warn({ url: raw }, 'mcp-server: METRO_MCP_SERVER_URL must be an https url (http only on this machine), so it is ignored');
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|