@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
@@ -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 { errMsg, log } from '@metro-labs/core/log';
14
- import { answerBlocked, mergeBlocked, screenCalls, type BlockedReason } from './relay-policy.js';
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
- signedOut: (connectorId: string) => void;
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\/((?:org-)?[A-Za-z0-9][A-Za-z0-9_-]{10})$/;
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 reconnectUrl = (id: string): string =>
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(sensitive ? {} : { url: target.url }, 'relay: upstream sent no response in time');
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, listener?: PassiveSseListener): Keepalive {
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
- if (listener?.keepaliveAllowed() !== false) res.write(': keepalive\n\n');
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>[], listener?: PassiveSseListener): Promise<void> {
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, listener) : null;
188
+ const keepalive = sse ? startKeepalive(res) : null;
214
189
  try {
215
- await pumpBody(res, upstream.body, () => keepalive?.touch(), listener);
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
- const authFailed = (status: number): boolean =>
223
- status === 401 || status === 403;
224
-
225
- type Exchanged =
226
- | { kind: 'response'; upstream: Response }
227
- | { kind: 'missing' }
228
- | { kind: 'signin' };
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 exchange(
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
- dispatch: MigrationOperation['dispatch'],
237
- ): Promise<Exchanged> {
238
- assertLocalMigrationUse(connectorId, deps.migrationDir);
239
- const target = await deps.target(connectorId, false);
240
- assertLocalMigrationUse(connectorId, deps.migrationDir);
241
- if (target.kind !== 'ok') return { kind: target.kind };
242
- let upstream = await dispatch(() => forward(req, target, body, signal), { effectful: migrationRequestEffectful(req.method, body) });
243
- if (!authFailed(upstream.status)) return { kind: 'response', upstream };
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
- answer(res, err.status, { error: err.message });
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
- export function persistentServerKeys(rows: PluginRow[], dir = agentsDir(), published: PluginRow[] = rows): Map<string, string> {
37
- const path = join(dir, 'connector-server-keys.json');
38
- const marker = join(dir, 'connector-server-keys.initialized');
39
- const saved = readKeys(path, marker);
40
- const keys = serverKeysOf(rows, saved, published);
41
- if (keys.size > 0 && keys.size !== saved?.size) {
42
- if (saved === undefined) writeSecure(marker, '1\n');
43
- writeSecure(path, `${JSON.stringify({ version: 1, keys: Object.fromEntries(keys) }, null, 2)}\n`);
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 connectorLines(name: string, tool: string, input: Record<string, unknown> | undefined): string[] {
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
- lines.push(...args.slice(0, ARGS_MAX));
85
- if (args.length > ARGS_MAX) lines.push(`(${String(args.length - ARGS_MAX)} more)`);
86
- return lines;
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
- export function promptBody(params: PermissionParams): string {
104
- const answer = `Reply "yes ${params.request_id}" or "no ${params.request_id}"`;
105
- const body = bodyLines(params).join('\n');
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
- 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;
@@ -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
+ }