@stage-labs/metro 0.1.0-beta.293 → 0.1.0-beta.295

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +42 -5
  4. package/runtime/node_modules/@metro-labs/daemon/src/channels/relay.ts +4 -0
  5. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +3 -1
  6. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +3 -3
  7. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +12 -6
  8. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +16 -3
  9. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +1 -1
  10. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +8 -1
  11. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +2 -2
  12. package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +1 -85
  13. package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +1 -1
  14. package/runtime/node_modules/@metro-labs/daemon/src/mcp/raw-get-stream.ts +25 -13
  15. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-slot.ts +25 -6
  16. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +4 -0
  17. package/runtime/node_modules/@metro-labs/http/package.json +1 -0
  18. package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +4 -3
  19. package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +2 -2
  20. package/runtime/node_modules/@metro-labs/http/src/connector-policy.ts +39 -3
  21. package/runtime/node_modules/@metro-labs/http/src/connector-tools.ts +94 -0
  22. package/runtime/runtime.json +1 -1
  23. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/runner-activity.ts +42 -5
  24. package/runtime/sdk-runner/runner.json +1 -1
  25. package/runtime/sdk-runner/src/activity.ts +14 -20
  26. package/runtime/sdk-runner/src/app.ts +2 -2
  27. package/runtime/sdk-runner/src/errors.ts +131 -0
  28. package/runtime/sdk-runner/src/main.ts +4 -2
  29. package/runtime/sdk-runner/src/runner.ts +25 -11
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.295",
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.295",
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",
@@ -26,6 +26,9 @@ const FAILURE = {
26
26
  status_unavailable: 'The SDK status file cannot be written. Check its path and permissions before starting.',
27
27
  saved_state: 'The saved Agent SDK state cannot be read. Restore it before starting.',
28
28
  executable: 'The SDK executable could not start. Prepare the SDK runtime again.',
29
+ module: 'The SDK runtime could not load a required module. Prepare the SDK runtime again.',
30
+ configuration: 'The SDK launch configuration is missing or invalid. Check Harness and the Model page before starting.',
31
+ process_exit: 'The SDK process exited unexpectedly. Check its reported exit code or signal before retrying.',
29
32
  connection: 'The SDK connection failed. Check the server and provider connection.',
30
33
  session_ended: 'The Agent SDK session ended unexpectedly.',
31
34
  interrupted: 'The SDK stopped before finishing a chat request. Check Conversations before retrying; actions may already have run.',
@@ -33,8 +36,40 @@ const FAILURE = {
33
36
  } as const;
34
37
  export type RunnerFailureCode = keyof typeof FAILURE;
35
38
 
36
- export function runnerFailureSummary(code: RunnerFailureCode, tool: string | null = null): string {
37
- return code === 'tool_error' && name(tool) !== null ? `${tool} failed. See its result in Conversations.` : FAILURE[code];
39
+ const ERROR_CODES = ['ENOENT', 'EACCES', 'EPERM', 'ENOEXEC', 'ENOMEM', 'ERR_MODULE_NOT_FOUND', 'MODULE_NOT_FOUND', 'ERR_DLOPEN_FAILED', 'ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'ETIMEDOUT', 'CERT_HAS_EXPIRED', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'] as const;
40
+ const SIGNALS = ['SIGTERM', 'SIGKILL', 'SIGSEGV', 'SIGABRT', 'SIGBUS', 'SIGILL', 'SIGINT', 'SIGHUP', 'SIGPIPE'] as const;
41
+
42
+ export interface RunnerDiagnostic {
43
+ source: 'exception' | 'stderr';
44
+ errorCode?: (typeof ERROR_CODES)[number];
45
+ httpStatus?: number;
46
+ exitCode?: number;
47
+ signal?: (typeof SIGNALS)[number];
48
+ }
49
+
50
+ const boundedNumber = (value: unknown, min: number, max: number): number | undefined => typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max ? value : undefined;
51
+
52
+ export function runnerDiagnostic(raw: unknown): RunnerDiagnostic | undefined {
53
+ if (!isRecord(raw) || (raw.source !== 'exception' && raw.source !== 'stderr')) return undefined;
54
+ const errorCode = ERROR_CODES.find((code) => code === raw.errorCode);
55
+ const signal = SIGNALS.find((value) => value === raw.signal);
56
+ const httpStatus = boundedNumber(raw.httpStatus, 400, 599);
57
+ const exitCode = boundedNumber(raw.exitCode, 0, 255);
58
+ return {
59
+ source: raw.source,
60
+ ...(errorCode === undefined ? {} : { errorCode }),
61
+ ...(httpStatus === undefined ? {} : { httpStatus }),
62
+ ...(exitCode === undefined ? {} : { exitCode }),
63
+ ...(signal === undefined ? {} : { signal }),
64
+ };
65
+ }
66
+
67
+ export function runnerFailureSummary(code: RunnerFailureCode, tool: string | null = null, diagnostic?: RunnerDiagnostic): string {
68
+ const summary = code === 'tool_error' && name(tool) !== null ? `${tool} failed. See its result in Conversations.` : FAILURE[code];
69
+ const safe = runnerDiagnostic(diagnostic);
70
+ if (safe === undefined) return summary;
71
+ const details = [safe.errorCode, safe.httpStatus === undefined ? null : `HTTP ${safe.httpStatus}`, safe.exitCode === undefined ? null : `exit ${safe.exitCode}`, safe.signal].filter((value) => value !== undefined && value !== null);
72
+ return details.length === 0 ? summary : `${summary} Observed: ${details.join(', ')} (${safe.source === 'stderr' ? 'startup stderr' : 'exception'}).`;
38
73
  }
39
74
 
40
75
  export interface RunnerTask {
@@ -70,6 +105,7 @@ export interface RunnerEvent {
70
105
  toolUseId?: string;
71
106
  taskId: string | null;
72
107
  code?: RunnerFailureCode;
108
+ diagnostic?: RunnerDiagnostic;
73
109
  }
74
110
 
75
111
  export interface RunnerFailure extends RunnerEvent {
@@ -146,16 +182,17 @@ function tool(raw: unknown): RunnerTool | null {
146
182
  return { id: String(raw.id), name: String(raw.name), taskId: name(raw.taskId), startedAt: count(raw.startedAt), ...(typeof raw.worker === 'boolean' ? { worker: raw.worker } : {}) };
147
183
  }
148
184
 
149
- const failureCode = (value: unknown): value is RunnerFailureCode => typeof value === 'string' && Object.hasOwn(FAILURE, value);
185
+ export const runnerFailureCode = (value: unknown): value is RunnerFailureCode => typeof value === 'string' && Object.hasOwn(FAILURE, value);
150
186
  const failureKind = (value: RunnerEventKind): boolean => ['turn_failed', 'tool_failed', 'task_failed', 'compact_failed', 'session_failed'].includes(value);
151
187
 
152
188
  function event(raw: unknown): RunnerEvent | null {
153
189
  if (!isRecord(raw) || !eventKind(raw.kind) || count(raw.at) === 0) return null;
190
+ const diagnostic = runnerDiagnostic(raw.diagnostic);
154
191
  return {
155
192
  at: count(raw.at), kind: raw.kind, tool: name(raw.tool), taskId: name(raw.taskId),
156
193
  ...(name(raw.id) === null ? {} : { id: String(raw.id) }),
157
194
  ...(name(raw.toolUseId) === null ? {} : { toolUseId: String(raw.toolUseId) }),
158
- ...(failureKind(raw.kind) && failureCode(raw.code) ? { code: raw.code } : {}),
195
+ ...(failureKind(raw.kind) && runnerFailureCode(raw.code) ? { code: raw.code, ...(diagnostic === undefined ? {} : { diagnostic }) } : {}),
159
196
  };
160
197
  }
161
198
 
@@ -184,7 +221,7 @@ function processIdentity(value: unknown): Pick<RunnerActivity, 'procStart'> {
184
221
  function failureFields(raw: Record<string, unknown>): Pick<RunnerActivity, 'lastError' | 'activeFailure' | 'lastFailure'> {
185
222
  const activeFailure = failure(raw.activeFailure);
186
223
  return {
187
- lastError: Object.hasOwn(raw, 'activeFailure') ? activeFailure === null ? null : runnerFailureSummary(activeFailure.code, activeFailure.tool) : safeError(raw.lastError),
224
+ lastError: Object.hasOwn(raw, 'activeFailure') ? activeFailure === null ? null : runnerFailureSummary(activeFailure.code, activeFailure.tool, activeFailure.diagnostic) : safeError(raw.lastError),
188
225
  ...(Object.hasOwn(raw, 'activeFailure') ? { activeFailure } : {}),
189
226
  ...(Object.hasOwn(raw, 'lastFailure') ? { lastFailure: failure(raw.lastFailure) } : {}),
190
227
  };
@@ -42,6 +42,10 @@ export class ChannelRelay {
42
42
  });
43
43
  }
44
44
 
45
+ whenIdle(): Promise<void> {
46
+ return this.chain;
47
+ }
48
+
45
49
  replayMissed(): void {
46
50
  const missed = bufferedSince(this.ledger.startAt).filter(
47
51
  (b) => !this.ledger.delivered.has(b.busSeq),
@@ -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';
@@ -84,7 +84,7 @@ async function serveGet(
84
84
  scope: session.scope,
85
85
  req,
86
86
  res,
87
- previous: session.currentSink,
87
+ previous: () => session.currentSink,
88
88
  log: channelLog,
89
89
  registerSink: (sink) => {
90
90
  session.bindSink(sink);
@@ -55,6 +55,9 @@ export interface RawGetSink {
55
55
  close: () => void;
56
56
  }
57
57
 
58
+ const streamEnded = (sink: RawGetSink, res: ServerResponse): boolean =>
59
+ sink.closed || res.destroyed || res.writableEnded;
60
+
58
61
  export interface ValidateResult {
59
62
  ok: boolean;
60
63
  status?: number;
@@ -93,6 +96,7 @@ export interface ServeOpts {
93
96
  res: ServerResponse;
94
97
  log: (...a: unknown[]) => void;
95
98
  registerSink: (sink: RawGetSink | undefined) => void;
99
+ previous?: () => RawGetSink | undefined;
96
100
  }
97
101
 
98
102
  export async function serveStandaloneGet(opts: ServeOpts): Promise<void> {
@@ -111,13 +115,17 @@ export async function serveStandaloneGet(opts: ServeOpts): Promise<void> {
111
115
  res.write(COMMENT);
112
116
 
113
117
  let active = transport;
118
+ let entry: StreamEntry | undefined;
114
119
  const timers: { keepalive?: ReturnType<typeof setInterval> } = {};
115
120
  const closeStream = (): void => {
116
121
  if (sink.closed) return;
117
122
  sink.closed = true;
118
123
  if (timers.keepalive) clearInterval(timers.keepalive);
119
- web(active)?._streamMapping?.delete(STANDALONE_STREAM_ID);
120
- registerSink(undefined);
124
+ const mapping = web(active)?._streamMapping;
125
+ if (entry !== undefined && mapping?.get(STANDALONE_STREAM_ID) === entry) {
126
+ mapping.delete(STANDALONE_STREAM_ID);
127
+ registerSink(undefined);
128
+ }
121
129
  try {
122
130
  res.end();
123
131
  } catch {
@@ -128,11 +136,10 @@ export async function serveStandaloneGet(opts: ServeOpts): Promise<void> {
128
136
  closed: false,
129
137
  attach: (next: StreamableHTTPServerTransport): void => {
130
138
  active = next;
131
- const entry: StreamEntry = {
139
+ entry = {
132
140
  controller: {
133
141
  enqueue: (chunk: Uint8Array): void => {
134
- if (sink.closed) return;
135
- if (res.destroyed || res.writableEnded) {
142
+ if (streamEnded(sink, res)) {
136
143
  closeStream();
137
144
  return;
138
145
  }
@@ -147,6 +154,10 @@ export async function serveStandaloneGet(opts: ServeOpts): Promise<void> {
147
154
  close: closeStream,
148
155
  };
149
156
 
157
+ res.on('close', closeStream);
158
+ res.on('error', closeStream);
159
+ req.on('close', closeStream);
160
+
150
161
  const lastEventId = headerValue(req, 'last-event-id');
151
162
  if (lastEventId) {
152
163
  await eventStore.replayEventsAfter(lastEventId, {
@@ -168,12 +179,17 @@ export async function serveStandaloneGet(opts: ServeOpts): Promise<void> {
168
179
  });
169
180
  }
170
181
 
182
+ if (streamEnded(sink, res)) {
183
+ closeStream();
184
+ return;
185
+ }
186
+ opts.previous?.()?.close();
171
187
  sink.attach(transport);
172
188
  registerSink(sink);
189
+ if (sink.closed) return;
173
190
 
174
191
  timers.keepalive = setInterval(() => {
175
- if (sink.closed) return;
176
- if (res.destroyed || res.writableEnded) {
192
+ if (streamEnded(sink, res)) {
177
193
  closeStream();
178
194
  return;
179
195
  }
@@ -185,14 +201,10 @@ export async function serveStandaloneGet(opts: ServeOpts): Promise<void> {
185
201
  }
186
202
  }, KEEPALIVE_MS);
187
203
  timers.keepalive.unref?.();
188
-
189
- res.on('close', closeStream);
190
- res.on('error', closeStream);
191
- req.on('close', closeStream);
192
204
  }
193
205
 
194
206
  export interface ChannelGetOpts extends ServeOpts {
195
- previous: RawGetSink | undefined;
207
+ previous: () => RawGetSink | undefined;
196
208
  }
197
209
 
198
210
  export async function serveChannelGet(opts: ChannelGetOpts): Promise<boolean> {
@@ -201,7 +213,7 @@ export async function serveChannelGet(opts: ChannelGetOpts): Promise<boolean> {
201
213
  opts.res.writeHead(check.status ?? 400).end(check.message ?? 'bad request');
202
214
  return false;
203
215
  }
204
- if (opts.previous && !opts.previous.closed) opts.previous.close();
216
+ opts.previous()?.close();
205
217
  await serveStandaloneGet(opts);
206
218
  return true;
207
219
  }
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { currentBusSeq } from '@metro-labs/core/events';
3
+ import { errMsg } from '@metro-labs/core/log';
3
4
  import { newReplayLedger } from '../channels/relay.js';
4
5
  import { McpSession, channelLog, type SessionKind } from './session.js';
5
6
  import { allowedAgents, type RequestIdentity } from './request-identity.js';
@@ -9,6 +10,7 @@ export class SessionSlot {
9
10
  private readonly ledger = newReplayLedger();
10
11
  private started = false;
11
12
  private live: boolean;
13
+ private changing: Promise<void> = Promise.resolve();
12
14
 
13
15
  constructor(
14
16
  live = true,
@@ -45,10 +47,20 @@ export class SessionSlot {
45
47
  if (this.started) this.session?.startChannel();
46
48
  }
47
49
 
48
- async open(
49
- identity: RequestIdentity,
50
- adoptId?: string,
51
- ): Promise<McpSession> {
50
+ open(identity: RequestIdentity, adoptId?: string): Promise<McpSession> {
51
+ return this.change(() => this.replace(identity, adoptId));
52
+ }
53
+
54
+ private change<T>(run: () => Promise<T>): Promise<T> {
55
+ const changed = this.changing.then(run);
56
+ this.changing = changed.then(() => undefined).catch((err: unknown) => {
57
+ channelLog('session: change failed', errMsg(err));
58
+ });
59
+ return changed;
60
+ }
61
+
62
+ private async replace(identity: RequestIdentity, adoptId?: string): Promise<McpSession> {
63
+ if (adoptId !== undefined && this.session?.id === adoptId) return this.session;
52
64
  await this.session?.close();
53
65
  if (this.ledger.startAt < 0) this.ledger.startAt = currentBusSeq();
54
66
  const id = adoptId ?? randomUUID();
@@ -61,6 +73,11 @@ export class SessionSlot {
61
73
  live: () => this.live,
62
74
  onClosed: (s) => {
63
75
  if (this.session === s) this.session = undefined;
76
+ s.channel.whenIdle().then(() => {
77
+ if (this.session?.streamAttached) this.session.replayMissed();
78
+ }).catch((err: unknown) => {
79
+ channelLog('session: handoff replay failed', errMsg(err));
80
+ });
64
81
  },
65
82
  });
66
83
  this.session = session;
@@ -69,7 +86,9 @@ export class SessionSlot {
69
86
  return session;
70
87
  }
71
88
 
72
- async close(): Promise<void> {
73
- await this.session?.close();
89
+ close(): Promise<void> {
90
+ return this.change(async () => {
91
+ await this.session?.close();
92
+ });
74
93
  }
75
94
  }
@@ -177,6 +177,10 @@ export class McpSession {
177
177
  }
178
178
 
179
179
  bindSink(sink: RawGetSink | undefined): void {
180
+ if (this.closed) {
181
+ sink?.close();
182
+ return;
183
+ }
180
184
  this.sink = sink;
181
185
  if (sink !== undefined) {
182
186
  this.announceCallState();
@@ -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 {