@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.293",
3
+ "version": "0.1.0-beta.294",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.293",
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
- return delivered?.state === 'ready' && delivered.name === record.name && samePolicy(delivered.policy, record.policy);
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 { DeliveredConnector } from '@metro-labs/http/connector-delivery';
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.filter((tool) => /^[A-Za-z0-9_.-]{1,128}$/.test(tool.name)).map((tool) => [tool.name, tool.readOnly ? 'read' : 'write']));
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 { DeliveredConnector } from '@metro-labs/http/connector-delivery';
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: structuredClone(row.policy), toolGroups: { ...this.entries.get(row.id)?.groups } },
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) throw entry.error;
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) throw orgUnavailable();
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
- if (this.runtime === null) throw new ApiError('Organization connectors are not available on this agent.', 503);
170
- return this.runtime.open(id);
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
- import { isRecord } from '@metro-labs/core/is-record';
2
- import { errMsg, log } from '@metro-labs/core/log';
3
- import type { FetchLike } from '@metro-labs/core/stations/oauth';
4
- import { authHeaders, INITIALIZE, mcpPost, payloadOf, type ConnectorAuth } from '@metro-labs/http/connector-mcp';
5
- import { refused } from '@metro-labs/http/connector-url';
6
-
7
- export interface RemoteTool {
8
- name: string;
9
- title: string;
10
- description: string;
11
- readOnly: boolean;
12
- }
13
-
14
- const TIMEOUT_MS = 15_000;
15
- const MAX_PAGES = 10;
16
-
17
- function resultOf(text: string, contentType: string): Record<string, unknown> | null {
18
- let parsed: unknown = null;
19
- try {
20
- parsed = JSON.parse(payloadOf(text, contentType));
21
- } catch {
22
- return null;
23
- }
24
- return isRecord(parsed) && isRecord(parsed.result) ? parsed.result : null;
25
- }
26
-
27
- async function rpc(url: URL, auth: ConnectorAuth, session: Map<string, string>, body: unknown, fetchImpl?: FetchLike): Promise<Record<string, unknown> | null> {
28
- const res = await mcpPost(url, auth, body, { timeoutMs: TIMEOUT_MS, headers: Object.fromEntries(session), fetch: fetchImpl });
29
- const id = res.headers.get('mcp-session-id');
30
- if (id !== null && id !== '') session.set('mcp-session-id', id);
31
- const text = await res.text().catch(() => '');
32
- if (res.status === 401 || res.status === 403) throw refused(`${url.hostname} rejected the credential; connect it again`);
33
- if (!res.ok) throw refused(`${url.hostname} answered ${String(res.status)}`);
34
- return resultOf(text, res.headers.get('content-type') ?? '');
35
- }
36
-
37
- function toTool(raw: unknown): RemoteTool | null {
38
- if (!isRecord(raw) || typeof raw.name !== 'string') return null;
39
- const annotations = isRecord(raw.annotations) ? raw.annotations : {};
40
- const title = typeof raw.title === 'string' && raw.title !== '' ? raw.title : raw.name;
41
- return { name: raw.name, title, description: typeof raw.description === 'string' ? raw.description : '', readOnly: annotations.readOnlyHint === true };
42
- }
43
-
44
- async function endSession(url: URL, auth: ConnectorAuth, session: Map<string, string>, fetchImpl?: FetchLike): Promise<void> {
45
- if (!session.has('mcp-session-id')) return;
46
- try {
47
- const send = fetchImpl ?? fetch;
48
- const res = await send(url.toString(), { method: 'DELETE', redirect: 'manual', signal: AbortSignal.timeout(TIMEOUT_MS), headers: { ...authHeaders(auth), ...Object.fromEntries(session) } });
49
- if (res.ok) await res.body?.pipeTo(new WritableStream<Uint8Array>());
50
- else await res.body?.cancel();
51
- } catch (err) {
52
- log.debug(fetchImpl === undefined ? { host: url.hostname, err: errMsg(err) } : {}, 'connector-tools: the remote declined to end the session');
53
- }
54
- }
55
-
56
- const toolsOf = (result: Record<string, unknown>): RemoteTool[] =>
57
- Array.isArray(result.tools) ? result.tools.flatMap((raw: unknown) => toTool(raw) ?? []) : [];
58
-
59
- function nextCursor(result: Record<string, unknown>, current: string): string | null {
60
- const next = result.nextCursor;
61
- return typeof next !== 'string' || next === '' || next === current ? null : next;
62
- }
63
-
64
- async function walkTools(url: URL, auth: ConnectorAuth, session: Map<string, string>, fetchImpl?: FetchLike): Promise<RemoteTool[]> {
65
- const tools: RemoteTool[] = [];
66
- let cursor: string | null = '';
67
- for (let page = 0; page < MAX_PAGES && cursor !== null; page += 1) {
68
- const result = await rpc(url, auth, session, { jsonrpc: '2.0', id: 2 + page, method: 'tools/list', params: cursor === '' ? {} : { cursor } }, fetchImpl);
69
- if (result === null) break;
70
- tools.push(...toolsOf(result));
71
- cursor = nextCursor(result, cursor);
72
- }
73
- return tools;
74
- }
75
-
76
- export async function listRemoteTools(url: URL, auth: ConnectorAuth, fetchImpl?: FetchLike): Promise<RemoteTool[]> {
77
- const session = new Map<string, string>();
78
- const init = await rpc(url, auth, session, INITIALIZE, fetchImpl);
79
- if (init === null || typeof init.protocolVersion !== 'string') throw refused(`${url.hostname} answered, but it does not speak MCP.`);
80
- session.set('mcp-protocol-version', init.protocolVersion);
81
- await rpc(url, auth, session, { jsonrpc: '2.0', method: 'notifications/initialized' }, fetchImpl);
82
- const tools = await walkTools(url, auth, session, fetchImpl);
83
- await endSession(url, auth, session, fetchImpl);
84
- return tools;
85
- }
1
+ export { listRemoteTools, type RemoteTool } from '@metro-labs/http/connector-tools';
@@ -18,6 +18,7 @@
18
18
  "./connector-resource": "./src/connector-resource.ts",
19
19
  "./connector-reach": "./src/connector-reach.ts",
20
20
  "./connector-mcp": "./src/connector-mcp.ts",
21
+ "./connector-tools": "./src/connector-tools.ts",
21
22
  "./connector-discovery": "./src/connector-discovery.ts",
22
23
  "./connector-oauth": "./src/connector-oauth.ts",
23
24
  "./connector-input": "./src/connector-input.ts",
@@ -1,7 +1,7 @@
1
1
  import { parseId } from '@metro-labs/core/ids';
2
2
  import { isRecord } from '@metro-labs/core/is-record';
3
3
  import { connectorName } from './connector-input.js';
4
- import { connectorPolicyOf, type ConnectorPolicy } from './connector-policy.js';
4
+ import { connectorPolicyLayersOf, connectorPolicyOf, type ConnectorPolicy, type ConnectorPolicyLayers } from './connector-policy.js';
5
5
  import { parsePublicConnectorUrl } from './connector-public-url.js';
6
6
  import { isOrganizationId } from './workos-token.js';
7
7
 
@@ -23,6 +23,7 @@ export interface DeliveredConnector {
23
23
  auth: 'none' | 'header' | 'oauth';
24
24
  headers: Record<string, string>;
25
25
  policy: ConnectorPolicy;
26
+ policyLayers?: ConnectorPolicyLayers;
26
27
  revision: string;
27
28
  state: 'ready' | 'signin' | 'unavailable';
28
29
  expiresAt?: number;
@@ -91,7 +92,7 @@ function urlOf(value: unknown): string {
91
92
  }
92
93
 
93
94
  function connectorOf(raw: unknown): DeliveredConnector {
94
- const value = deliveryRecord(raw, ['id', 'name', 'url', 'auth', 'headers', 'policy', 'revision', 'state', 'expiresAt']);
95
+ const value = deliveryRecord(raw, ['id', 'name', 'url', 'auth', 'headers', 'policy', 'policyLayers', 'revision', 'state', 'expiresAt']);
95
96
  const id = parseId(value.id);
96
97
  if (id === null || typeof value.name !== 'string' || connectorName(value.name) !== value.name) throw new Error('Invalid delivered connector.');
97
98
  const url = urlOf(value.url);
@@ -99,7 +100,7 @@ function connectorOf(raw: unknown): DeliveredConnector {
99
100
  const state = stateOf(value.state, auth);
100
101
  deliveryBytes(value.revision, 32);
101
102
  if (Object.hasOwn(value, 'expiresAt') && !timestamp(value.expiresAt)) throw new Error('Invalid delivered connector expiry.');
102
- return { id, name: value.name, url, auth, state, revision: String(value.revision), headers: headersOf(value.headers, auth, state), policy: connectorPolicyOf(value.policy), ...(timestamp(value.expiresAt) ? { expiresAt: value.expiresAt } : {}) };
103
+ return { id, name: value.name, url, auth, state, revision: String(value.revision), headers: headersOf(value.headers, auth, state), policy: connectorPolicyOf(value.policy), ...(Object.hasOwn(value, 'policyLayers') ? { policyLayers: connectorPolicyLayersOf(value.policyLayers) } : {}), ...(timestamp(value.expiresAt) ? { expiresAt: value.expiresAt } : {}) };
103
104
  }
104
105
 
105
106
  export function deliverySnapshotOf(raw: unknown): DeliverySnapshot {
@@ -2,8 +2,8 @@ import type { KeyObject } from 'node:crypto';
2
2
  import { CONNECTOR_DELIVERY_MAX_BYTES, DELIVERY_PLAINTEXT_MAX, deliveryContextBytes, deliverySnapshotOf, type DeliveryContext, type DeliverySnapshot } from './connector-delivery-validation.js';
3
3
  import { openTransfer, sealTransfer, type SealedTransfer } from './transfer-crypto.js';
4
4
 
5
- export { CONNECTOR_DELIVERY_LEASE_MS, CONNECTOR_DELIVERY_MAX_BYTES, deliverySnapshotOf, type DeliveryContext, type DeliverySnapshot, type DeliveredConnector } from './connector-delivery-validation.js';
6
- export { combineConnectorPolicies, connectorPolicyOf, type ConnectorPolicy } from './connector-policy.js';
5
+ export { CONNECTOR_DELIVERY_LEASE_MS, CONNECTOR_DELIVERY_MAX_BYTES, DELIVERY_PLAINTEXT_MAX, deliverySnapshotOf, type DeliveryContext, type DeliverySnapshot, type DeliveredConnector } from './connector-delivery-validation.js';
6
+ export { combineConnectorPolicies, connectorPolicyOf, effectiveConnectorPolicy, connectorPoliciesBlocked, validConnectorToolName, type ConnectorPolicy, type ConnectorPolicyLayers } from './connector-policy.js';
7
7
  export type SealedDelivery = SealedTransfer;
8
8
 
9
9
  export function sealConnectorDelivery(publicKey: string, context: DeliveryContext, snapshot: DeliverySnapshot): SealedDelivery {
@@ -2,15 +2,24 @@ import { isRecord } from '@metro-labs/core/is-record';
2
2
 
3
3
  export type ConnectorDecision = 'allow' | 'ask' | 'deny';
4
4
 
5
+ type ToolGroup = 'read' | 'write';
6
+
5
7
  export interface ConnectorPolicy {
6
8
  read?: ConnectorDecision;
7
9
  write?: ConnectorDecision;
8
10
  tools?: Record<string, ConnectorDecision>;
9
11
  }
10
12
 
13
+ export interface ConnectorPolicyLayers {
14
+ organization: ConnectorPolicy;
15
+ agent: ConnectorPolicy;
16
+ }
17
+
11
18
  const RANK: Record<ConnectorDecision, number> = { allow: 0, ask: 1, deny: 2 };
12
19
  const TOOL_RE = /^[A-Za-z0-9_.:/-]{1,256}$/;
13
20
 
21
+ export const validConnectorToolName = (name: string): boolean => TOOL_RE.test(name) && !['__proto__', 'constructor', 'prototype'].includes(name);
22
+
14
23
  const decision = (value: unknown): value is ConnectorDecision => value === 'allow' || value === 'ask' || value === 'deny';
15
24
 
16
25
  export function connectorPolicyOf(value: unknown): ConnectorPolicy {
@@ -30,15 +39,23 @@ function toolPolicyOf(value: unknown): Record<string, ConnectorDecision> {
30
39
  if (!isRecord(value) || Object.keys(value).length > 2000) throw new Error('Invalid connector policy.');
31
40
  const out: Record<string, ConnectorDecision> = {};
32
41
  for (const [name, rule] of Object.entries(value)) {
33
- if (!TOOL_RE.test(name) || ['__proto__', 'constructor', 'prototype'].includes(name) || !decision(rule)) throw new Error('Invalid connector policy.');
42
+ if (!validConnectorToolName(name) || !decision(rule)) throw new Error('Invalid connector policy.');
34
43
  out[name] = rule;
35
44
  }
36
45
  return out;
37
46
  }
38
47
 
48
+ export function connectorPolicyLayersOf(value: unknown): ConnectorPolicyLayers {
49
+ if (!isRecord(value) || Object.keys(value).some((key) => !['organization', 'agent'].includes(key))) throw new Error('Invalid connector policy layers.');
50
+ return { organization: connectorPolicyOf(value.organization), agent: connectorPolicyOf(value.agent) };
51
+ }
52
+
39
53
  const strictest = (...values: (ConnectorDecision | undefined)[]): ConnectorDecision => values.reduce<ConnectorDecision>((held, next) => next !== undefined && RANK[next] > RANK[held] ? next : held, 'allow');
54
+ const toolRule = (policy: ConnectorPolicy, name: string): ConnectorDecision | undefined => policy.tools !== undefined && Object.hasOwn(policy.tools, name) ? policy.tools[name] : undefined;
55
+ const toolGroup = (groups: Record<string, ToolGroup>, name: string): ToolGroup => Object.hasOwn(groups, name) && groups[name] === 'read' ? 'read' : 'write';
56
+ const access = (policy: ConnectorPolicy, name: string, group: ToolGroup): ConnectorDecision => toolRule(policy, name) ?? policy[group] ?? 'allow';
40
57
 
41
- export function combineConnectorPolicies(global: ConnectorPolicy, assignment: ConnectorPolicy): ConnectorPolicy {
58
+ export function combineConnectorPolicies(global: ConnectorPolicy, assignment: ConnectorPolicy, groups?: Record<string, ToolGroup>): ConnectorPolicy {
42
59
  const policies = [connectorPolicyOf(global), connectorPolicyOf(assignment)];
43
60
  const neutral = (policy: ConnectorPolicy): boolean => (policy.read ?? 'allow') === 'allow' && (policy.write ?? 'allow') === 'allow' && Object.keys(policy.tools ?? {}).length === 0;
44
61
  if (neutral(assignment)) return connectorPolicyOf(global);
@@ -48,9 +65,28 @@ export function combineConnectorPolicies(global: ConnectorPolicy, assignment: Co
48
65
  write: strictest(...policies.map((policy) => policy.write)),
49
66
  };
50
67
  const names = new Set(policies.flatMap((policy) => Object.keys(policy.tools ?? {})));
68
+ if (groups === undefined && names.size > 2000) return { read: 'deny', write: 'deny' };
51
69
  if (names.size > 0) {
52
70
  out.tools = {};
53
- for (const name of names) out.tools[name] = strictest(...policies.flatMap((policy) => [policy.read, policy.write, policy.tools?.[name]]));
71
+ for (const name of names) {
72
+ out.tools[name] = groups === undefined
73
+ ? strictest(...policies.flatMap((policy) => [policy.read, policy.write, toolRule(policy, name)]))
74
+ : strictest(...policies.map((policy) => access(policy, name, toolGroup(groups, name))));
75
+ }
54
76
  }
55
77
  return out;
56
78
  }
79
+
80
+ export function effectiveConnectorPolicy(row: { policy: ConnectorPolicy; policyLayers?: ConnectorPolicyLayers }, groups: Record<string, ToolGroup>): ConnectorPolicy {
81
+ const layers = row.policyLayers;
82
+ return layers === undefined ? connectorPolicyOf(row.policy) : combineConnectorPolicies(layers.organization, layers.agent, groups);
83
+ }
84
+
85
+ export function connectorPoliciesBlocked(layers: ConnectorPolicyLayers): boolean {
86
+ const policies = [layers.organization, layers.agent];
87
+ const names = new Set(policies.flatMap((policy) => Object.keys(policy.tools ?? {})));
88
+ return (['read', 'write'] as const).every((group) =>
89
+ strictest(...policies.map((policy) => policy[group])) === 'deny' &&
90
+ [...names].every((name) => strictest(...policies.map((policy) => access(policy, name, group))) === 'deny'),
91
+ );
92
+ }
@@ -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
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.293"
2
+ "version": "0.1.0-beta.294"
3
3
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.293",
2
+ "version": "0.1.0-beta.294",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",