@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.
- package/dist/onboarding.js +4 -3
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/bin/guard.mjs +22 -6
- package/runtime/marketplace/plugin/bin/policy.mjs +97 -0
- package/runtime/marketplace/plugin/orchestrator.md +2 -0
- package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +4 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/account-routes.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +10 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +17 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/files.ts +10 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/policy-route.ts +27 -0
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/api.ts +55 -0
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +126 -0
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/preview.ts +66 -0
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +10 -1
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +11 -38
- package/runtime/node_modules/@metro-labs/daemon/src/channels/react-note.ts +13 -0
- package/runtime/node_modules/@metro-labs/daemon/src/channels/reply-id.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +34 -12
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +19 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +63 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +9 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-policy.ts +85 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +33 -13
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +46 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/accounts.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/attachment-schema.ts +91 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +18 -21
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +87 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +42 -33
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +77 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +79 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/profile-lookup.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/profile-tool.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +8 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-catalog.ts +88 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-def.ts +9 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +20 -31
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +20 -91
- package/runtime/node_modules/@metro-labs/daemon/src/policy/policy.ts +118 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +8 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +10 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/tools.ts +6 -0
- 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
|
|
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(
|
|
265
|
-
const
|
|
266
|
-
if (
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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 (
|
|
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([
|
|
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
|
|
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
|
|
33
|
+
export function serverKeysOf(rows: PluginRow[]): Map<string, string> {
|
|
34
34
|
const taken = new Map<string, number>();
|
|
35
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
250
|
-
req: IncomingMessage,
|
|
260
|
+
async function deliver(
|
|
251
261
|
res: ServerResponse,
|
|
252
262
|
connectorId: string,
|
|
253
263
|
deps: RelayApiDeps,
|
|
254
|
-
|
|
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(
|