@stage-labs/metro 0.1.0-beta.292 → 0.1.0-beta.294

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 (31) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/marketplace/plugin/bin/policy.mjs +4 -1
  4. package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +27 -23
  5. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +7 -3
  6. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +12 -9
  7. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +20 -0
  8. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +4 -6
  9. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +2 -2
  10. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +3 -3
  11. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +12 -6
  12. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +16 -3
  13. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +1 -1
  14. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +8 -1
  15. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +39 -12
  16. package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +22 -11
  17. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-passive.ts +96 -0
  18. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +21 -12
  19. package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +46 -0
  20. package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +10 -15
  21. package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +1 -85
  22. package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +1 -4
  23. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +6 -2
  24. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +1 -2
  25. package/runtime/node_modules/@metro-labs/http/package.json +1 -0
  26. package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +4 -3
  27. package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +2 -2
  28. package/runtime/node_modules/@metro-labs/http/src/connector-policy.ts +39 -3
  29. package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +94 -0
  30. package/runtime/runtime.json +1 -1
  31. package/runtime/sdk-runner/runner.json +1 -1
@@ -0,0 +1,96 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { JSONRPCNotificationSchema, ServerNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
3
+ import { isRecord } from '@metro-labs/core/is-record';
4
+ import type { MigrationOperation } from './migration-operations.js';
5
+
6
+ export const isRelaySse = (contentType: string | null | undefined): boolean => /^text\/event-stream\s*(?:;|$)/i.test(contentType ?? '');
7
+ const MAX_FRAME = 64 * 1024;
8
+ const NOTIFICATIONS = new Set(['notifications/tools/list_changed', 'notifications/resources/list_changed', 'notifications/prompts/list_changed', 'notifications/resources/updated', 'notifications/message']);
9
+
10
+ export class PassiveSseListener {
11
+ forwarding = false;
12
+ private pending: Buffer[] = [];
13
+ private length = 0;
14
+ private data: string[] = [];
15
+ private fields = false;
16
+ private size = 0;
17
+ private unsafe = false;
18
+ private skipLf = false;
19
+ private first = true;
20
+ private readonly decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
21
+
22
+ idle(): boolean { return !this.unsafe && this.length === 0 && !this.fields && !this.forwarding; }
23
+ keepaliveAllowed(): boolean { return this.unsafe || this.idle(); }
24
+
25
+ observe(chunk: Uint8Array): void {
26
+ if (this.unsafe) return;
27
+ this.scan(chunk);
28
+ if (this.unsafe) { this.pending = []; this.length = 0; this.data = []; }
29
+ }
30
+
31
+ private append(bytes: Uint8Array): void {
32
+ this.length += bytes.length;
33
+ if (this.length + this.size > MAX_FRAME) this.unsafe = true;
34
+ else if (bytes.length > 0) this.pending.push(Buffer.from(bytes));
35
+ }
36
+
37
+ private scan(bytes: Uint8Array): void {
38
+ let start = 0;
39
+ for (let at = 0; at < bytes.length; at += 1) {
40
+ if (at - start + this.length + this.size > MAX_FRAME) { this.unsafe = true; return; }
41
+ const byte = bytes[at];
42
+ if (this.skipLf && byte === 10) { this.skipLf = false; start = at + 1; continue; }
43
+ this.skipLf = false;
44
+ if (byte !== 10 && byte !== 13) continue;
45
+ this.append(bytes.subarray(start, at));
46
+ try { this.line(this.decoder.decode(Buffer.concat(this.pending, this.length)), this.length); }
47
+ catch { this.unsafe = true; }
48
+ this.pending = []; this.length = 0;
49
+ this.skipLf = byte === 13;
50
+ start = at + 1;
51
+ if (this.unsafe) return;
52
+ }
53
+ this.append(bytes.subarray(start));
54
+ }
55
+
56
+ private line(raw: string, length: number): void {
57
+ const line = this.first && raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
58
+ this.first = false;
59
+ if (line === '') {
60
+ if (this.data.length > 0 && !this.notification()) this.unsafe = true;
61
+ this.data = []; this.fields = false; this.size = 0;
62
+ return;
63
+ }
64
+ if (line.startsWith(':')) return;
65
+ this.size += length;
66
+ if (this.size > MAX_FRAME) { this.unsafe = true; return; }
67
+ this.fields = true;
68
+ const colon = line.indexOf(':');
69
+ const field = colon < 0 ? line : line.slice(0, colon);
70
+ const value = colon < 0 ? '' : line.slice(colon + 1).replace(/^ /, '');
71
+ this.field(field, value);
72
+ }
73
+
74
+ private field(field: string, value: string): void {
75
+ if (field === 'data') this.data.push(value);
76
+ else if (field === 'event') { if (value !== '' && value !== 'message') this.unsafe = true; }
77
+ else if (field === 'retry') { if (!/^\d+$/.test(value)) this.unsafe = true; }
78
+ else this.unsafe = true;
79
+ }
80
+
81
+ private notification(): boolean {
82
+ const text = this.data.join('\n');
83
+ if (text === '') return true;
84
+ const value: unknown = JSON.parse(text);
85
+ return isRecord(value) && typeof value.method === 'string' && NOTIFICATIONS.has(value.method)
86
+ && JSONRPCNotificationSchema.safeParse(value).success && ServerNotificationSchema.safeParse(value).success;
87
+ }
88
+ }
89
+
90
+ export function passiveRelayListener(req: IncomingMessage, res: ServerResponse, upstream: Response, operation: MigrationOperation, stop: () => void): PassiveSseListener | undefined {
91
+ if (req.method !== 'GET' || req.headers['last-event-id'] !== undefined || req.headers['transfer-encoding'] !== undefined || Number(req.headers['content-length'] ?? 0) !== 0) return undefined;
92
+ if (upstream.status !== 200 || upstream.body === null || !isRelaySse(upstream.headers.get('content-type'))) return undefined;
93
+ const listener = new PassiveSseListener();
94
+ operation.passiveListener({ idle: () => listener.idle() && res.writableLength === 0 && !res.destroyed, stop });
95
+ return listener;
96
+ }
@@ -3,6 +3,7 @@ import { whyUnreachable } from '@metro-labs/http/connector-reach';
3
3
  import { noteHealth } from './health.js';
4
4
  import type { RelayTarget } from './relay-target.js';
5
5
  import type { RelaySession } from './relay-session.js';
6
+ import { isRelaySse, passiveRelayListener, type PassiveSseListener } from './relay-passive.js';
6
7
  import { isOrgRelayId } from './relay-id.js';
7
8
  import { exchangeOrg } from './org-relay.js';
8
9
  import { assertLocalMigrationUse, migrationSource, withLocalMigrationOperation } from './migration-guard.js';
@@ -18,7 +19,7 @@ export interface RelayApiDeps {
18
19
  identify: (req: IncomingMessage) => { subject: string; agentId: string } | null;
19
20
  signedOut: (connectorId: string) => void;
20
21
  blocked?: BlockedReason;
21
- open?: (connectorId: string) => RelaySession | null;
22
+ open?: (connectorId: string) => RelaySession | Promise<RelaySession> | null;
22
23
  }
23
24
 
24
25
  const ID_PATH_RE = /^\/relay\/((?:org-)?[A-Za-z0-9][A-Za-z0-9_-]{10})$/;
@@ -131,12 +132,12 @@ interface Keepalive {
131
132
  stop: () => void;
132
133
  }
133
134
 
134
- function startKeepalive(res: ServerResponse): Keepalive {
135
+ function startKeepalive(res: ServerResponse, listener?: PassiveSseListener): Keepalive {
135
136
  let timer: ReturnType<typeof setTimeout> | undefined;
136
137
  const arm = (): void => {
137
138
  timer = setTimeout(() => {
138
139
  try {
139
- res.write(': keepalive\n\n');
140
+ if (listener?.keepaliveAllowed() !== false) res.write(': keepalive\n\n');
140
141
  } catch {
141
142
  log.debug('relay: keepalive write failed');
142
143
  }
@@ -172,13 +173,17 @@ async function pumpBody(
172
173
  res: ServerResponse,
173
174
  body: ReadableStream<Uint8Array>,
174
175
  touch: () => void,
176
+ listener?: PassiveSseListener,
175
177
  ): Promise<void> {
176
178
  const reader = body.getReader();
177
179
  for (;;) {
178
180
  const { done, value } = await reader.read();
179
181
  if (done) return;
182
+ listener?.observe(value);
180
183
  touch();
184
+ if (listener !== undefined) listener.forwarding = true;
181
185
  if (!res.write(value)) await drainedOrClosed(res);
186
+ if (listener !== undefined) listener.forwarding = false;
182
187
  }
183
188
  }
184
189
 
@@ -191,22 +196,23 @@ function passedHeaders(upstream: Response): Record<string, string> {
191
196
  return headers;
192
197
  }
193
198
 
194
- async function pipe(res: ServerResponse, upstream: Response, answers: Record<string, unknown>[]): Promise<void> {
199
+ async function pipe(res: ServerResponse, upstream: Response, answers: Record<string, unknown>[], listener?: PassiveSseListener): Promise<void> {
195
200
  const headers = passedHeaders(upstream);
196
201
  if (answers.length > 0 && (upstream.status === 200 || upstream.status === 202)) {
197
202
  await mergeBlocked(res, upstream, headers, answers);
198
203
  return;
199
204
  }
200
- const sse = (headers['content-type'] ?? '').includes('text/event-stream');
205
+ const sse = isRelaySse(headers['content-type']);
201
206
  if (sse) headers['x-accel-buffering'] = 'no';
202
207
  res.writeHead(upstream.status, headers);
208
+ if (sse) res.flushHeaders();
203
209
  if (upstream.body === null) {
204
210
  res.end();
205
211
  return;
206
212
  }
207
- const keepalive = sse ? startKeepalive(res) : null;
213
+ const keepalive = sse ? startKeepalive(res, listener) : null;
208
214
  try {
209
- await pumpBody(res, upstream.body, () => keepalive?.touch());
215
+ await pumpBody(res, upstream.body, () => keepalive?.touch(), listener);
210
216
  } finally {
211
217
  keepalive?.stop();
212
218
  }
@@ -262,7 +268,7 @@ async function relayExchange(
262
268
  res.socket?.removeListener('close', bail);
263
269
  });
264
270
  try {
265
- await relayOnce(req, res, connectorId, deps, control.signal);
271
+ await relayOnce(req, res, connectorId, deps, control);
266
272
  } catch (err) {
267
273
  if (!control.signal.aborted) throw err;
268
274
  log.debug({ connector: connectorId }, 'relay: the client left before the answer ended');
@@ -276,6 +282,7 @@ async function deliver(
276
282
  deps: RelayApiDeps,
277
283
  out: Exchanged,
278
284
  answers: Record<string, unknown>[],
285
+ listener?: PassiveSseListener,
279
286
  ): Promise<void> {
280
287
  if (out.kind === 'missing') {
281
288
  answer(res, 404, { error: 'no such connector' });
@@ -295,11 +302,11 @@ async function deliver(
295
302
  }
296
303
  const { status } = out.upstream;
297
304
  noteHealth(connectorId, status < 400, status < 400 ? null : `the connector answered ${String(status)}`);
298
- await pipe(res, out.upstream, answers);
305
+ await pipe(res, out.upstream, answers, listener);
299
306
  }
300
307
 
301
308
  async function relayOrg(req: IncomingMessage, res: ServerResponse, id: string, deps: RelayApiDeps, read: Uint8Array<ArrayBuffer> | null, signal: AbortSignal): Promise<void> {
302
- const session = deps.open?.(id);
309
+ const session = await deps.open?.(id);
303
310
  if (session === undefined || session === null) throw new ApiError('Organization connector tools are not switched on yet.', 503);
304
311
  try {
305
312
  const out = await exchangeOrg(id, session, read, signal, (target, body, guard) => {
@@ -316,8 +323,9 @@ async function relayOnce(
316
323
  res: ServerResponse,
317
324
  connectorId: string,
318
325
  deps: RelayApiDeps,
319
- signal: AbortSignal,
326
+ control: AbortController,
320
327
  ): Promise<void> {
328
+ const { signal } = control;
321
329
  const read = req.method === 'POST' ? await readCapped(req) : null;
322
330
  const dir = deps.migrationDir ?? agentsDir();
323
331
  if (isOrgRelayId(connectorId) || migrationSource(connectorId, dir)?.phase === 'active') return relayOrg(req, res, connectorId, deps, read, signal);
@@ -328,7 +336,8 @@ async function relayOnce(
328
336
  return;
329
337
  }
330
338
  const out = await exchange(req, connectorId, deps, screened === null ? read : screened.forward, signal, operation.dispatch);
331
- await deliver(res, connectorId, deps, out, screened?.answers ?? []);
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);
332
341
  });
333
342
  }
334
343
 
@@ -0,0 +1,46 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { isRecord } from '@metro-labs/core/is-record';
4
+ import { writeSecure } from '@metro-labs/core/secure-fs';
5
+ import { agentsDir } from '../agents/files.js';
6
+ import { serverKeysOf, type PluginRow } from './plugin-sync.js';
7
+
8
+ const invalid = (): Error => new Error('Invalid connector server keys. Restore the saved namespace mapping and initialization marker.');
9
+
10
+ function readKeys(path: string, marker: string): Map<string, string> | undefined {
11
+ try {
12
+ const marked = existsSync(marker);
13
+ if (marked && readFileSync(marker, 'utf8') !== '1\n') throw invalid();
14
+ if (!existsSync(path)) {
15
+ if (marked) throw invalid();
16
+ return undefined;
17
+ }
18
+ if (!marked) throw invalid();
19
+ return parseKeys(readFileSync(path, 'utf8'));
20
+ } catch { throw invalid(); }
21
+ }
22
+
23
+ function parseKeys(text: string): Map<string, string> {
24
+ const raw: unknown = JSON.parse(text);
25
+ if (!isRecord(raw) || raw.version !== 1 || !isRecord(raw.keys)) throw invalid();
26
+ const keys = new Map<string, string>();
27
+ const reserved = new Set<string>();
28
+ for (const [id, key] of Object.entries(raw.keys)) {
29
+ if (typeof key !== 'string' || !/^[a-z0-9-]+$/.test(key) || reserved.has(key)) throw invalid();
30
+ reserved.add(key);
31
+ keys.set(id, key);
32
+ }
33
+ return keys;
34
+ }
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
+ }
45
+ return keys;
46
+ }
@@ -1,6 +1,7 @@
1
1
  import { join } from 'node:path';
2
2
  import { closeSync, fsyncSync, openSync } from 'node:fs';
3
3
  import { publishLocalConnectors } from './publication.js';
4
+ import { persistentServerKeys } from './server-keys.js';
4
5
  import { assertLocalMigrationEdit, localMigrationEnabled, localMigrationIdle, migrationSource, withLocalMigrationOperation } from './migration-guard.js';
5
6
  import type { FetchLike } from '@metro-labs/core/stations/oauth';
6
7
  import type { RelayTarget } from './relay-target.js';
@@ -116,6 +117,8 @@ export function readLocalConnectors(dir = agentsDir()): LocalConnectorRow[] {
116
117
  }
117
118
 
118
119
  function writeRows(dir: string, rows: LocalConnectorRow[]): void {
120
+ persistentServerKeys(readLocalConnectors(dir), dir);
121
+ persistentServerKeys(rows, dir);
119
122
  writeSecure(filePath(dir), `${JSON.stringify({ version: 1, connectors: rows }, null, 2)}\n`);
120
123
  if (dir !== agentsDir()) return;
121
124
  publishLocalConnectors(rows);
@@ -130,7 +133,7 @@ function rowOrThrow(id: string, dir: string): LocalConnectorRow {
130
133
  }
131
134
 
132
135
  function replace(dir: string, row: LocalConnectorRow, refresh = false): Connector {
133
- if (!refresh) assertLocalMigrationEdit(row.id, row.name, dir);
136
+ if (!refresh) assertLocalMigrationEdit(row.id, dir);
134
137
  writeRows(dir, readLocalConnectors(dir).map((r) => (r.id === row.id ? row : r)));
135
138
  return connectorFromRow(row);
136
139
  }
@@ -150,17 +153,17 @@ export function localImportConnectors(
150
153
  const fresh =
151
154
  mode === 'overwrite'
152
155
  ? rows
153
- : rows.filter((row) => !current.some((r) => r.id === row.id || r.name === row.name));
156
+ : rows.filter((row) => !current.some((r) => r.id === row.id));
154
157
  const imported: LocalConnectorRow[] = fresh.map((row) => ({
155
158
  id: row.id,
156
159
  name: row.name,
157
160
  url: row.url,
158
161
  config: readConfig(row.config),
159
162
  }));
160
- for (const row of imported) assertLocalMigrationEdit(row.id, row.name, dir);
163
+ for (const row of imported) assertLocalMigrationEdit(row.id, dir);
161
164
  const ids = new Set(imported.map((r) => r.id));
162
- const names = new Set(imported.map((r) => r.name));
163
- writeRows(dir, [...current.filter((r) => !ids.has(r.id) && !names.has(r.name)), ...imported]);
165
+ if (ids.size !== imported.length) throw new ConnectorError('The import contains duplicate connector IDs.', 400);
166
+ writeRows(dir, [...current.filter((r) => !ids.has(r.id)), ...imported]);
164
167
  return imported.length;
165
168
  }
166
169
 
@@ -172,14 +175,7 @@ export async function localGetConnector(id: string, dir = agentsDir()): Promise<
172
175
  return Promise.resolve(connectorFromRow(rowOrThrow(id, dir)));
173
176
  }
174
177
 
175
- function assertNameFree(name: string, exceptId: string | null, dir: string): void {
176
- if (readLocalConnectors(dir).some((r) => r.id !== exceptId && r.name === name))
177
- throw new ConnectorError(`a connector named '${name}' already exists on this daemon`, 409);
178
- }
179
-
180
178
  function insert(dir: string, name: string, url: URL, config: ConnectorConfig): Connector {
181
- assertLocalMigrationEdit('', name, dir);
182
- assertNameFree(name, null, dir);
183
179
  const row: LocalConnectorRow = { id: newId(), name, url: connectorUrlText(url), config };
184
180
  writeRows(dir, [...readLocalConnectors(dir), row]);
185
181
  return connectorFromRow(row);
@@ -193,7 +189,7 @@ export async function localCreateConnector(input: ConnectorInput, dir = agentsDi
193
189
  return withLocalMigrationOperation('', dir, async (operation) => {
194
190
  const verified = stamp(await verifyRemoteMcp(url, auth, operation.fetch));
195
191
  return insert(dir, name, url, { auth, createdAt: new Date().toISOString(), verified, oauth: await oauthCapable(url, auth, operation.fetch), client });
196
- }, true, name);
192
+ }, true);
197
193
  }
198
194
 
199
195
  export async function localCreatePendingConnector(input: PendingConnectorInput, dir = agentsDir()): Promise<Connector> {
@@ -309,13 +305,12 @@ export async function loadConnectorPolicies(dir = agentsDir()): Promise<void> {
309
305
  export async function localRenameConnector(id: string, raw: string, dir = agentsDir()): Promise<Connector> {
310
306
  const name = connectorName(raw);
311
307
  const row = rowOrThrow(id, dir);
312
- assertNameFree(name, id, dir);
313
308
  return Promise.resolve(replace(dir, { ...row, name }));
314
309
  }
315
310
 
316
311
  export async function localDeleteConnector(id: string, dir = agentsDir()): Promise<DeletedConnector> {
317
312
  const row = rowOrThrow(id, dir);
318
- assertLocalMigrationEdit(id, row.name, dir);
313
+ assertLocalMigrationEdit(id, dir);
319
314
  writeRows(dir, readLocalConnectors(dir).filter((r) => r.id !== id));
320
315
  return Promise.resolve({ id: row.id, name: row.name });
321
316
  }
@@ -1,85 +1 @@
1
- import { isRecord } from '@metro-labs/core/is-record';
2
- import { errMsg, log } from '@metro-labs/core/log';
3
- import type { FetchLike } from '@metro-labs/core/stations/oauth';
4
- import { authHeaders, INITIALIZE, mcpPost, payloadOf, type ConnectorAuth } from '@metro-labs/http/connector-mcp';
5
- import { refused } from '@metro-labs/http/connector-url';
6
-
7
- export interface RemoteTool {
8
- name: string;
9
- title: string;
10
- description: string;
11
- readOnly: boolean;
12
- }
13
-
14
- const TIMEOUT_MS = 15_000;
15
- const MAX_PAGES = 10;
16
-
17
- function resultOf(text: string, contentType: string): Record<string, unknown> | null {
18
- let parsed: unknown = null;
19
- try {
20
- parsed = JSON.parse(payloadOf(text, contentType));
21
- } catch {
22
- return null;
23
- }
24
- return isRecord(parsed) && isRecord(parsed.result) ? parsed.result : null;
25
- }
26
-
27
- async function rpc(url: URL, auth: ConnectorAuth, session: Map<string, string>, body: unknown, fetchImpl?: FetchLike): Promise<Record<string, unknown> | null> {
28
- const res = await mcpPost(url, auth, body, { timeoutMs: TIMEOUT_MS, headers: Object.fromEntries(session), fetch: fetchImpl });
29
- const id = res.headers.get('mcp-session-id');
30
- if (id !== null && id !== '') session.set('mcp-session-id', id);
31
- const text = await res.text().catch(() => '');
32
- if (res.status === 401 || res.status === 403) throw refused(`${url.hostname} rejected the credential; connect it again`);
33
- if (!res.ok) throw refused(`${url.hostname} answered ${String(res.status)}`);
34
- return resultOf(text, res.headers.get('content-type') ?? '');
35
- }
36
-
37
- function toTool(raw: unknown): RemoteTool | null {
38
- if (!isRecord(raw) || typeof raw.name !== 'string') return null;
39
- const annotations = isRecord(raw.annotations) ? raw.annotations : {};
40
- const title = typeof raw.title === 'string' && raw.title !== '' ? raw.title : raw.name;
41
- return { name: raw.name, title, description: typeof raw.description === 'string' ? raw.description : '', readOnly: annotations.readOnlyHint === true };
42
- }
43
-
44
- async function endSession(url: URL, auth: ConnectorAuth, session: Map<string, string>, fetchImpl?: FetchLike): Promise<void> {
45
- if (!session.has('mcp-session-id')) return;
46
- try {
47
- const send = fetchImpl ?? fetch;
48
- const res = await send(url.toString(), { method: 'DELETE', redirect: 'manual', signal: AbortSignal.timeout(TIMEOUT_MS), headers: { ...authHeaders(auth), ...Object.fromEntries(session) } });
49
- if (res.ok) await res.body?.pipeTo(new WritableStream<Uint8Array>());
50
- else await res.body?.cancel();
51
- } catch (err) {
52
- log.debug(fetchImpl === undefined ? { host: url.hostname, err: errMsg(err) } : {}, 'connector-tools: the remote declined to end the session');
53
- }
54
- }
55
-
56
- const toolsOf = (result: Record<string, unknown>): RemoteTool[] =>
57
- Array.isArray(result.tools) ? result.tools.flatMap((raw: unknown) => toTool(raw) ?? []) : [];
58
-
59
- function nextCursor(result: Record<string, unknown>, current: string): string | null {
60
- const next = result.nextCursor;
61
- return typeof next !== 'string' || next === '' || next === current ? null : next;
62
- }
63
-
64
- async function walkTools(url: URL, auth: ConnectorAuth, session: Map<string, string>, fetchImpl?: FetchLike): Promise<RemoteTool[]> {
65
- const tools: RemoteTool[] = [];
66
- let cursor: string | null = '';
67
- for (let page = 0; page < MAX_PAGES && cursor !== null; page += 1) {
68
- const result = await rpc(url, auth, session, { jsonrpc: '2.0', id: 2 + page, method: 'tools/list', params: cursor === '' ? {} : { cursor } }, fetchImpl);
69
- if (result === null) break;
70
- tools.push(...toolsOf(result));
71
- cursor = nextCursor(result, cursor);
72
- }
73
- return tools;
74
- }
75
-
76
- export async function listRemoteTools(url: URL, auth: ConnectorAuth, fetchImpl?: FetchLike): Promise<RemoteTool[]> {
77
- const session = new Map<string, string>();
78
- const init = await rpc(url, auth, session, INITIALIZE, fetchImpl);
79
- if (init === null || typeof init.protocolVersion !== 'string') throw refused(`${url.hostname} answered, but it does not speak MCP.`);
80
- session.set('mcp-protocol-version', init.protocolVersion);
81
- await rpc(url, auth, session, { jsonrpc: '2.0', method: 'notifications/initialized' }, fetchImpl);
82
- const tools = await walkTools(url, auth, session, fetchImpl);
83
- await endSession(url, auth, session, fetchImpl);
84
- return tools;
85
- }
1
+ export { listRemoteTools, type RemoteTool } from '@metro-labs/http/connector-tools';
@@ -6,7 +6,7 @@ import { connectorAuth, connectorName } from '@metro-labs/http/connector-input';
6
6
  import { readConfig } from './config.js';
7
7
  import { parseConnectorUrl } from '@metro-labs/http/connector-url';
8
8
  import { normalizePolicy } from '../policy/policy.js';
9
- import { localImportConnectors, readLocalConnectors, type LocalConnectorRow } from './store.js';
9
+ import { localImportConnectors, type LocalConnectorRow } from './store.js';
10
10
 
11
11
  export interface ConnectorCopyResult {
12
12
  sourceId: string | null;
@@ -28,7 +28,6 @@ function copyRow(raw: unknown): LocalConnectorRow {
28
28
  export function copyConnectorRows(value: unknown, dir?: string): ConnectorCopyResult[] {
29
29
  if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.connectors) || value.connectors.length > 500)
30
30
  throw new ApiError('invalid connector transfer', 400);
31
- const names = new Set(readLocalConnectors(dir).map((row) => row.name));
32
31
  const imported: LocalConnectorRow[] = [];
33
32
  const results: ConnectorCopyResult[] = value.connectors.map((raw: unknown) => {
34
33
  const sourceId = isRecord(raw) && typeof raw.id === 'string' ? parseId(raw.id) : null;
@@ -38,8 +37,6 @@ export function copyConnectorRows(value: unknown, dir?: string): ConnectorCopyRe
38
37
  } catch {
39
38
  return { sourceId, status: 'invalid' };
40
39
  }
41
- if (names.has(row.name)) return { sourceId, status: 'skipped' };
42
- names.add(row.name);
43
40
  const id = newId();
44
41
  imported.push({ ...row, id });
45
42
  return { sourceId, id, status: 'copied' };
@@ -1,6 +1,6 @@
1
1
  import { str } from '@metro-labs/core/str';
2
2
  import { readPreview, shownValue } from '../approvals/preview.js';
3
- import { connectorToolOf } from '../connectors/gates.js';
3
+ import { connectorGates, connectorToolOf } from '../connectors/gates.js';
4
4
  import { stationByName } from '../stations/registry.js';
5
5
  import { stationOfAccount } from './read-tool.js';
6
6
 
@@ -92,7 +92,11 @@ function bodyLines(params: PermissionParams): string[] {
92
92
  const raw = params.input_preview.trim() === '' ? [] : [shorten(params.input_preview, RAW_MAX)];
93
93
  if (metro !== undefined) return input === undefined ? [`Approval needed: ${metro}`, ...raw] : metroLines(metro, input);
94
94
  const connector = connectorToolOf(params.tool_name);
95
- if (connector !== undefined) return connectorLines(connector.gate.name, connector.tool, input);
95
+ if (connector !== undefined) {
96
+ const { gate, tool } = connector;
97
+ const duplicate = connectorGates().some((entry) => entry.id !== gate.id && entry.name === gate.name);
98
+ return connectorLines(duplicate ? `${gate.name} (${gate.id})` : gate.name, tool, input);
99
+ }
96
100
  return [`Claude wants to run ${params.tool_name}: ${shorten(params.description)}`, '', ...raw];
97
101
  }
98
102
 
@@ -4,7 +4,6 @@ import { accountFromLine, knownAccounts } from '../agents/map.js';
4
4
  import { policyFor, storedPolicy, strictest, type Access, type PolicyTarget } from '../policy/policy.js';
5
5
  import { errResult } from './ctx.js';
6
6
  import { NEEDS_APPROVAL, takeGrant } from '../approvals/pending.js';
7
- import { connectorToolOf } from '../connectors/gates.js';
8
7
  import { profileScopeLine } from './profile-lookup.js';
9
8
  import { stationOfAccount } from './read-tool.js';
10
9
  import { stationForTool, toolGroupOf } from './tool-catalog.js';
@@ -56,7 +55,7 @@ export function channelDecision(name: string, a: Record<string, unknown>): Polic
56
55
  return verdict;
57
56
  }
58
57
 
59
- const isChannelTool = (name: string) => (tool: string): boolean => tool.endsWith(`__${name}`) && connectorToolOf(tool) === undefined;
58
+ const isChannelTool = (name: string) => (tool: string): boolean => tool === `mcp__metro__${name}`;
60
59
 
61
60
  export function policyGate(name: string, a: Record<string, unknown>): ToolResult | undefined {
62
61
  const { access, target } = channelDecision(name, a);
@@ -18,6 +18,7 @@
18
18
  "./connector-resource": "./src/connector-resource.ts",
19
19
  "./connector-reach": "./src/connector-reach.ts",
20
20
  "./connector-mcp": "./src/connector-mcp.ts",
21
+ "./connector-tools": "./src/connector-tools.ts",
21
22
  "./connector-discovery": "./src/connector-discovery.ts",
22
23
  "./connector-oauth": "./src/connector-oauth.ts",
23
24
  "./connector-input": "./src/connector-input.ts",
@@ -1,7 +1,7 @@
1
1
  import { parseId } from '@metro-labs/core/ids';
2
2
  import { isRecord } from '@metro-labs/core/is-record';
3
3
  import { connectorName } from './connector-input.js';
4
- import { connectorPolicyOf, type ConnectorPolicy } from './connector-policy.js';
4
+ import { connectorPolicyLayersOf, connectorPolicyOf, type ConnectorPolicy, type ConnectorPolicyLayers } from './connector-policy.js';
5
5
  import { parsePublicConnectorUrl } from './connector-public-url.js';
6
6
  import { isOrganizationId } from './workos-token.js';
7
7
 
@@ -23,6 +23,7 @@ export interface DeliveredConnector {
23
23
  auth: 'none' | 'header' | 'oauth';
24
24
  headers: Record<string, string>;
25
25
  policy: ConnectorPolicy;
26
+ policyLayers?: ConnectorPolicyLayers;
26
27
  revision: string;
27
28
  state: 'ready' | 'signin' | 'unavailable';
28
29
  expiresAt?: number;
@@ -91,7 +92,7 @@ function urlOf(value: unknown): string {
91
92
  }
92
93
 
93
94
  function connectorOf(raw: unknown): DeliveredConnector {
94
- const value = deliveryRecord(raw, ['id', 'name', 'url', 'auth', 'headers', 'policy', 'revision', 'state', 'expiresAt']);
95
+ const value = deliveryRecord(raw, ['id', 'name', 'url', 'auth', 'headers', 'policy', 'policyLayers', 'revision', 'state', 'expiresAt']);
95
96
  const id = parseId(value.id);
96
97
  if (id === null || typeof value.name !== 'string' || connectorName(value.name) !== value.name) throw new Error('Invalid delivered connector.');
97
98
  const url = urlOf(value.url);
@@ -99,7 +100,7 @@ function connectorOf(raw: unknown): DeliveredConnector {
99
100
  const state = stateOf(value.state, auth);
100
101
  deliveryBytes(value.revision, 32);
101
102
  if (Object.hasOwn(value, 'expiresAt') && !timestamp(value.expiresAt)) throw new Error('Invalid delivered connector expiry.');
102
- return { id, name: value.name, url, auth, state, revision: String(value.revision), headers: headersOf(value.headers, auth, state), policy: connectorPolicyOf(value.policy), ...(timestamp(value.expiresAt) ? { expiresAt: value.expiresAt } : {}) };
103
+ return { id, name: value.name, url, auth, state, revision: String(value.revision), headers: headersOf(value.headers, auth, state), policy: connectorPolicyOf(value.policy), ...(Object.hasOwn(value, 'policyLayers') ? { policyLayers: connectorPolicyLayersOf(value.policyLayers) } : {}), ...(timestamp(value.expiresAt) ? { expiresAt: value.expiresAt } : {}) };
103
104
  }
104
105
 
105
106
  export function deliverySnapshotOf(raw: unknown): DeliverySnapshot {
@@ -2,8 +2,8 @@ import type { KeyObject } from 'node:crypto';
2
2
  import { CONNECTOR_DELIVERY_MAX_BYTES, DELIVERY_PLAINTEXT_MAX, deliveryContextBytes, deliverySnapshotOf, type DeliveryContext, type DeliverySnapshot } from './connector-delivery-validation.js';
3
3
  import { openTransfer, sealTransfer, type SealedTransfer } from './transfer-crypto.js';
4
4
 
5
- export { CONNECTOR_DELIVERY_LEASE_MS, CONNECTOR_DELIVERY_MAX_BYTES, deliverySnapshotOf, type DeliveryContext, type DeliverySnapshot, type DeliveredConnector } from './connector-delivery-validation.js';
6
- export { combineConnectorPolicies, connectorPolicyOf, type ConnectorPolicy } from './connector-policy.js';
5
+ export { CONNECTOR_DELIVERY_LEASE_MS, CONNECTOR_DELIVERY_MAX_BYTES, DELIVERY_PLAINTEXT_MAX, deliverySnapshotOf, type DeliveryContext, type DeliverySnapshot, type DeliveredConnector } from './connector-delivery-validation.js';
6
+ export { combineConnectorPolicies, connectorPolicyOf, effectiveConnectorPolicy, connectorPoliciesBlocked, validConnectorToolName, type ConnectorPolicy, type ConnectorPolicyLayers } from './connector-policy.js';
7
7
  export type SealedDelivery = SealedTransfer;
8
8
 
9
9
  export function sealConnectorDelivery(publicKey: string, context: DeliveryContext, snapshot: DeliverySnapshot): SealedDelivery {
@@ -2,15 +2,24 @@ import { isRecord } from '@metro-labs/core/is-record';
2
2
 
3
3
  export type ConnectorDecision = 'allow' | 'ask' | 'deny';
4
4
 
5
+ type ToolGroup = 'read' | 'write';
6
+
5
7
  export interface ConnectorPolicy {
6
8
  read?: ConnectorDecision;
7
9
  write?: ConnectorDecision;
8
10
  tools?: Record<string, ConnectorDecision>;
9
11
  }
10
12
 
13
+ export interface ConnectorPolicyLayers {
14
+ organization: ConnectorPolicy;
15
+ agent: ConnectorPolicy;
16
+ }
17
+
11
18
  const RANK: Record<ConnectorDecision, number> = { allow: 0, ask: 1, deny: 2 };
12
19
  const TOOL_RE = /^[A-Za-z0-9_.:/-]{1,256}$/;
13
20
 
21
+ export const validConnectorToolName = (name: string): boolean => TOOL_RE.test(name) && !['__proto__', 'constructor', 'prototype'].includes(name);
22
+
14
23
  const decision = (value: unknown): value is ConnectorDecision => value === 'allow' || value === 'ask' || value === 'deny';
15
24
 
16
25
  export function connectorPolicyOf(value: unknown): ConnectorPolicy {
@@ -30,15 +39,23 @@ function toolPolicyOf(value: unknown): Record<string, ConnectorDecision> {
30
39
  if (!isRecord(value) || Object.keys(value).length > 2000) throw new Error('Invalid connector policy.');
31
40
  const out: Record<string, ConnectorDecision> = {};
32
41
  for (const [name, rule] of Object.entries(value)) {
33
- if (!TOOL_RE.test(name) || ['__proto__', 'constructor', 'prototype'].includes(name) || !decision(rule)) throw new Error('Invalid connector policy.');
42
+ if (!validConnectorToolName(name) || !decision(rule)) throw new Error('Invalid connector policy.');
34
43
  out[name] = rule;
35
44
  }
36
45
  return out;
37
46
  }
38
47
 
48
+ export function connectorPolicyLayersOf(value: unknown): ConnectorPolicyLayers {
49
+ if (!isRecord(value) || Object.keys(value).some((key) => !['organization', 'agent'].includes(key))) throw new Error('Invalid connector policy layers.');
50
+ return { organization: connectorPolicyOf(value.organization), agent: connectorPolicyOf(value.agent) };
51
+ }
52
+
39
53
  const strictest = (...values: (ConnectorDecision | undefined)[]): ConnectorDecision => values.reduce<ConnectorDecision>((held, next) => next !== undefined && RANK[next] > RANK[held] ? next : held, 'allow');
54
+ const toolRule = (policy: ConnectorPolicy, name: string): ConnectorDecision | undefined => policy.tools !== undefined && Object.hasOwn(policy.tools, name) ? policy.tools[name] : undefined;
55
+ const toolGroup = (groups: Record<string, ToolGroup>, name: string): ToolGroup => Object.hasOwn(groups, name) && groups[name] === 'read' ? 'read' : 'write';
56
+ const access = (policy: ConnectorPolicy, name: string, group: ToolGroup): ConnectorDecision => toolRule(policy, name) ?? policy[group] ?? 'allow';
40
57
 
41
- export function combineConnectorPolicies(global: ConnectorPolicy, assignment: ConnectorPolicy): ConnectorPolicy {
58
+ export function combineConnectorPolicies(global: ConnectorPolicy, assignment: ConnectorPolicy, groups?: Record<string, ToolGroup>): ConnectorPolicy {
42
59
  const policies = [connectorPolicyOf(global), connectorPolicyOf(assignment)];
43
60
  const neutral = (policy: ConnectorPolicy): boolean => (policy.read ?? 'allow') === 'allow' && (policy.write ?? 'allow') === 'allow' && Object.keys(policy.tools ?? {}).length === 0;
44
61
  if (neutral(assignment)) return connectorPolicyOf(global);
@@ -48,9 +65,28 @@ export function combineConnectorPolicies(global: ConnectorPolicy, assignment: Co
48
65
  write: strictest(...policies.map((policy) => policy.write)),
49
66
  };
50
67
  const names = new Set(policies.flatMap((policy) => Object.keys(policy.tools ?? {})));
68
+ if (groups === undefined && names.size > 2000) return { read: 'deny', write: 'deny' };
51
69
  if (names.size > 0) {
52
70
  out.tools = {};
53
- for (const name of names) out.tools[name] = strictest(...policies.flatMap((policy) => [policy.read, policy.write, policy.tools?.[name]]));
71
+ for (const name of names) {
72
+ out.tools[name] = groups === undefined
73
+ ? strictest(...policies.flatMap((policy) => [policy.read, policy.write, toolRule(policy, name)]))
74
+ : strictest(...policies.map((policy) => access(policy, name, toolGroup(groups, name))));
75
+ }
54
76
  }
55
77
  return out;
56
78
  }
79
+
80
+ export function effectiveConnectorPolicy(row: { policy: ConnectorPolicy; policyLayers?: ConnectorPolicyLayers }, groups: Record<string, ToolGroup>): ConnectorPolicy {
81
+ const layers = row.policyLayers;
82
+ return layers === undefined ? connectorPolicyOf(row.policy) : combineConnectorPolicies(layers.organization, layers.agent, groups);
83
+ }
84
+
85
+ export function connectorPoliciesBlocked(layers: ConnectorPolicyLayers): boolean {
86
+ const policies = [layers.organization, layers.agent];
87
+ const names = new Set(policies.flatMap((policy) => Object.keys(policy.tools ?? {})));
88
+ return (['read', 'write'] as const).every((group) =>
89
+ strictest(...policies.map((policy) => policy[group])) === 'deny' &&
90
+ [...names].every((name) => strictest(...policies.map((policy) => access(policy, name, group))) === 'deny'),
91
+ );
92
+ }