@stage-labs/metro 0.1.0-beta.293 → 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.
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +12 -6
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +16 -3
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +8 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +1 -85
- package/runtime/node_modules/@metro-labs/http/package.json +1 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +4 -3
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +2 -2
- package/runtime/node_modules/@metro-labs/http/src/connector-policy.ts +39 -3
- package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +94 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.294",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -71,7 +71,9 @@ function callFailure(error: unknown, action: string): ApiError {
|
|
|
71
71
|
|
|
72
72
|
function received(record: MigrationRecord, snapshot: DeliverySnapshot): boolean {
|
|
73
73
|
const delivered = snapshot.connectors.find((row) => row.id === record.destinationId);
|
|
74
|
-
|
|
74
|
+
if (delivered?.state !== 'ready' || delivered.name !== record.name) return false;
|
|
75
|
+
const layers = delivered.policyLayers;
|
|
76
|
+
return layers === undefined ? samePolicy(delivered.policy, record.policy) : samePolicy(layers.organization, record.policy) && samePolicy(layers.agent, {});
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
export class ConnectorMigrations {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { FetchLike } from '@metro-labs/core/stations/oauth';
|
|
2
2
|
import type { ToolGroup } from '@metro-labs/core/stations/types';
|
|
3
3
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
4
|
-
import type
|
|
4
|
+
import { validConnectorToolName, type DeliveredConnector } from '@metro-labs/http/connector-delivery';
|
|
5
5
|
import { MetroApiRefusal } from './metro-api.js';
|
|
6
6
|
import { listRemoteTools } from './tools.js';
|
|
7
7
|
|
|
@@ -106,8 +106,8 @@ export async function discoverOrgGroups(url: string, guard: DiscoveryGuard): Pro
|
|
|
106
106
|
if (failure.error !== null) throw failure.error;
|
|
107
107
|
guard.current();
|
|
108
108
|
guard.signal.throwIfAborted();
|
|
109
|
-
if (tools.length > MAX_TOOLS) throw orgUnavailable();
|
|
110
|
-
return Object.fromEntries(tools.
|
|
109
|
+
if (tools.length > MAX_TOOLS || tools.some((tool) => !validConnectorToolName(tool.name))) throw orgUnavailable();
|
|
110
|
+
return Object.fromEntries(tools.map((tool) => [tool.name, tool.readOnly ? 'read' : 'write']));
|
|
111
111
|
} catch (error) {
|
|
112
112
|
throw orgRuntimeError(error);
|
|
113
113
|
}
|
|
@@ -3,7 +3,7 @@ import { agentsDir } from '../agents/files.js';
|
|
|
3
3
|
import { assertMigrationOutcomeKnown, beginMigrationOperation, migrationRequestEffectful, type MigrationOperation } from './migration-operations.js';
|
|
4
4
|
import type { FetchLike } from '@metro-labs/core/stations/oauth';
|
|
5
5
|
import type { ToolGroup } from '@metro-labs/core/stations/types';
|
|
6
|
-
import type
|
|
6
|
+
import { effectiveConnectorPolicy, type DeliveredConnector } from '@metro-labs/http/connector-delivery';
|
|
7
7
|
import type { ApiError } from '@metro-labs/http/api-error';
|
|
8
8
|
import { requestConnectorBlocker, type GateRow } from './gates.js';
|
|
9
9
|
import { publishOrgConnectors } from './publication.js';
|
|
@@ -49,7 +49,7 @@ interface Session {
|
|
|
49
49
|
|
|
50
50
|
const stampOf = (row: RuntimeRow): string => JSON.stringify([
|
|
51
51
|
row.relayId, row.relayName, row.name, row.url, row.auth, row.policy.read ?? 'allow', row.policy.write ?? 'allow',
|
|
52
|
-
Object.entries(row.policy.tools ?? {}).sort(([a], [b]) => a.localeCompare(b)),
|
|
52
|
+
Object.entries(row.policy.tools ?? {}).sort(([a], [b]) => a.localeCompare(b)), row.policyLayers,
|
|
53
53
|
]);
|
|
54
54
|
|
|
55
55
|
const rowError = (row: DeliveredConnector | undefined): ApiError | null => {
|
|
@@ -94,7 +94,7 @@ export class OrgConnectorRuntime {
|
|
|
94
94
|
private publish(rows: RuntimeRow[]): void {
|
|
95
95
|
const metadata: GateRow[] = rows.filter((row) => row.state === 'ready').map((row) => ({
|
|
96
96
|
id: row.relayId, name: row.relayName,
|
|
97
|
-
config: { policy:
|
|
97
|
+
config: { policy: effectiveConnectorPolicy(row, this.entries.get(row.id)?.groups ?? {}), toolGroups: { ...this.entries.get(row.id)?.groups } },
|
|
98
98
|
}));
|
|
99
99
|
const text = JSON.stringify(metadata);
|
|
100
100
|
if (text === this.published) return;
|
|
@@ -230,15 +230,21 @@ export class OrgConnectorRuntime {
|
|
|
230
230
|
private discover(entry: Entry): Promise<void> | null {
|
|
231
231
|
if (entry.scan !== null) return entry.scan;
|
|
232
232
|
if (performance.now() < entry.nextScan) {
|
|
233
|
-
if (entry.error !== null)
|
|
233
|
+
if (entry.error !== null) {
|
|
234
|
+
log.debug({ stage: 'discovery_backoff', status: entry.error.status }, 'organization connectors: request unavailable');
|
|
235
|
+
throw entry.error;
|
|
236
|
+
}
|
|
234
237
|
return null;
|
|
235
238
|
}
|
|
236
|
-
if (this.scans >= 4)
|
|
239
|
+
if (this.scans >= 4) {
|
|
240
|
+
log.debug({ stage: 'discovery_capacity', active: this.scans }, 'organization connectors: request unavailable');
|
|
241
|
+
throw orgUnavailable();
|
|
242
|
+
}
|
|
237
243
|
this.scans += 1;
|
|
238
244
|
entry.scan = this.scan(entry).catch((error: unknown) => {
|
|
239
245
|
entry.error = orgRuntimeError(error);
|
|
240
246
|
entry.nextScan = performance.now() + 30_000;
|
|
241
|
-
log.debug({ status: entry.error.status }, 'organization connectors: tool discovery failed');
|
|
247
|
+
log.debug({ stage: 'tool_discovery', status: entry.error.status }, 'organization connectors: tool discovery failed');
|
|
242
248
|
throw entry.error;
|
|
243
249
|
}).finally(() => {
|
|
244
250
|
this.scans -= 1;
|
|
@@ -11,6 +11,7 @@ import type { RelaySession } from './relay-session.js';
|
|
|
11
11
|
import type { DeliverySnapshot } from '@metro-labs/http/connector-delivery';
|
|
12
12
|
import { localMigrationIdle, migrationRuntimeMapping } from './migration-guard.js';
|
|
13
13
|
import { publishConnectors } from './publication.js';
|
|
14
|
+
import { orgRuntimeError, orgUnavailable } from './org-runtime-discovery.js';
|
|
14
15
|
|
|
15
16
|
type ControlDeps = Pick<OrgSyncDeps, 'owner' | 'key' | 'monotonic'> & { api?: MetroApi; dir?: string };
|
|
16
17
|
type Context = Pick<ConnectorSyncStatus, 'owner' | 'agentId' | 'scope' | 'enrolled'>;
|
|
@@ -163,10 +164,22 @@ export class OrgConnectorControl {
|
|
|
163
164
|
this.clear();
|
|
164
165
|
}
|
|
165
166
|
|
|
166
|
-
open(id: string): RelaySession {
|
|
167
|
+
async open(id: string): Promise<RelaySession> {
|
|
167
168
|
if (this.runtime === null && !syncOptedIn(this.dir)) throw new ApiError('Organization connectors are not available on this agent.', 503);
|
|
168
169
|
this.reconcile(true);
|
|
169
|
-
|
|
170
|
-
|
|
170
|
+
const runtime = this.runtime;
|
|
171
|
+
const sync = this.sync;
|
|
172
|
+
if (runtime === null || sync === null) throw new ApiError('Organization connectors are not available on this agent.', 503);
|
|
173
|
+
const state = sync.status().state;
|
|
174
|
+
try {
|
|
175
|
+
await sync.recover();
|
|
176
|
+
this.reconcile(false);
|
|
177
|
+
if (this.runtime !== runtime || this.sync !== sync) throw orgUnavailable();
|
|
178
|
+
} catch (error) {
|
|
179
|
+
const safe = orgRuntimeError(error);
|
|
180
|
+
log.debug({ stage: 'receipt_open', state, status: safe.status }, 'organization connectors: request unavailable');
|
|
181
|
+
throw safe;
|
|
182
|
+
}
|
|
183
|
+
return runtime.open(id);
|
|
171
184
|
}
|
|
172
185
|
}
|
|
@@ -13,7 +13,7 @@ function control(): OrgConnectorControl {
|
|
|
13
13
|
return active;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export function orgConnectorRelaySession(id: string): RelaySession | null {
|
|
16
|
+
export function orgConnectorRelaySession(id: string): Promise<RelaySession> | null {
|
|
17
17
|
return isOrgRelayId(id) || migrationSource(id)?.phase === 'active' ? control().open(id) : null;
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -47,6 +47,7 @@ export class OrgConnectorSync {
|
|
|
47
47
|
private wallDeadline = 0;
|
|
48
48
|
private generation = 0;
|
|
49
49
|
private running: Promise<void> | null = null;
|
|
50
|
+
private recovery: Promise<void> | null = null;
|
|
50
51
|
private expiry: ReturnType<typeof setTimeout> | null = null;
|
|
51
52
|
private readonly monotonic: () => number;
|
|
52
53
|
private readonly listeners = new Set<() => void>();
|
|
@@ -153,6 +154,12 @@ export class OrgConnectorSync {
|
|
|
153
154
|
return this.running;
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
recover(): Promise<void> {
|
|
158
|
+
if (this.read() !== null) return Promise.resolve();
|
|
159
|
+
this.recovery ??= this.syncFresh().finally(() => { this.recovery = null; });
|
|
160
|
+
return this.recovery;
|
|
161
|
+
}
|
|
162
|
+
|
|
156
163
|
async syncFresh(refresh?: RefreshRequest): Promise<void> {
|
|
157
164
|
if (this.running !== null) {
|
|
158
165
|
await this.running.catch(() => {
|
|
@@ -167,7 +174,7 @@ export class OrgConnectorSync {
|
|
|
167
174
|
const context = { ...identity.context, requestId };
|
|
168
175
|
const started = this.deps.api.now();
|
|
169
176
|
const clock = this.monotonic();
|
|
170
|
-
const response = await agentCall(this.deps.api, identity.key, '/api/agents/connectors', { requestId, ...(refresh === undefined ? {} : { refresh }) }, { answerMax: CONNECTOR_DELIVERY_MAX_BYTES, timeoutMs: 60_000, signal });
|
|
177
|
+
const response = await agentCall(this.deps.api, identity.key, '/api/agents/connectors?policy_layers=1', { requestId, ...(refresh === undefined ? {} : { refresh }) }, { answerMax: CONNECTOR_DELIVERY_MAX_BYTES, timeoutMs: 60_000, signal });
|
|
171
178
|
this.validate(this.identity());
|
|
172
179
|
if (generation !== this.generation || this.token !== identity.token) throw invalid();
|
|
173
180
|
const snapshot = openConnectorDelivery(identity.key.sealing, context, response.sealed);
|
|
@@ -19,7 +19,7 @@ export interface RelayApiDeps {
|
|
|
19
19
|
identify: (req: IncomingMessage) => { subject: string; agentId: string } | null;
|
|
20
20
|
signedOut: (connectorId: string) => void;
|
|
21
21
|
blocked?: BlockedReason;
|
|
22
|
-
open?: (connectorId: string) => RelaySession | null;
|
|
22
|
+
open?: (connectorId: string) => RelaySession | Promise<RelaySession> | null;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
const ID_PATH_RE = /^\/relay\/((?:org-)?[A-Za-z0-9][A-Za-z0-9_-]{10})$/;
|
|
@@ -306,7 +306,7 @@ async function deliver(
|
|
|
306
306
|
}
|
|
307
307
|
|
|
308
308
|
async function relayOrg(req: IncomingMessage, res: ServerResponse, id: string, deps: RelayApiDeps, read: Uint8Array<ArrayBuffer> | null, signal: AbortSignal): Promise<void> {
|
|
309
|
-
const session = deps.open?.(id);
|
|
309
|
+
const session = await deps.open?.(id);
|
|
310
310
|
if (session === undefined || session === null) throw new ApiError('Organization connector tools are not switched on yet.', 503);
|
|
311
311
|
try {
|
|
312
312
|
const out = await exchangeOrg(id, session, read, signal, (target, body, guard) => {
|
|
@@ -1,85 +1 @@
|
|
|
1
|
-
|
|
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';
|
|
@@ -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 (!
|
|
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)
|
|
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
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
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 './connector-mcp.js';
|
|
5
|
+
import { ConnectorUnauthorized, refused } from './connector-url.js';
|
|
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 = 100;
|
|
16
|
+
const MAX_TOOLS = 2000;
|
|
17
|
+
|
|
18
|
+
function resultOf(text: string, contentType: string): Record<string, unknown> | null {
|
|
19
|
+
let parsed: unknown = null;
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(payloadOf(text, contentType));
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return isRecord(parsed) && isRecord(parsed.result) ? parsed.result : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function rpc(url: URL, auth: ConnectorAuth, session: Map<string, string>, body: unknown, fetchImpl?: FetchLike): Promise<Record<string, unknown> | null> {
|
|
29
|
+
const res = await mcpPost(url, auth, body, { timeoutMs: TIMEOUT_MS, headers: Object.fromEntries(session), fetch: fetchImpl });
|
|
30
|
+
const id = res.headers.get('mcp-session-id');
|
|
31
|
+
if (id !== null && id !== '') session.set('mcp-session-id', id);
|
|
32
|
+
const text = await res.text().catch(() => '');
|
|
33
|
+
if (res.status === 401 || res.status === 403) throw new ConnectorUnauthorized(`${url.hostname} rejected the credential; connect it again`, 400);
|
|
34
|
+
if (!res.ok) throw refused(`${url.hostname} answered ${String(res.status)}`);
|
|
35
|
+
return resultOf(text, res.headers.get('content-type') ?? '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function toTool(raw: unknown): RemoteTool | null {
|
|
39
|
+
if (!isRecord(raw) || typeof raw.name !== 'string') return null;
|
|
40
|
+
const annotations = isRecord(raw.annotations) ? raw.annotations : {};
|
|
41
|
+
const title = typeof raw.title === 'string' && raw.title !== '' ? raw.title : raw.name;
|
|
42
|
+
return { name: raw.name, title, description: typeof raw.description === 'string' ? raw.description : '', readOnly: annotations.readOnlyHint === true };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function endSession(url: URL, auth: ConnectorAuth, session: Map<string, string>, fetchImpl?: FetchLike): Promise<void> {
|
|
46
|
+
if (!session.has('mcp-session-id')) return;
|
|
47
|
+
try {
|
|
48
|
+
const send = fetchImpl ?? fetch;
|
|
49
|
+
const res = await send(url.toString(), { method: 'DELETE', redirect: 'manual', signal: AbortSignal.timeout(TIMEOUT_MS), headers: { ...authHeaders(auth), ...Object.fromEntries(session) } });
|
|
50
|
+
if (res.ok) await res.body?.pipeTo(new WritableStream<Uint8Array>());
|
|
51
|
+
else await res.body?.cancel();
|
|
52
|
+
} catch (err) {
|
|
53
|
+
log.debug(fetchImpl === undefined ? { host: url.hostname, err: errMsg(err) } : {}, 'connector-tools: the remote declined to end the session');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function addTools(result: Record<string, unknown> | null, tools: Map<string, RemoteTool>): unknown {
|
|
58
|
+
if (result === null || !Array.isArray(result.tools)) throw refused('The provider did not return a complete tool list.');
|
|
59
|
+
for (const raw of result.tools) {
|
|
60
|
+
const tool = toTool(raw);
|
|
61
|
+
if (tool === null || tools.has(tool.name)) throw refused('The provider returned an invalid tool list.');
|
|
62
|
+
tools.set(tool.name, tool);
|
|
63
|
+
if (tools.size > MAX_TOOLS) throw refused('The provider returned too many tools.');
|
|
64
|
+
}
|
|
65
|
+
return result.nextCursor;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function walkTools(url: URL, auth: ConnectorAuth, session: Map<string, string>, fetchImpl?: FetchLike): Promise<RemoteTool[]> {
|
|
69
|
+
const tools = new Map<string, RemoteTool>();
|
|
70
|
+
const cursors = new Set<string>();
|
|
71
|
+
let cursor = '';
|
|
72
|
+
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
73
|
+
const result = await rpc(url, auth, session, { jsonrpc: '2.0', id: 2 + page, method: 'tools/list', params: cursor === '' ? {} : { cursor } }, fetchImpl);
|
|
74
|
+
const next = addTools(result, tools);
|
|
75
|
+
if (next === undefined || next === null || next === '') return [...tools.values()];
|
|
76
|
+
if (typeof next !== 'string' || cursors.has(next)) throw refused('The provider did not return a complete tool list.');
|
|
77
|
+
cursors.add(next);
|
|
78
|
+
cursor = next;
|
|
79
|
+
}
|
|
80
|
+
throw refused('The provider did not return a complete tool list.');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function listRemoteTools(url: URL, auth: ConnectorAuth, fetchImpl?: FetchLike): Promise<RemoteTool[]> {
|
|
84
|
+
const session = new Map<string, string>();
|
|
85
|
+
try {
|
|
86
|
+
const init = await rpc(url, auth, session, INITIALIZE, fetchImpl);
|
|
87
|
+
if (init === null || typeof init.protocolVersion !== 'string') throw refused(`${url.hostname} answered, but it does not speak MCP.`);
|
|
88
|
+
session.set('mcp-protocol-version', init.protocolVersion);
|
|
89
|
+
await rpc(url, auth, session, { jsonrpc: '2.0', method: 'notifications/initialized' }, fetchImpl);
|
|
90
|
+
return await walkTools(url, auth, session, fetchImpl);
|
|
91
|
+
} finally {
|
|
92
|
+
await endSession(url, auth, session, fetchImpl);
|
|
93
|
+
}
|
|
94
|
+
}
|
package/runtime/runtime.json
CHANGED