@stage-labs/metro 0.1.0-beta.286 → 0.1.0-beta.288

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 (50) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/marketplace/plugin/STAGE.md +11 -0
  4. package/runtime/marketplace/plugin/bin/policy.mjs +3 -2
  5. package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +6 -0
  6. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +7 -3
  7. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +2 -0
  8. package/runtime/node_modules/@metro-labs/daemon/src/connectors/agent-key.ts +148 -0
  9. package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +14 -2
  10. package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +2 -72
  11. package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +101 -0
  12. package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +40 -4
  13. package/runtime/node_modules/@metro-labs/daemon/src/connectors/metro-api.ts +92 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +200 -0
  15. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-url.ts +32 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +3 -3
  17. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +4 -7
  18. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-relay.ts +61 -0
  19. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +111 -0
  20. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +246 -0
  21. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +50 -0
  22. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +161 -0
  23. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +11 -1
  24. package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +33 -0
  25. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-id.ts +2 -0
  26. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +10 -0
  27. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-target.ts +4 -0
  28. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +25 -4
  29. package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +10 -20
  30. package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +16 -13
  31. package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +3 -2
  32. package/runtime/node_modules/@metro-labs/daemon/src/mcp/frame-schema.ts +4 -0
  33. package/runtime/node_modules/@metro-labs/daemon/src/policy/policy.ts +2 -1
  34. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +12 -3
  35. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +3 -0
  36. package/runtime/node_modules/@metro-labs/http/package.json +10 -2
  37. package/runtime/node_modules/@metro-labs/http/src/agent-signature.ts +110 -0
  38. package/runtime/node_modules/@metro-labs/http/src/api-http.ts +6 -2
  39. package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +115 -0
  40. package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +54 -0
  41. package/runtime/node_modules/@metro-labs/{daemon/src/connectors/oauth-discovery.ts → http/src/connector-discovery.ts} +31 -14
  42. package/runtime/node_modules/@metro-labs/http/src/connector-input.ts +73 -0
  43. package/runtime/node_modules/@metro-labs/{daemon/src/connectors/verify.ts → http/src/connector-mcp.ts} +16 -16
  44. package/runtime/node_modules/@metro-labs/{daemon/src/connectors/oauth-client.ts → http/src/connector-oauth.ts} +36 -13
  45. package/runtime/node_modules/@metro-labs/http/src/connector-policy.ts +53 -0
  46. package/runtime/node_modules/@metro-labs/{daemon/src/connectors/url.ts → http/src/connector-url.ts} +1 -1
  47. package/runtime/node_modules/@metro-labs/http/src/ed25519.ts +56 -0
  48. package/runtime/runtime.json +1 -1
  49. package/runtime/sdk-runner/runner.json +1 -1
  50. /package/runtime/node_modules/@metro-labs/{daemon/src/connectors/reach.ts → http/src/connector-reach.ts} +0 -0
@@ -0,0 +1,92 @@
1
+ import { isRecord } from '@metro-labs/core/is-record';
2
+ import { ApiError } from '@metro-labs/http/api-error';
3
+ import { signAgentRequest } from '@metro-labs/http/agent-signature';
4
+ import type { AgentKey } from './agent-key.js';
5
+
6
+ const TIMEOUT_MS = 15_000;
7
+ const ANSWER_MAX = 64 * 1024;
8
+
9
+ export type AgentFetch = (url: string, init: RequestInit) => Promise<Response>;
10
+
11
+ export interface MetroApi {
12
+ base: string;
13
+ fetch: AgentFetch;
14
+ now: () => number;
15
+ }
16
+
17
+ export const METRO_API: MetroApi = {
18
+ base: 'https://api.metro.box',
19
+ fetch: (url, init) => fetch(url, init),
20
+ now: () => Date.now(),
21
+ };
22
+
23
+ const tooLarge = (): ApiError => new ApiError('api.metro.box sent an answer too large for Metro.', 503);
24
+ const unreachable = (): ApiError => new ApiError('Metro could not reach api.metro.box. Try again.', 503);
25
+
26
+ async function answerOf(response: Response, max: number): Promise<unknown> {
27
+ if (Number(response.headers.get('content-length') ?? '0') > max) throw tooLarge();
28
+ const reader = response.body?.getReader();
29
+ if (reader === undefined) return null;
30
+ const parts: Uint8Array[] = [];
31
+ let size = 0;
32
+ for (let part = await reader.read(); !part.done; part = await reader.read()) {
33
+ size += part.value.length;
34
+ if (size > max) {
35
+ await reader.cancel();
36
+ throw tooLarge();
37
+ }
38
+ parts.push(part.value);
39
+ }
40
+ try {
41
+ return JSON.parse(Buffer.concat(parts).toString('utf8')) as unknown;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ export class MetroApiRefusal extends ApiError {
48
+ constructor(message: string, readonly upstreamStatus: number, readonly code: string | null) {
49
+ super(message, upstreamStatus >= 500 ? 503 : 400);
50
+ }
51
+ }
52
+
53
+ function refusal(status: number, value: unknown): ApiError {
54
+ const said = isRecord(value) && typeof value.error === 'string' && value.error.length <= 300 ? value.error : `api.metro.box answered ${String(status)}`;
55
+ const code = isRecord(value) && typeof value.code === 'string' ? value.code : null;
56
+ return new MetroApiRefusal(said, status, code);
57
+ }
58
+
59
+ interface AgentCallOptions {
60
+ answerMax?: number;
61
+ timeoutMs?: number;
62
+ }
63
+
64
+ function signedCall(api: MetroApi, key: AgentKey, path: string, body: Record<string, unknown> | undefined): { url: URL; method: string; text: string; authorization: string } {
65
+ const url = new URL(path, api.base);
66
+ const method = body === undefined ? 'GET' : 'POST';
67
+ const text = body === undefined ? '' : JSON.stringify(body);
68
+ const authorization = signAgentRequest({ method, host: url.host, path: `${url.pathname}${url.search}`, body: Buffer.from(text, 'utf8') }, key.signing, key.keyId, api.now());
69
+ return { url, method, text, authorization };
70
+ }
71
+
72
+ export async function agentCall(api: MetroApi, key: AgentKey, path: string, body?: Record<string, unknown>, options: AgentCallOptions = {}): Promise<Record<string, unknown>> {
73
+ const { url, method, text, authorization } = signedCall(api, key, path, body);
74
+ let response: Response;
75
+ try {
76
+ response = await api.fetch(url.toString(), {
77
+ method,
78
+ headers: { authorization, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
79
+ ...(body === undefined ? {} : { body: text }),
80
+ redirect: 'error',
81
+ signal: AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS),
82
+ });
83
+ } catch {
84
+ throw unreachable();
85
+ }
86
+ const value = await answerOf(response, options.answerMax ?? ANSWER_MAX).catch((err: unknown) => {
87
+ throw err instanceof ApiError ? err : unreachable();
88
+ });
89
+ if (!response.ok) throw refusal(response.status, value);
90
+ if (!isRecord(value)) throw new ApiError('api.metro.box sent an answer Metro cannot read.', 503);
91
+ return value;
92
+ }
@@ -0,0 +1,200 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parseId } from '@metro-labs/core/ids';
4
+ import { isRecord } from '@metro-labs/core/is-record';
5
+ import { ApiError } from '@metro-labs/http/api-error';
6
+ import { bodyField } from '@metro-labs/http/api-http';
7
+ import { connectorAuth, connectorClient, connectorName } from '@metro-labs/http/connector-input';
8
+ import { parseConnectorUrl } from '@metro-labs/http/connector-url';
9
+ import { agentsDir } from '../agents/files.js';
10
+ import { normalizePolicy, type ToolPolicy } from '../policy/policy.js';
11
+ import { readAgentKey } from './agent-key.js';
12
+ import { migrationOrigin } from './migration-url.js';
13
+
14
+ export type MigrationIssue = 'invalid_config' | 'unsupported_url' | 'custom_policy' | 'oauth_sign_in' | 'header_reentry';
15
+
16
+ export interface MigrationConnector {
17
+ id: string;
18
+ name: string;
19
+ auth: 'none' | 'header' | 'oauth' | 'invalid';
20
+ origin: string | null;
21
+ policy: ToolPolicy;
22
+ policySupported: boolean;
23
+ issues: MigrationIssue[];
24
+ }
25
+
26
+ export interface ConnectorMigrationPreview {
27
+ enabled: boolean;
28
+ enrolled: boolean;
29
+ agentId: string | null;
30
+ owner: string | null;
31
+ liveEnabled: boolean;
32
+ connectors: MigrationConnector[];
33
+ }
34
+
35
+ const invalid = (): never => { throw new Error('Invalid local connector configuration.'); };
36
+ const onlyKeys = (raw: Record<string, unknown>, keys: readonly string[]): boolean => Object.keys(raw).every((key) => keys.includes(key));
37
+ const nonempty = (value: unknown): value is string => typeof value === 'string' && value.trim() !== '';
38
+
39
+ function optionalStrings(raw: Record<string, unknown>, keys: readonly string[]): boolean {
40
+ return keys.every((key) => raw[key] === undefined || typeof raw[key] === 'string');
41
+ }
42
+
43
+ function validClient(raw: unknown): boolean {
44
+ if (raw === undefined || raw === null) return true;
45
+ if (!isRecord(raw) || !onlyKeys(raw, ['clientId', 'clientSecret'])) return false;
46
+ if (!nonempty(raw.clientId) || !optionalStrings(raw, ['clientSecret'])) return false;
47
+ return connectorClient(raw.clientId, raw.clientSecret) !== null;
48
+ }
49
+
50
+ function oauthTokensValid(raw: Record<string, unknown>): boolean {
51
+ const token = (value: unknown): boolean => typeof value === 'string' && /^[\x21-\x7e]+$/.test(value);
52
+ return token(raw.accessToken) && (raw.refreshToken === undefined || token(raw.refreshToken));
53
+ }
54
+
55
+ function oauthAuth(raw: Record<string, unknown>): 'oauth' {
56
+ if (!onlyKeys(raw, ['kind', 'accessToken', 'refreshToken', 'clientId', 'clientSecret', 'tokenEndpoint', 'issuer', 'scope', 'expiresAt'])) invalid();
57
+ if (!oauthTokensValid(raw) || ![raw.clientId, raw.tokenEndpoint, raw.issuer].every(nonempty)) invalid();
58
+ if (!optionalStrings(raw, ['refreshToken', 'clientSecret', 'scope'])) invalid();
59
+ if (raw.expiresAt !== undefined && (typeof raw.expiresAt !== 'number' || !Number.isFinite(raw.expiresAt) || raw.expiresAt < 0)) invalid();
60
+ connectorClient(raw.clientId, raw.clientSecret);
61
+ parseConnectorUrl(raw.tokenEndpoint);
62
+ parseConnectorUrl(raw.issuer);
63
+ return 'oauth';
64
+ }
65
+
66
+ function authKind(raw: unknown): Exclude<MigrationConnector['auth'], 'invalid'> {
67
+ if (!isRecord(raw)) return invalid();
68
+ if (raw.kind === 'oauth') return oauthAuth(raw);
69
+ if (raw.kind === 'none' && onlyKeys(raw, ['kind'])) return 'none';
70
+ if (raw.kind !== 'header' || !onlyKeys(raw, ['kind', 'name', 'value'])) return invalid();
71
+ if (!nonempty(raw.name) || !nonempty(raw.value)) return invalid();
72
+ const auth = connectorAuth(raw.name, raw.value);
73
+ return auth.kind === 'header' ? 'header' : invalid();
74
+ }
75
+
76
+ function metadataValid(config: Record<string, unknown>): boolean {
77
+ if (!optionalStrings(config, ['createdAt'])) return false;
78
+ const verified = config.verified;
79
+ if (verified !== undefined && (!isRecord(verified) || !optionalStrings(verified, ['at', 'server']))) return false;
80
+ if (config.oauth !== undefined && typeof config.oauth !== 'boolean') return false;
81
+ const groups = config.toolGroups;
82
+ if (groups === undefined) return true;
83
+ if (!isRecord(groups) || Object.keys(groups).length > 1000) return false;
84
+ return Object.entries(groups).every(([name, group]) => name.length > 0 && name.length <= 128 && (group === 'read' || group === 'write'));
85
+ }
86
+
87
+ function configAuth(config: unknown): MigrationConnector['auth'] {
88
+ try {
89
+ if (!isRecord(config) || !onlyKeys(config, ['auth', 'oauth', 'client', 'createdAt', 'verified', 'policy', 'toolGroups'])) return 'invalid';
90
+ if (!metadataValid(config) || !validClient(config.client)) return 'invalid';
91
+ const kind = authKind(config.auth);
92
+ if (kind === 'header' && config.oauth === true) return 'invalid';
93
+ return kind === 'none' && config.oauth === true ? 'oauth' : kind;
94
+ } catch {
95
+ return 'invalid';
96
+ }
97
+ }
98
+
99
+ function policyShape(raw: Record<string, unknown>): boolean {
100
+ if (!onlyKeys(raw, ['read', 'write', 'tools'])) return false;
101
+ for (const key of ['read', 'write']) if (Object.hasOwn(raw, key) && typeof raw[key] !== 'string') return false;
102
+ return raw.tools === undefined || (isRecord(raw.tools) && Object.values(raw.tools).every((value) => typeof value === 'string'));
103
+ }
104
+
105
+ function strictPolicy(raw: unknown): ToolPolicy {
106
+ if (raw === undefined) return {};
107
+ if (!isRecord(raw) || !policyShape(raw)) return invalid();
108
+ const policy = normalizePolicy(raw);
109
+ if (isRecord(raw.tools)) {
110
+ policy.tools = Object.fromEntries(Object.entries(raw.tools).map(([name, value]) => {
111
+ const { read } = normalizePolicy({ read: value });
112
+ if (read === undefined) return invalid();
113
+ return [name, read];
114
+ }));
115
+ }
116
+ return policy;
117
+ }
118
+
119
+ function policyPreview(raw: unknown): Pick<MigrationConnector, 'policy' | 'policySupported'> & { valid: boolean } {
120
+ try {
121
+ const policy = strictPolicy(raw);
122
+ const policySupported = (policy.read ?? 'allow') === 'allow' && policy.write !== 'ask' && Object.keys(policy.tools ?? {}).length === 0;
123
+ return { policy, policySupported, valid: true };
124
+ } catch {
125
+ return { policy: {}, policySupported: false, valid: false };
126
+ }
127
+ }
128
+
129
+ function nameOf(raw: unknown): string | null {
130
+ try {
131
+ return connectorName(raw);
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ function rowIdentity(row: Record<string, unknown>, index: number): { id: string; name: string; valid: boolean } {
138
+ const id = parseId(row.id);
139
+ const name = nameOf(row.name);
140
+ return { id: id ?? `invalid-${String(index + 1)}`, name: name ?? `Connector ${String(index + 1)}`, valid: id !== null && name !== null };
141
+ }
142
+
143
+ function previewRow(raw: unknown, index: number): MigrationConnector {
144
+ const row = isRecord(raw) ? raw : {};
145
+ const identity = rowIdentity(row, index);
146
+ const auth = configAuth(row.config);
147
+ const { policy, policySupported, valid } = policyPreview(bodyField(row.config, 'policy'));
148
+ const origin = migrationOrigin(row.url);
149
+ const issues: MigrationIssue[] = [];
150
+ if (!identity.valid || auth === 'invalid' || !valid) issues.push('invalid_config');
151
+ if (origin === null) issues.push('unsupported_url');
152
+ if (valid && !policySupported) issues.push('custom_policy');
153
+ if (auth === 'oauth') issues.push('oauth_sign_in');
154
+ if (auth === 'header') issues.push('header_reentry');
155
+ return { id: identity.id, name: identity.name, auth, origin, policy, policySupported, issues };
156
+ }
157
+
158
+ const unavailable = (): ApiError => new ApiError('Local connector configuration cannot be previewed. Restore its file or permissions first.', 503);
159
+
160
+ function readRows(dir: string): unknown[] {
161
+ let text: string;
162
+ try {
163
+ text = readFileSync(join(dir, 'connectors.json'), 'utf8');
164
+ } catch (err) {
165
+ if (isRecord(err) && err.code === 'ENOENT') return [];
166
+ throw unavailable();
167
+ }
168
+ let raw: unknown;
169
+ try {
170
+ raw = JSON.parse(text);
171
+ } catch {
172
+ throw unavailable();
173
+ }
174
+ if (!isRecord(raw) || raw.version !== 1 || !Array.isArray(raw.connectors)) throw unavailable();
175
+ return raw.connectors;
176
+ }
177
+
178
+ function previewRows(dir: string): MigrationConnector[] {
179
+ const rows = readRows(dir).map(previewRow);
180
+ const seen = new Map<string, MigrationConnector>();
181
+ for (const row of rows) {
182
+ for (const key of [`id:${row.id}`, `name:${row.name}`]) {
183
+ const prior = seen.get(key);
184
+ if (prior !== undefined) {
185
+ for (const duplicate of [prior, row]) if (!duplicate.issues.includes('invalid_config')) duplicate.issues.push('invalid_config');
186
+ }
187
+ seen.set(key, row);
188
+ }
189
+ }
190
+ return rows;
191
+ }
192
+
193
+ export function connectorMigrationPreview(owner: string | null, dir = agentsDir()): ConnectorMigrationPreview {
194
+ const enabled = process.env.METRO_CONNECTORS_SYNC_ENABLED === 'true';
195
+ const liveEnabled = process.env.METRO_CONNECTORS_LIVE_ENABLED === 'true';
196
+ if (!enabled) return { enabled, enrolled: false, agentId: null, owner, liveEnabled, connectors: [] };
197
+ const enrollment = owner === null ? null : readAgentKey(dir)?.enrollment;
198
+ const agentId = enrollment?.organization === owner ? enrollment?.server ?? null : null;
199
+ return { enabled, enrolled: agentId !== null, agentId, owner, liveEnabled, connectors: previewRows(dir) };
200
+ }
@@ -0,0 +1,32 @@
1
+ import { BlockList, isIP } from 'node:net';
2
+ import { parseConnectorUrl } from '@metro-labs/http/connector-url';
3
+
4
+ const blocked = new BlockList();
5
+ for (const [address, prefix] of [
6
+ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],
7
+ ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15],
8
+ ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
9
+ ] as const) blocked.addSubnet(address, prefix, 'ipv4');
10
+ for (const [address, prefix] of [
11
+ ['2001::', 32], ['2001:10::', 28], ['2001:20::', 28], ['2001:db8::', 32], ['2002::', 16],
12
+ ] as const) blocked.addSubnet(address, prefix, 'ipv6');
13
+ const globalV6 = new BlockList();
14
+ globalV6.addSubnet('2000::', 3, 'ipv6');
15
+
16
+ function publicHost(host: string): boolean {
17
+ if (isIP(host) === 4) return !blocked.check(host, 'ipv4');
18
+ if (isIP(host) === 6) return globalV6.check(host, 'ipv6') && !blocked.check(host, 'ipv6');
19
+ const name = host.replace(/\.$/, '');
20
+ if (/(^|\.)(localhost|local|internal|lan|home|invalid|test|onion)$/.test(name)) return false;
21
+ return name.includes('.') && name.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
22
+ }
23
+
24
+ export function migrationOrigin(raw: unknown): string | null {
25
+ try {
26
+ const url = parseConnectorUrl(raw);
27
+ if (url.protocol !== 'https:' || !publicHost(url.hostname.replace(/^\[|\]$/g, ''))) return null;
28
+ return url.origin;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
@@ -8,9 +8,9 @@ import {
8
8
  takePending,
9
9
  type PendingAuth,
10
10
  } from './oauth.js';
11
- import { connectorClient } from './config.js';
12
- import { parseConnectorUrl } from './verify.js';
13
- import type { OAuthAuth } from './verify.js';
11
+ import { connectorClient } from '@metro-labs/http/connector-input';
12
+ import { parseConnectorUrl } from '@metro-labs/http/connector-url';
13
+ import type { OAuthAuth } from '@metro-labs/http/connector-mcp';
14
14
  import type { Connector, PendingConnectorInput } from './store.js';
15
15
 
16
16
  export function hostOf(url: string): string {
@@ -1,9 +1,6 @@
1
1
  import { publicBaseOrDefault } from '../files/attach-serve.js';
2
- import {
3
- refused,
4
- type OAuthAuth,
5
- type OAuthTokens,
6
- } from './verify.js';
2
+ import { refused } from '@metro-labs/http/connector-url';
3
+ import type { OAuthAuth, OAuthTokens } from '@metro-labs/http/connector-mcp';
7
4
  import { validateReturnTo } from '@metro-labs/http/return-to';
8
5
  import {
9
6
  authorizeUrl,
@@ -12,8 +9,8 @@ import {
12
9
  refreshTokens,
13
10
  registerClient,
14
11
  type OAuthClient,
15
- } from './oauth-client.js';
16
- import { discoverOAuth, type OAuthServer } from './oauth-discovery.js';
12
+ } from '@metro-labs/http/connector-oauth';
13
+ import { discoverOAuth, type OAuthServer } from '@metro-labs/http/connector-discovery';
17
14
  import { ticketStore } from '@metro-labs/core/tickets';
18
15
 
19
16
  export interface PendingAuth {
@@ -0,0 +1,61 @@
1
+ import { isRecord } from '@metro-labs/core/is-record';
2
+ import { ApiError } from '@metro-labs/http/api-error';
3
+ import { screenCalls, type Screened } from './relay-policy.js';
4
+ import type { RelaySession } from './relay-session.js';
5
+ import type { RelayTarget } from './relay-target.js';
6
+
7
+ type Ready = Extract<RelayTarget, { kind: 'ok' }>;
8
+ type Forward = (target: Ready, body: Uint8Array<ArrayBuffer> | null, signal: AbortSignal) => Promise<Response>;
9
+ type OrgExchange =
10
+ | { kind: 'response'; upstream: Response; answers: Record<string, unknown>[] }
11
+ | { kind: 'blocked'; screened: Screened };
12
+
13
+ const METHODS = new Set(['initialize', 'ping', 'tools/list', 'tools/call', 'notifications/initialized', 'notifications/cancelled']);
14
+
15
+ function requestBatch(body: Uint8Array | null): boolean {
16
+ if (body === null) return false;
17
+ let parsed: unknown;
18
+ try { parsed = JSON.parse(new TextDecoder().decode(body)); }
19
+ catch { throw new ApiError('Invalid organization connector request.', 400); }
20
+ const batch = Array.isArray(parsed);
21
+ const entries: unknown[] = Array.isArray(parsed) ? parsed : [parsed];
22
+ if (entries.length === 0 || entries.some((item) => !validMessage(item))) throw new ApiError('This organization connector accepts MCP tool requests only.', 400);
23
+ return batch;
24
+ }
25
+
26
+ function validMessage(item: unknown): boolean {
27
+ if (!isRecord(item) || item.jsonrpc !== '2.0' || typeof item.method !== 'string' || !METHODS.has(item.method)) return false;
28
+ if (item.method !== 'tools/call') return true;
29
+ if (!isRecord(item.params) || typeof item.params.name !== 'string' || item.params.name === '') return false;
30
+ return item.params.arguments === undefined || isRecord(item.params.arguments);
31
+ }
32
+
33
+ function ready(session: RelaySession, signal: AbortSignal): Ready {
34
+ if (signal.aborted) throw new ApiError('This organization connector is no longer available.', 503);
35
+ const target = session.current();
36
+ if (target.kind === 'missing') throw new ApiError('No such organization connector.', 404);
37
+ if (target.kind === 'signin') throw new ApiError('This organization connector needs signing in again.', 424);
38
+ return target;
39
+ }
40
+
41
+ export async function exchangeOrg(id: string, session: RelaySession, body: Uint8Array<ArrayBuffer> | null, clientSignal: AbortSignal, forward: Forward): Promise<OrgExchange> {
42
+ const signal = AbortSignal.any([clientSignal, session.signal]);
43
+ const batch = requestBatch(body);
44
+ let pending = body;
45
+ const answers: Record<string, unknown>[] = [];
46
+ for (let attempt = 0; attempt < 2; attempt += 1) {
47
+ await session.target(attempt > 0);
48
+ ready(session, signal);
49
+ const screened = pending === null ? null : screenCalls(pending, id, session.blocker());
50
+ if (screened !== null) {
51
+ answers.push(...screened.answers);
52
+ pending = screened.forward;
53
+ if (pending === null) return { kind: 'blocked', screened: { forward: null, answers, batch } };
54
+ }
55
+ const target = ready(session, signal);
56
+ const upstream = await forward(target, pending, signal);
57
+ if (upstream.status !== 401 && upstream.status !== 403) return { kind: 'response', upstream, answers };
58
+ await upstream.body?.cancel();
59
+ }
60
+ throw new ApiError('The organization connector rejected its credential. Check its sign-in in the organization settings.', 424);
61
+ }
@@ -0,0 +1,111 @@
1
+ import type { FetchLike } from '@metro-labs/core/stations/oauth';
2
+ import type { ToolGroup } from '@metro-labs/core/stations/types';
3
+ import { ApiError } from '@metro-labs/http/api-error';
4
+ import type { DeliveredConnector } from '@metro-labs/http/connector-delivery';
5
+ import { MetroApiRefusal } from './metro-api.js';
6
+ import { listRemoteTools } from './tools.js';
7
+
8
+ export const orgUnavailable = (): ApiError => new ApiError('This organization connector is not available. Try again.', 503);
9
+ export const orgSignin = (): ApiError => new ApiError('This organization connector needs sign-in again.', 424);
10
+ export const orgMissing = (): ApiError => new ApiError('No such organization connector is assigned to this agent.', 404);
11
+
12
+ export function orgRuntimeError(error: unknown): ApiError {
13
+ if (error instanceof MetroApiRefusal) return error.upstreamStatus === 401 || error.upstreamStatus === 403 ? orgSignin() : orgUnavailable();
14
+ const status = error instanceof ApiError ? error.status : 503;
15
+ if (status === 401 || status === 403 || status === 424) return orgSignin();
16
+ if (status === 404) return orgMissing();
17
+ if (status === 502) return new ApiError('The organization connector could not complete the request.', 502);
18
+ return orgUnavailable();
19
+ }
20
+
21
+ interface DiscoveryGuard {
22
+ fresh: (revision?: string) => Promise<DeliveredConnector>;
23
+ current: () => DeliveredConnector;
24
+ signal: AbortSignal;
25
+ fetch: FetchLike;
26
+ }
27
+
28
+ const MAX_BYTES = 2 * 1024 * 1024;
29
+ const MAX_TOOLS = 2000;
30
+
31
+ async function boundedResponse(response: Response, signal: AbortSignal): Promise<Response> {
32
+ const reader = response.body?.getReader();
33
+ if (reader === undefined) return response;
34
+ const chunks: Uint8Array[] = [];
35
+ let size = 0;
36
+ try {
37
+ for (let part = await reader.read(); !part.done; part = await reader.read()) {
38
+ signal.throwIfAborted();
39
+ size += part.value.length;
40
+ if (size > MAX_BYTES) throw orgUnavailable();
41
+ chunks.push(part.value);
42
+ }
43
+ return new Response(Buffer.concat(chunks), { status: response.status, headers: response.headers });
44
+ } finally {
45
+ await reader.cancel();
46
+ }
47
+ }
48
+
49
+ function requestInit(row: DeliveredConnector, init: RequestInit | undefined, scanSignal: AbortSignal): RequestInit & { signal: AbortSignal } {
50
+ const headers = new Headers(init?.headers);
51
+ for (const [name, value] of Object.entries(row.headers)) headers.set(name, value);
52
+ const signals = [scanSignal, AbortSignal.timeout(15_000)];
53
+ if (init?.signal !== undefined && init.signal !== null) signals.push(init.signal);
54
+ return { ...init, headers, redirect: 'manual', signal: AbortSignal.any(signals) };
55
+ }
56
+
57
+ async function discoveryResponse(response: Response, init: RequestInit | undefined, signal: AbortSignal): Promise<Response> {
58
+ if (response.status >= 300) throw new ApiError('The organization connector refused tool discovery.', 502);
59
+ if (init?.method === 'DELETE') return new Response(null, { status: response.status });
60
+ return boundedResponse(response, signal);
61
+ }
62
+
63
+ async function guardedRequest(input: string, init: RequestInit | undefined, guard: DiscoveryGuard, budget: { refreshed: boolean }): Promise<Response> {
64
+ let revision: string | undefined;
65
+ for (let attempt = 0; attempt < 2; attempt += 1) {
66
+ guard.signal.throwIfAborted();
67
+ await guard.fresh(revision);
68
+ guard.signal.throwIfAborted();
69
+ const row = guard.current();
70
+ if (input !== new URL(row.url).toString()) throw orgUnavailable();
71
+ const options = requestInit(row, init, guard.signal);
72
+ const response = await guard.fetch(input, options);
73
+ try {
74
+ guard.current();
75
+ options.signal.throwIfAborted();
76
+ if (response.status === 401 || response.status === 403) {
77
+ if (row.auth !== 'oauth' || budget.refreshed) throw orgSignin();
78
+ budget.refreshed = true;
79
+ revision = row.revision;
80
+ continue;
81
+ }
82
+ return await discoveryResponse(response, init, options.signal);
83
+ } finally {
84
+ if (!response.bodyUsed) await response.body?.cancel();
85
+ }
86
+ }
87
+ throw orgSignin();
88
+ }
89
+
90
+ export async function discoverOrgGroups(url: string, guard: DiscoveryGuard): Promise<Record<string, ToolGroup>> {
91
+ try {
92
+ const failure: { error: ApiError | null } = { error: null };
93
+ const budget = { refreshed: false };
94
+ const send: FetchLike = async (input, init) => {
95
+ try {
96
+ return await guardedRequest(input, init, guard, budget);
97
+ } catch (error) {
98
+ failure.error = orgRuntimeError(error);
99
+ throw failure.error;
100
+ }
101
+ };
102
+ const tools = await listRemoteTools(new URL(url), { kind: 'none' }, send);
103
+ if (failure.error !== null) throw failure.error;
104
+ guard.current();
105
+ guard.signal.throwIfAborted();
106
+ if (tools.length > MAX_TOOLS) throw orgUnavailable();
107
+ return Object.fromEntries(tools.filter((tool) => /^[A-Za-z0-9_.-]{1,128}$/.test(tool.name)).map((tool) => [tool.name, tool.readOnly ? 'read' : 'write']));
108
+ } catch (error) {
109
+ throw orgRuntimeError(error);
110
+ }
111
+ }