@stage-labs/metro 0.1.0-beta.178 → 0.1.0-beta.179

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 (50) hide show
  1. package/dist/onboarding.js +4 -3
  2. package/package.json +1 -1
  3. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  4. package/runtime/marketplace/plugin/bin/guard.mjs +22 -6
  5. package/runtime/marketplace/plugin/bin/policy.mjs +97 -0
  6. package/runtime/marketplace/plugin/orchestrator.md +2 -0
  7. package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +4 -0
  8. package/runtime/node_modules/@metro-labs/daemon/src/agents/account-routes.ts +3 -1
  9. package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +3 -0
  10. package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +2 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +10 -1
  12. package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +17 -0
  13. package/runtime/node_modules/@metro-labs/daemon/src/agents/files.ts +10 -1
  14. package/runtime/node_modules/@metro-labs/daemon/src/agents/policy-route.ts +27 -0
  15. package/runtime/node_modules/@metro-labs/daemon/src/approvals/api.ts +55 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +126 -0
  17. package/runtime/node_modules/@metro-labs/daemon/src/approvals/preview.ts +66 -0
  18. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +10 -1
  19. package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +11 -38
  20. package/runtime/node_modules/@metro-labs/daemon/src/channels/react-note.ts +13 -0
  21. package/runtime/node_modules/@metro-labs/daemon/src/channels/reply-id.ts +1 -0
  22. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +4 -1
  23. package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +34 -12
  24. package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +19 -0
  25. package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +63 -0
  26. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +9 -4
  27. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-policy.ts +85 -0
  28. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +33 -13
  29. package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +46 -2
  30. package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +2 -1
  31. package/runtime/node_modules/@metro-labs/daemon/src/mcp/accounts.ts +4 -1
  32. package/runtime/node_modules/@metro-labs/daemon/src/mcp/attachment-schema.ts +91 -0
  33. package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +18 -21
  34. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +87 -0
  35. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +42 -33
  36. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +77 -0
  37. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +79 -0
  38. package/runtime/node_modules/@metro-labs/daemon/src/mcp/profile-lookup.ts +3 -1
  39. package/runtime/node_modules/@metro-labs/daemon/src/mcp/profile-tool.ts +3 -1
  40. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +8 -0
  41. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-catalog.ts +88 -0
  42. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-def.ts +9 -0
  43. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +20 -31
  44. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +20 -91
  45. package/runtime/node_modules/@metro-labs/daemon/src/policy/policy.ts +118 -0
  46. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +8 -0
  47. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +2 -0
  48. package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +10 -0
  49. package/runtime/node_modules/@metro-labs/xmtp/src/tools.ts +6 -0
  50. package/runtime/runtime.json +1 -1
@@ -21,7 +21,7 @@ import {
21
21
  } from '../routes/http.js';
22
22
  import { agentsDir, fileSource } from '../agents/files.js';
23
23
  import { syncPluginServers } from '../connectors/plugin-sync.js';
24
- import { readLocalConnectors } from '../connectors/store.js';
24
+ import { loadConnectorPolicies, readLocalConnectors } from '../connectors/store.js';
25
25
  import { ensureMetroPlugin } from '../claude/plugin-install.js';
26
26
  import { unwatchSession, watchSession } from '../claude/session.js';
27
27
  import { tryClaudeSetup } from '../claude/setup.js';
@@ -33,7 +33,10 @@ import { ensureStationDeps } from '../stations/runtime-deps.js';
33
33
  import { localSessionApis } from '../routes/local-mode.js';
34
34
  import type { SessionApis } from '../routes/session-apis.js';
35
35
  import { createMetroMcp } from '../mcp/index.js';
36
+ import { startPromptExpiry } from '../approvals/pending.js';
37
+ import { watchPolicySnapshot } from '../mcp/policy-snapshot.js';
36
38
  import { gatherAccountsForAgents } from '../mcp/accounts.js';
39
+ import { stationToolGroups } from '../mcp/tool-catalog.js';
37
40
  import {
38
41
  accountStationCapabilities,
39
42
  forgetOrphans,
@@ -107,6 +110,7 @@ function sessionApis(): SessionApis {
107
110
  },
108
111
  gatherAccounts: gatherAccountsForAgents,
109
112
  capabilities: accountStationCapabilities,
113
+ toolGroups: stationToolGroups,
110
114
  prepareAccount,
111
115
  });
112
116
  }
@@ -116,6 +120,10 @@ async function main(): Promise<void> {
116
120
  applyLocalOwner();
117
121
  installBearerSessions(agentsDir(), localOwner);
118
122
  migrateAgentLayout();
123
+ watchPolicySnapshot();
124
+ loadConnectorPolicies().catch((err: unknown) => {
125
+ log.warn({ err: errMsg(err) }, 'connector policy: loading the policies failed');
126
+ });
119
127
  log.info({ agent: await ensureLocalAgent() }, 'local daemon: agent');
120
128
  await materializeFrom(fileSource);
121
129
  forgetOrphans(knownAccounts());
@@ -128,6 +136,7 @@ installBearerSessions(agentsDir(), localOwner);
128
136
  true,
129
137
  );
130
138
  metroMcp.startInbound();
139
+ startPromptExpiry();
131
140
  syncPluginServers(readLocalConnectors());
132
141
  startUploadReaper();
133
142
  startAttachReaper();
@@ -1,6 +1,7 @@
1
1
  import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import { str } from '@metro-labs/core/str';
3
3
  import { dedupeKey } from './dedupe.js';
4
+ import { PERMISSION_REPLY_RE } from './reply-id.js';
4
5
  import {
5
6
  buildMediaFailureNote,
6
7
  buildMediaNote,
@@ -8,6 +9,7 @@ import {
8
9
  type SavedMedia,
9
10
  } from './media-note.js';
10
11
  import { buildWebhookNote } from './webhook-note.js';
12
+ import { reactContent, reactionEmoji } from './react-note.js';
11
13
  import { replyMeta } from './addressed.js';
12
14
  import {
13
15
  capSet,
@@ -26,26 +28,20 @@ interface InboundDeps {
26
28
  getStations: () => Set<string>;
27
29
  senderAllowed: (from: string, line: string, verified?: boolean) => boolean;
28
30
  approves?: (station: string) => boolean;
31
+ answerPermission?: (requestId: string, behavior: 'allow' | 'deny', line: string) => Promise<boolean>;
29
32
  }
30
33
 
31
34
  const ATTACH_TIMEOUT_MS = 15_000;
32
35
  const DEDUPE_TTL_MS = 30_000;
33
36
  const DEDUPE_MAX = 2_000;
34
37
  const ALLOWED_LINES_MAX = 2_000;
35
- const PENDING_PERMISSIONS_MAX = 500;
36
38
  const SENT_IDS_MAX = 2_000;
37
39
 
38
- const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i;
39
-
40
- const shortId = (id: string): string =>
41
- id.length > 10 ? `${id.slice(0, 6)}…` : id;
42
-
43
40
  export class InboundRelay {
44
41
  private readonly deps: InboundDeps;
45
42
  private readonly pendingAttachments = new Map<string, PendingMsg>();
46
43
  private readonly seenEvents = new Map<string, number>();
47
44
  private readonly allowedLines = new Set<string>();
48
- private readonly pendingPermissions = new Map<string, string>();
49
45
  private readonly sentIds = new Set<string>();
50
46
  private lastLine: string | undefined;
51
47
 
@@ -57,15 +53,6 @@ export class InboundRelay {
57
53
  return this.lastLine;
58
54
  }
59
55
 
60
- registerPermission(requestId: string, line: string): void {
61
- this.pendingPermissions.set(requestId, line);
62
- while (this.pendingPermissions.size > PENDING_PERMISSIONS_MAX) {
63
- const oldest = this.pendingPermissions.keys().next();
64
- if (oldest.done) break;
65
- this.pendingPermissions.delete(oldest.value);
66
- }
67
- }
68
-
69
56
  noteSent(messageId: string): void {
70
57
  if (!messageId) return;
71
58
  this.sentIds.add(messageId);
@@ -241,9 +228,7 @@ export class InboundRelay {
241
228
  const removed =
242
229
  (ev.payload as { removed?: boolean } | undefined)?.removed === true ||
243
230
  / \(removed\)\]?$/.test(base.text);
244
- const verb = removed ? 'removed from' : 'reacted to';
245
- const label = removed ? emoji || 'reaction' : emoji || 'reacted';
246
- const content = `${label} ${verb} message ${shortId(target)}`.trim();
231
+ const content = reactContent(emoji, target, removed);
247
232
  await this.notify('notifications/claude/channel', {
248
233
  content,
249
234
  meta: {
@@ -261,18 +246,12 @@ export class InboundRelay {
261
246
  });
262
247
  }
263
248
 
264
- private async handlePermissionReply(text: string, line: string): Promise<boolean> {
265
- const m = PERMISSION_REPLY_RE.exec(text);
266
- if (m?.[1] === undefined || m[2] === undefined || !this.pendingPermissions.size)
267
- return false;
268
- const id = m[2].toLowerCase();
269
- if (this.pendingPermissions.get(id) !== line) return false;
270
- this.pendingPermissions.delete(id);
271
- await this.notify('notifications/claude/channel/permission', {
272
- request_id: id,
273
- behavior: m[1].toLowerCase().startsWith('y') ? 'allow' : 'deny',
274
- });
275
- return true;
249
+ private async handlePermissionReply(ev: Record<string, unknown>, base: EventBase): Promise<boolean> {
250
+ const answer = this.deps.answerPermission;
251
+ if (answer === undefined || base.evType !== 'msg' || !this.approves(base.station) || ev.senderVerified === false) return false;
252
+ const m = PERMISSION_REPLY_RE.exec(base.text);
253
+ if (m?.[1] === undefined || m[2] === undefined) return false;
254
+ return answer(m[2].toLowerCase(), m[1].toLowerCase().startsWith('y') ? 'allow' : 'deny', base.line);
276
255
  }
277
256
 
278
257
  private approves(station: string): boolean {
@@ -334,8 +313,7 @@ export class InboundRelay {
334
313
  this.deps.log('drop: empty message', base.station, base.line, str(ev.messageId));
335
314
  return;
336
315
  }
337
- if (base.evType === 'msg' && this.approves(base.station) && (await this.handlePermissionReply(base.text, base.line)))
338
- return;
316
+ if (await this.handlePermissionReply(ev, base)) return;
339
317
  await this.notify('notifications/claude/channel', {
340
318
  content:
341
319
  base.evType === 'system'
@@ -388,8 +366,3 @@ interface EventBase {
388
366
  text: string;
389
367
  }
390
368
 
391
- function reactionEmoji(raw: unknown): string {
392
- if (typeof raw === 'string') return raw;
393
- const obj = raw as { name?: string; reaction?: string } | undefined;
394
- return obj?.name ?? obj?.reaction ?? '';
395
- }
@@ -0,0 +1,13 @@
1
+ const shortId = (id: string): string => (id.length > 10 ? `${id.slice(0, 6)}…` : id);
2
+
3
+ export function reactionEmoji(raw: unknown): string {
4
+ if (typeof raw === 'string') return raw;
5
+ const obj = raw as { name?: string; reaction?: string } | undefined;
6
+ return obj?.name ?? obj?.reaction ?? '';
7
+ }
8
+
9
+ export function reactContent(emoji: string, target: string, removed: boolean): string {
10
+ const verb = removed ? 'removed from' : 'reacted to';
11
+ const label = removed ? emoji || 'reaction' : emoji || 'reacted';
12
+ return `${label} ${verb} message ${shortId(target)}`.trim();
13
+ }
@@ -0,0 +1 @@
1
+ export const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i;
@@ -128,7 +128,10 @@ export function guidancePath(env: NodeJS.ProcessEnv = process.env): string {
128
128
  }
129
129
 
130
130
  const PRIOR_WORKER: ReadonlySet<string> = new Set(['08a0cd8710df285d6512245246bb8b658b7cfc7f3e89490514ee5376779eb2ef']);
131
- const PRIOR_SKILL: ReadonlySet<string> = new Set(['0b3d122ed95beff09d579cf912cd4238e1db524c41fce4b314de57d6ff5908ad']);
131
+ const PRIOR_SKILL: ReadonlySet<string> = new Set([
132
+ '0b3d122ed95beff09d579cf912cd4238e1db524c41fce4b314de57d6ff5908ad',
133
+ '36f4fb57f231a119d717b5e3d6ccb654f94960fcca708683f376679aead8df25',
134
+ ]);
132
135
 
133
136
  const digest = (text: string): string => createHash('sha256').update(text).digest('hex');
134
137
 
@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { log } from '@metro-labs/core/log';
3
3
  import { healthOf } from './health.js';
4
4
  import type { RemoteTool } from './tools.js';
5
+ import { normalizePolicy, type ToolPolicy } from '../policy/policy.js';
5
6
  import {
6
7
  apiFailure,
7
8
  apiSession,
@@ -39,6 +40,7 @@ export interface ConnectorApiDeps extends OAuthRouteDeps {
39
40
  disconnectConnector: (id: string) => Promise<Connector>;
40
41
  renameConnector: (id: string, name: string) => Promise<Connector>;
41
42
  deleteConnector: (id: string) => Promise<DeletedConnector>;
43
+ setConnectorPolicy: (id: string, policy: ToolPolicy) => Promise<Connector>;
42
44
  }
43
45
 
44
46
  type Routable =
@@ -49,7 +51,8 @@ type Routable =
49
51
  | { kind: 'tools'; id: string }
50
52
  | { kind: 'connect'; id: string }
51
53
  | { kind: 'disconnect'; id: string }
52
- | { kind: 'rename'; id: string };
54
+ | { kind: 'rename'; id: string }
55
+ | { kind: 'policy'; id: string };
53
56
 
54
57
  type Target = Routable | { kind: 'unknown' } | null;
55
58
 
@@ -62,6 +65,7 @@ function subTarget(id: string, rest: string[]): Target {
62
65
  if (head === 'connect') return { kind: 'connect', id };
63
66
  if (head === 'disconnect') return { kind: 'disconnect', id };
64
67
  if (head === 'rename') return { kind: 'rename', id };
68
+ if (head === 'policy') return { kind: 'policy', id };
65
69
  return { kind: 'unknown' };
66
70
  }
67
71
 
@@ -86,6 +90,7 @@ function connectorPayload(row: Connector): Record<string, unknown> {
86
90
  clientId: row.client?.clientId ?? null,
87
91
  signIn: row.signIn,
88
92
  verified: row.verified,
93
+ policy: row.policy,
89
94
  health: healthOf(row.id),
90
95
  };
91
96
  }
@@ -202,6 +207,32 @@ async function handleRename(
202
207
  sendJson(req, res, 200, connectorPayload(row));
203
208
  }
204
209
 
210
+ async function handlePolicy(
211
+ req: IncomingMessage,
212
+ res: ServerResponse,
213
+ deps: ConnectorApiDeps,
214
+ id: string,
215
+ ): Promise<void> {
216
+ const policy = normalizePolicy(bodyField(await readJsonBody(req), 'policy'));
217
+ const row = await deps.setConnectorPolicy(id, policy);
218
+ log.info({ id: row.id, name: row.name, policy: row.policy }, 'connector-api: tool policy set');
219
+ sendJson(req, res, 200, connectorPayload(row));
220
+ }
221
+
222
+ type ById = Exclude<Routable['kind'], 'collection' | 'callback'>;
223
+
224
+ const BY_ID: Record<ById, (req: IncomingMessage, res: ServerResponse, deps: ConnectorApiDeps, id: string) => Promise<void>> = {
225
+ verify: handleVerify,
226
+ tools: async (req, res, deps, id) => {
227
+ sendJson(req, res, 200, { tools: await deps.connectorTools(id) });
228
+ },
229
+ connect: handleConnect,
230
+ disconnect: handleDisconnect,
231
+ rename: handleRename,
232
+ policy: handlePolicy,
233
+ connector: handleConnector,
234
+ };
235
+
205
236
  async function route(
206
237
  req: IncomingMessage,
207
238
  res: ServerResponse,
@@ -210,17 +241,7 @@ async function route(
210
241
  ): Promise<void> {
211
242
  try {
212
243
  if (tgt.kind === 'callback') return;
213
- if (tgt.kind === 'verify')
214
- await handleVerify(req, res, deps, tgt.id);
215
- else if (tgt.kind === 'tools') sendJson(req, res, 200, { tools: await deps.connectorTools(tgt.id) });
216
- else if (tgt.kind === 'connect')
217
- await handleConnect(req, res, deps, tgt.id);
218
- else if (tgt.kind === 'disconnect')
219
- await handleDisconnect(req, res, deps, tgt.id);
220
- else if (tgt.kind === 'rename')
221
- await handleRename(req, res, deps, tgt.id);
222
- else if (tgt.kind === 'connector')
223
- await handleConnector(req, res, deps, tgt.id);
244
+ if (tgt.kind !== 'collection') await BY_ID[tgt.kind](req, res, deps, tgt.id);
224
245
  else if (req.method === 'GET') await handleList(req, res, deps);
225
246
  else await handleCreate(req, res, deps);
226
247
  } catch (err) {
@@ -237,6 +258,7 @@ const ALLOWED: Record<Routable['kind'], string[]> = {
237
258
  connect: ['POST'],
238
259
  disconnect: ['POST'],
239
260
  rename: ['POST'],
261
+ policy: ['PUT'],
240
262
  };
241
263
 
242
264
  async function dispatch(
@@ -6,6 +6,8 @@ import type {
6
6
  } from './verify.js';
7
7
  import type { OAuthClient } from './oauth-client.js';
8
8
  import { isRecord } from '@metro-labs/core/is-record';
9
+ import type { ToolGroup } from '@metro-labs/core/stations/types';
10
+ import { parsePolicy, type ToolPolicy } from '../policy/policy.js';
9
11
 
10
12
  const CONNECTOR_NAME_MAX = 64;
11
13
  const HEADER_NAME_RE = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$/;
@@ -24,6 +26,8 @@ export interface ConnectorConfig {
24
26
  verified: VerifiedRecord;
25
27
  oauth: boolean;
26
28
  client: OAuthClient | null;
29
+ policy?: ToolPolicy;
30
+ toolGroups?: Record<string, ToolGroup>;
27
31
  }
28
32
 
29
33
  function text(value: unknown): string {
@@ -135,15 +139,30 @@ function readVerified(raw: unknown): VerifiedRecord {
135
139
  return { at: text(record.at), server: text(record.server) };
136
140
  }
137
141
 
142
+ const TOOL_NAME_MAX = 128;
143
+ const TOOL_GROUPS_MAX = 1000;
144
+
145
+ export function readToolGroups(raw: unknown): Record<string, ToolGroup> | undefined {
146
+ if (!isRecord(raw)) return undefined;
147
+ const kept = Object.entries(raw)
148
+ .filter((entry): entry is [string, ToolGroup] => entry[0].length <= TOOL_NAME_MAX && (entry[1] === 'read' || entry[1] === 'write'))
149
+ .slice(0, TOOL_GROUPS_MAX);
150
+ return kept.length > 0 ? Object.fromEntries(kept) : undefined;
151
+ }
152
+
138
153
  export function readConfig(raw: unknown): ConnectorConfig {
139
154
  const record = isRecord(raw) ? raw : {};
140
155
  const auth = readAuth(record.auth);
156
+ const policy = parsePolicy(record.policy, 'connectors.json');
157
+ const toolGroups = readToolGroups(record.toolGroups);
141
158
  return {
142
159
  auth,
143
160
  createdAt: text(record.createdAt),
144
161
  verified: readVerified(record.verified),
145
162
  oauth: record.oauth === true || auth.kind === 'oauth',
146
163
  client: readClient(record.client),
164
+ ...(policy === undefined || Object.keys(policy).length === 0 ? {} : { policy }),
165
+ ...(toolGroups === undefined ? {} : { toolGroups }),
147
166
  };
148
167
  }
149
168
 
@@ -0,0 +1,63 @@
1
+ import type { ToolGroup } from '@metro-labs/core/stations/types';
2
+ import { policyFor, setPolicies, type PolicyTarget, type ToolPolicy } from '../policy/policy.js';
3
+ import { serverKeysOf } from './plugin-sync.js';
4
+
5
+ interface GateRow {
6
+ id: string;
7
+ name: string;
8
+ config: { policy?: ToolPolicy; toolGroups?: Record<string, ToolGroup> };
9
+ }
10
+
11
+ export interface ConnectorGate {
12
+ id: string;
13
+ name: string;
14
+ server: string;
15
+ policy: ToolPolicy;
16
+ tools: Record<string, ToolGroup>;
17
+ }
18
+
19
+ let gates: ConnectorGate[] = [];
20
+
21
+ const normalized = (name: string): string => name.replace(/[^a-zA-Z0-9_-]/g, '_');
22
+
23
+ export function registerConnectors(rows: readonly GateRow[]): void {
24
+ const keys = serverKeysOf([...rows]);
25
+ gates = rows.map((row) => ({
26
+ id: row.id,
27
+ name: row.name,
28
+ server: keys.get(row.id) ?? '',
29
+ policy: row.config.policy ?? {},
30
+ tools: row.config.toolGroups ?? {},
31
+ }));
32
+ const entries = rows.flatMap((row): [PolicyTarget, ToolPolicy][] =>
33
+ row.config.policy === undefined ? [] : [[{ kind: 'connector', id: row.id }, row.config.policy]],
34
+ );
35
+ setPolicies('connector', entries);
36
+ }
37
+
38
+ export const connectorGates = (): readonly ConnectorGate[] => gates;
39
+
40
+ export const toolGroupOf = (gate: ConnectorGate, tool: string): ToolGroup => (gate.tools[tool] === 'read' ? 'read' : 'write');
41
+
42
+ export function blockedReason(connectorId: string, tool: string): string | null {
43
+ const gate = gates.find((g) => g.id === connectorId);
44
+ if (gate === undefined) return null;
45
+ const access = policyFor({ kind: 'connector', id: connectorId }, { name: tool, group: toolGroupOf(gate, tool) });
46
+ return access === 'deny' ? `Blocked by the owner's policy for ${gate.name} (${tool}).` : null;
47
+ }
48
+
49
+ function prefixesOf(gate: ConnectorGate): string[] {
50
+ return [`mcp__plugin_metro_${gate.server}__`, `mcp__${gate.server}__`, `mcp__${normalized(`metro.box ${gate.name}`)}__`];
51
+ }
52
+
53
+ export function connectorToolOf(toolName: string): { gate: ConnectorGate; tool: string } | undefined {
54
+ if (toolName.startsWith('mcp__metro__')) return undefined;
55
+ for (const gate of gates)
56
+ for (const prefix of prefixesOf(gate))
57
+ if (toolName.startsWith(prefix) && toolName.length > prefix.length) {
58
+ const called = toolName.slice(prefix.length);
59
+ const known = [...Object.keys(gate.tools), ...Object.keys(gate.policy.tools ?? {})];
60
+ return { gate, tool: known.find((name) => name === called) ?? known.find((name) => normalized(name) === called) ?? called };
61
+ }
62
+ return undefined;
63
+ }
@@ -30,16 +30,21 @@ export function serverKey(name: string): string {
30
30
  return slug === '' ? 'connector' : slug;
31
31
  }
32
32
 
33
- export function pluginServers(rows: PluginRow[], base: string): Record<string, PluginServer> {
33
+ export function serverKeysOf(rows: PluginRow[]): Map<string, string> {
34
34
  const taken = new Map<string, number>();
35
- const out: Record<string, PluginServer> = {};
35
+ const keys = new Map<string, string>();
36
36
  for (const row of rows) {
37
37
  const wanted = serverKey(row.name);
38
38
  const seen = taken.get(wanted) ?? 0;
39
39
  taken.set(wanted, seen + 1);
40
- const key = seen === 0 ? wanted : `${wanted}-${row.id.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 4)}`;
41
- out[key] = { type: 'http', url: `${base}/relay/${row.id}`, headersHelper: HELPER };
40
+ keys.set(row.id, seen === 0 ? wanted : `${wanted}-${row.id.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 4)}`);
42
41
  }
42
+ return keys;
43
+ }
44
+
45
+ export function pluginServers(rows: PluginRow[], base: string): Record<string, PluginServer> {
46
+ const out: Record<string, PluginServer> = {};
47
+ for (const [id, key] of serverKeysOf(rows)) out[key] = { type: 'http', url: `${base}/relay/${id}`, headersHelper: HELPER };
43
48
  return out;
44
49
  }
45
50
 
@@ -0,0 +1,85 @@
1
+ import type { ServerResponse } from 'node:http';
2
+ import { isRecord } from '@metro-labs/core/is-record';
3
+ import { log } from '@metro-labs/core/log';
4
+
5
+ export type BlockedReason = (connectorId: string, tool: string) => string | null;
6
+
7
+ export interface Screened {
8
+ forward: Uint8Array<ArrayBuffer> | null;
9
+ answers: Record<string, unknown>[];
10
+ batch: boolean;
11
+ }
12
+
13
+ const encoder = new TextEncoder();
14
+
15
+ function parsed(body: Uint8Array): unknown {
16
+ try {
17
+ return JSON.parse(new TextDecoder().decode(body));
18
+ } catch {
19
+ return undefined;
20
+ }
21
+ }
22
+
23
+ function toolOf(message: unknown): string | null {
24
+ if (!isRecord(message) || message.method !== 'tools/call' || !isRecord(message.params)) return null;
25
+ const name = message.params.name;
26
+ return typeof name === 'string' ? name : null;
27
+ }
28
+
29
+ function blockedAnswer(message: Record<string, unknown>, reason: string): Record<string, unknown> | null {
30
+ if (!('id' in message)) return null;
31
+ return { jsonrpc: '2.0', id: message.id, result: { isError: true, content: [{ type: 'text', text: reason }] } };
32
+ }
33
+
34
+ function reasonFor(message: unknown, connectorId: string, blocked: BlockedReason): string | null {
35
+ const tool = toolOf(message);
36
+ return tool === null ? null : blocked(connectorId, tool);
37
+ }
38
+
39
+ export function screenCalls(body: Uint8Array<ArrayBuffer>, connectorId: string, blocked: BlockedReason): Screened | null {
40
+ const message = parsed(body);
41
+ const batch = Array.isArray(message);
42
+ const list: unknown[] = batch ? message : [message];
43
+ const answers: Record<string, unknown>[] = [];
44
+ const kept: unknown[] = [];
45
+ for (const item of list) {
46
+ const reason = reasonFor(item, connectorId, blocked);
47
+ if (reason === null || !isRecord(item)) {
48
+ kept.push(item);
49
+ continue;
50
+ }
51
+ log.info({ connector: connectorId, tool: toolOf(item) }, 'relay: a call the owner blocked was answered without the connector');
52
+ const answer = blockedAnswer(item, reason);
53
+ if (answer !== null) answers.push(answer);
54
+ }
55
+ if (kept.length === list.length) return null;
56
+ const forward = kept.length === 0 ? null : new Uint8Array(encoder.encode(JSON.stringify(kept)));
57
+ return { forward, answers, batch };
58
+ }
59
+
60
+ export function answerBlocked(res: ServerResponse, screened: Screened): void {
61
+ if (res.headersSent) return;
62
+ if (screened.answers.length === 0) {
63
+ res.writeHead(202).end();
64
+ return;
65
+ }
66
+ const body = screened.batch ? screened.answers : screened.answers[0];
67
+ res.writeHead(200, { 'content-type': 'application/json' });
68
+ res.end(JSON.stringify(body));
69
+ }
70
+
71
+ const sseFrames = (answers: Record<string, unknown>[]): string =>
72
+ answers.map((answer) => `event: message\ndata: ${JSON.stringify(answer)}\n\n`).join('');
73
+
74
+ export async function mergeBlocked(res: ServerResponse, upstream: Response, headers: Record<string, string>, answers: Record<string, unknown>[]): Promise<void> {
75
+ const text = await upstream.text();
76
+ if ((headers['content-type'] ?? '').includes('text/event-stream')) {
77
+ res.writeHead(upstream.status, headers);
78
+ res.end(`${text}${sseFrames(answers)}`);
79
+ return;
80
+ }
81
+ const theirs = text.trim() === '' ? [] : parsed(encoder.encode(text));
82
+ const list: unknown[] = Array.isArray(theirs) ? theirs : theirs === undefined || theirs === null ? [] : [theirs];
83
+ res.writeHead(200, { ...headers, 'content-type': 'application/json' });
84
+ res.end(JSON.stringify([...list, ...answers]));
85
+ }
@@ -5,11 +5,13 @@ import type { RelayTarget } from './relay-target.js';
5
5
  import type { AgentIdentity } from '@metro-labs/http/api-http';
6
6
  import { ApiError } from '@metro-labs/http/api-error';
7
7
  import { errMsg, log } from '@metro-labs/core/log';
8
+ import { answerBlocked, mergeBlocked, screenCalls, type BlockedReason } from './relay-policy.js';
8
9
 
9
10
  export interface RelayApiDeps {
10
11
  target: (connectorId: string, force: boolean) => Promise<RelayTarget>;
11
12
  identify: (req: IncomingMessage) => AgentIdentity | null;
12
13
  signedOut: (connectorId: string) => void;
14
+ blocked?: BlockedReason;
13
15
  }
14
16
 
15
17
  const ID_PATH_RE = /^\/relay\/([A-Za-z0-9][A-Za-z0-9_-]{10})$/;
@@ -172,12 +174,21 @@ async function pumpBody(
172
174
  }
173
175
  }
174
176
 
175
- async function pipe(res: ServerResponse, upstream: Response): Promise<void> {
177
+ function passedHeaders(upstream: Response): Record<string, string> {
176
178
  const headers: Record<string, string> = {};
177
179
  for (const name of PASS_RES) {
178
180
  const value = upstream.headers.get(name);
179
181
  if (value !== null) headers[name] = value;
180
182
  }
183
+ return headers;
184
+ }
185
+
186
+ async function pipe(res: ServerResponse, upstream: Response, answers: Record<string, unknown>[]): Promise<void> {
187
+ const headers = passedHeaders(upstream);
188
+ if (answers.length > 0 && (upstream.status === 200 || upstream.status === 202)) {
189
+ await mergeBlocked(res, upstream, headers, answers);
190
+ return;
191
+ }
181
192
  const sse = (headers['content-type'] ?? '').includes('text/event-stream');
182
193
  if (sse) headers['x-accel-buffering'] = 'no';
183
194
  res.writeHead(upstream.status, headers);
@@ -246,21 +257,13 @@ async function relayExchange(
246
257
  }
247
258
  }
248
259
 
249
- async function relayOnce(
250
- req: IncomingMessage,
260
+ async function deliver(
251
261
  res: ServerResponse,
252
262
  connectorId: string,
253
263
  deps: RelayApiDeps,
254
- signal: AbortSignal,
264
+ out: Exchanged,
265
+ answers: Record<string, unknown>[],
255
266
  ): Promise<void> {
256
- const body = req.method === 'POST' ? await readCapped(req) : null;
257
- const out = await exchange(
258
- req,
259
- connectorId,
260
- deps,
261
- body,
262
- signal,
263
- );
264
267
  if (out.kind === 'missing') {
265
268
  answer(res, 404, { error: 'no such connector' });
266
269
  return;
@@ -279,7 +282,24 @@ async function relayOnce(
279
282
  }
280
283
  const { status } = out.upstream;
281
284
  noteHealth(connectorId, status < 400, status < 400 ? null : `the connector answered ${String(status)}`);
282
- await pipe(res, out.upstream);
285
+ await pipe(res, out.upstream, answers);
286
+ }
287
+
288
+ async function relayOnce(
289
+ req: IncomingMessage,
290
+ res: ServerResponse,
291
+ connectorId: string,
292
+ deps: RelayApiDeps,
293
+ signal: AbortSignal,
294
+ ): Promise<void> {
295
+ const read = req.method === 'POST' ? await readCapped(req) : null;
296
+ const screened = read === null || deps.blocked === undefined ? null : screenCalls(read, connectorId, deps.blocked);
297
+ if (screened?.forward === null) {
298
+ answerBlocked(res, screened);
299
+ return;
300
+ }
301
+ const out = await exchange(req, connectorId, deps, screened === null ? read : screened.forward, signal);
302
+ await deliver(res, connectorId, deps, out, screened?.answers ?? []);
283
303
  }
284
304
 
285
305
  function dispatch(