@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.
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/core/src/runner-activity.ts +42 -5
- package/runtime/node_modules/@metro-labs/daemon/src/channels/relay.ts +4 -0
- 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/daemon/src/mcp/index.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/raw-get-stream.ts +25 -13
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-slot.ts +25 -6
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +4 -0
- 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/node_modules/@metro-labs/core/src/runner-activity.ts +42 -5
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/sdk-runner/src/activity.ts +14 -20
- package/runtime/sdk-runner/src/app.ts +2 -2
- package/runtime/sdk-runner/src/errors.ts +131 -0
- package/runtime/sdk-runner/src/main.ts +4 -2
- package/runtime/sdk-runner/src/runner.ts +25 -11
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.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
|
-
|
|
37
|
-
|
|
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
|
|
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) &&
|
|
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
|
};
|
|
@@ -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';
|
|
@@ -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
|
|
120
|
-
|
|
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
|
-
|
|
139
|
+
entry = {
|
|
132
140
|
controller: {
|
|
133
141
|
enqueue: (chunk: Uint8Array): void => {
|
|
134
|
-
if (sink
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
identity
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
73
|
-
|
|
89
|
+
close(): Promise<void> {
|
|
90
|
+
return this.change(async () => {
|
|
91
|
+
await this.session?.close();
|
|
92
|
+
});
|
|
74
93
|
}
|
|
75
94
|
}
|
|
@@ -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 {
|