@stage-labs/metro 0.1.0-beta.304 → 0.1.0-beta.305

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 (51) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +3 -4
  3. package/runtime/marketplace/plugin/METRO.md +1 -1
  4. package/runtime/marketplace/plugin/bin/metro-plugin.mjs +4 -28
  5. package/runtime/marketplace/plugin/bin/policy.mjs +1 -32
  6. package/runtime/node_modules/@metro-labs/core/package.json +0 -1
  7. package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +3 -5
  8. package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +1 -7
  9. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -9
  10. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +1 -3
  11. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +1 -0
  12. package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +0 -2
  13. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +9 -54
  14. package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +1 -13
  15. package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +0 -14
  16. package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +14 -15
  17. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/announced.ts +68 -0
  18. package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +3 -1
  19. package/runtime/node_modules/@metro-labs/daemon/src/policy/policy.ts +6 -5
  20. package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +0 -2
  21. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -27
  22. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -5
  23. package/runtime/node_modules/@metro-labs/http/package.json +1 -2
  24. package/runtime/node_modules/@metro-labs/http/src/connector-grant.ts +0 -51
  25. package/runtime/runtime.json +1 -1
  26. package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +0 -1
  27. package/runtime/sdk-runner/runner.json +1 -1
  28. package/runtime/node_modules/@metro-labs/core/src/connector-sync.ts +0 -17
  29. package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +0 -104
  30. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-adopt.ts +0 -57
  31. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-relay.ts +0 -61
  32. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +0 -115
  33. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +0 -271
  34. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-api.ts +0 -25
  35. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +0 -173
  36. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-setting.ts +0 -60
  37. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +0 -38
  38. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +0 -208
  39. package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +0 -40
  40. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-id.ts +0 -2
  41. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-policy.ts +0 -90
  42. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +0 -10
  43. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-target.ts +0 -4
  44. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +0 -287
  45. package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +0 -59
  46. package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +0 -1
  47. package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +0 -116
  48. package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +0 -19
  49. package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +0 -178
  50. package/runtime/node_modules/@metro-labs/http/src/transfer-crypto.ts +0 -44
  51. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/connector-sync.ts +0 -17
@@ -13,9 +13,11 @@ export interface ToolPolicy {
13
13
  tools?: Record<string, Access>;
14
14
  }
15
15
 
16
- export type PolicyTarget =
17
- | { kind: 'channel'; station: string; account: string }
18
- | { kind: 'connector'; id: string };
16
+ export interface PolicyTarget {
17
+ kind: 'channel';
18
+ station: string;
19
+ account: string;
20
+ }
19
21
 
20
22
  export interface PolicyTool {
21
23
  name: string;
@@ -28,8 +30,7 @@ const RANK: Record<Access, number> = { allow: 0, ask: 1, deny: 2 };
28
30
 
29
31
  const isAccess = (value: unknown): value is Access => typeof value === 'string' && (ACCESS as readonly string[]).includes(value);
30
32
 
31
- export const targetKey = (target: PolicyTarget): string =>
32
- target.kind === 'channel' ? `channel:${target.station}/${target.account}` : `connector:${target.id}`;
33
+ export const targetKey = (target: PolicyTarget): string => `channel:${target.station}/${target.account}`;
33
34
 
34
35
  export function decide(policy: ToolPolicy | undefined, tool: PolicyTool): Access {
35
36
  const byTool = policy?.tools !== undefined && Object.hasOwn(policy.tools, tool.name) ? policy.tools[tool.name] : undefined;
@@ -1,6 +1,5 @@
1
1
  import { handleSessionApis, type SessionApis } from './session-apis.js';
2
2
  import { handleGatewayRequest } from '../gateway/gateway.js';
3
- import { handleRelayRequest } from '../connectors/relay.js';
4
3
  import { handleThreemaCallback } from './threema-callback.js';
5
4
  import { BodyTooLargeError, readBody, WEBHOOK_BODY_MAX } from './body.js';
6
5
 
@@ -346,7 +345,6 @@ async function handlePreMcpRoutes(
346
345
  if (handleEarlyRoutes(req, res, apis)) return true;
347
346
  if (handleUploadRequest(req, res)) return true;
348
347
  if (handleAttachRequest(req, res)) return true;
349
- if (apis.relayApi && handleRelayRequest(req, res, apis.relayApi)) return true;
350
348
  if (await handleWebhookRoute(req, res, emit)) return true;
351
349
  if (await handleThreemaCallback(req, res)) return true;
352
350
  return monitor && handleMonitorRequest(req, res);
@@ -6,16 +6,12 @@ import { assertAttachOwner, finishGmailUpgrade, startGmailAttach } from './gmail
6
6
  import { detachWithGmailRevocation } from './gmail-detach.js';
7
7
  import { recentSenders } from '../agents/senders.js';
8
8
  import { forwardTrainCall } from '../stations/train-call.js';
9
- import { invalidateOrgConnectorSync, orgConnectorRelaySession, orgConnectorSyncStatus, setOrgConnectorSync } from '../connectors/org-sync-watch.js';
10
9
  import type { AgentApiDeps } from '../agents/api.js';
11
10
  import { ATTACHABLE, type AccountApiDeps } from '../agents/accounts-api.js';
12
11
  import type { SessionApis } from './session-apis.js';
13
12
  import type { ModeInfo } from '@metro-labs/http/mode-api';
14
13
  import { loadedAgentOf, type AgentBundle, type BundleApiDeps } from '../agents/bundle.js';
15
14
  import { METRO_VERSION } from '@metro-labs/core/version';
16
- import { authenticate } from '../mcp/request-identity.js';
17
- import type { RelayApiDeps } from '../connectors/relay.js';
18
- import { connectorGates } from '../connectors/gates.js';
19
15
  import {
20
16
  setLocalOwner,
21
17
  localAttachAccount,
@@ -78,7 +74,6 @@ function agentApi(deps: LocalModeDeps): AgentApiDeps {
78
74
  ...(deps.toolGroups === undefined ? {} : { toolGroups: deps.toolGroups }),
79
75
  attachable: ATTACHABLE.filter((s) => s !== 'webhook'),
80
76
  features: ['gmail-managed'],
81
- connectorIds: connectorIdsOfLocalAgents,
82
77
  prepareAccount: deps.prepareAccount,
83
78
  attachAccount: localAttachAccount,
84
79
  detachAccount: detachWithGmailRevocation,
@@ -93,20 +88,6 @@ function agentApi(deps: LocalModeDeps): AgentApiDeps {
93
88
  };
94
89
  }
95
90
 
96
- function connectorIdsOfLocalAgents(ids: string[]): Promise<Map<string, string[]>> {
97
- const all = connectorGates().map((gate) => gate.id);
98
- return Promise.resolve(new Map(ids.map((id) => [id, all] as const)));
99
- }
100
-
101
- const relayApi: RelayApiDeps = {
102
- open: orgConnectorRelaySession,
103
- identify: (req) => {
104
- const who = authenticate(req);
105
- return who?.kind === 'agent' ? { subject: 'agent-key', agentId: who.agentId } : null;
106
- },
107
- };
108
-
109
-
110
91
  function bundleApi(deps: LocalModeDeps): BundleApiDeps {
111
92
  return {
112
93
  bundle: (agentId) => {
@@ -155,17 +136,11 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
155
136
  return {
156
137
  agentApi: agentApi(deps),
157
138
  bundleApi: bundleApi(deps),
158
- connectorSyncApi: { status: orgConnectorSyncStatus, set: setOrgConnectorSync },
159
- enrollApi: { owner: () => localOwner(), invalidate: invalidateOrgConnectorSync },
160
- relayApi,
139
+ enrollApi: { owner: () => localOwner() },
161
140
  claudeApi: { liveEvents: deps.liveEvents },
162
141
  updateApi: { restart: deps.restart },
163
142
  controlApi: { restart: deps.restart, stop: deps.stop },
164
- ownerApi: { setOwner: (owner) => {
165
- const saved = setLocalOwner(owner);
166
- invalidateOrgConnectorSync();
167
- return saved;
168
- } },
143
+ ownerApi: { setOwner: setLocalOwner },
169
144
  machineApi: {},
170
145
  modelApi: { switchModel: deps.switchModel, owner: localOwner },
171
146
  gateway: { config: readModelConfig },
@@ -2,8 +2,6 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { handleSessionApiRequest } from './session.js';
3
3
  import { handleApprovalsRequest } from '../approvals/api.js';
4
4
  import { handleAgentApiRequest, type AgentApiDeps } from '../agents/api.js';
5
- import type { RelayApiDeps } from '../connectors/relay.js';
6
- import { handleConnectorSyncRequest, type ConnectorSyncApiDeps } from '../connectors/org-sync-api.js';
7
5
  import { handleEnrollRequest, type EnrollApiDeps } from '../connectors/enroll-api.js';
8
6
  import { handleClaudeRequest, type ClaudeApiDeps } from '../claude/api.js';
9
7
  import type { ModeInfo } from '@metro-labs/http/mode-api';
@@ -33,8 +31,6 @@ export interface SessionApis {
33
31
  terminalApi?: TerminalApiDeps;
34
32
  claudeApi?: ClaudeApiDeps;
35
33
  enrollApi?: EnrollApiDeps;
36
- connectorSyncApi?: ConnectorSyncApiDeps;
37
- relayApi?: RelayApiDeps;
38
34
  mode?: () => ModeInfo;
39
35
  }
40
36
 
@@ -51,7 +47,6 @@ export function handleSessionApis(
51
47
  () => handleRunEventsRequest(req, res),
52
48
  () => handleApprovalsRequest(req, res),
53
49
  ...when(apis.enrollApi, (d) => handleEnrollRequest(req, res, d)),
54
- ...when(apis.connectorSyncApi, (d) => handleConnectorSyncRequest(req, res, d)),
55
50
  ...when(apis.bundleApi, (d) => handleBundleRequest(req, res, d)),
56
51
  ...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
57
52
  ...when(apis.controlApi, (d) => handleControlRequest(req, res, d)),
@@ -22,8 +22,7 @@
22
22
  "./connector-discovery": "./src/connector-discovery.ts",
23
23
  "./connector-oauth": "./src/connector-oauth.ts",
24
24
  "./connector-input": "./src/connector-input.ts",
25
- "./connector-delivery": "./src/connector-delivery.ts",
26
- "./connector-migration": "./src/connector-migration.ts",
25
+ "./connector-policy": "./src/connector-policy.ts",
27
26
  "./connector-grant": "./src/connector-grant.ts",
28
27
  "./mcp-server-protocol": "./src/mcp-server-protocol.ts"
29
28
  },
@@ -1,59 +1,8 @@
1
- import { deliveryRecord } from './connector-delivery-validation.js';
2
- import type { OAuthAuth } from './connector-mcp.js';
3
1
  import { boundOAuthResource, parsePublicConnectorUrl } from './connector-public-url.js';
4
2
 
5
- export interface PortableGrant extends OAuthAuth {
6
- resource: string;
7
- tokenEndpointAuthMethod: 'none' | 'client_secret_post';
8
- }
9
-
10
- const invalid = (): never => { throw new Error('This OAuth grant cannot be transferred. Its client authentication or provider binding is unsupported. Sign in separately.'); };
11
-
12
- function text(raw: unknown, max = 16384): string {
13
- if (typeof raw !== 'string' || raw.length === 0 || raw.length > max || /[^\x21-\x7e]/.test(raw)) return invalid();
14
- return raw;
15
- }
16
-
17
- function endpoint(raw: unknown): string {
18
- const value = text(raw, 2048);
19
- parsePublicConnectorUrl(value);
20
- return value;
21
- }
22
-
23
3
  export function grantResource(target: string, raw: unknown): string {
24
4
  const transport = parsePublicConnectorUrl(target);
25
5
  const resource = parsePublicConnectorUrl(raw);
26
6
  if (resource.toString() === transport.toString() && typeof raw === 'string') return raw;
27
7
  return boundOAuthResource(target, raw);
28
8
  }
29
-
30
- function binding(value: Record<string, unknown>): Pick<PortableGrant, 'clientId' | 'clientSecret' | 'tokenEndpointAuthMethod'> {
31
- const clientSecret = value.clientSecret === undefined ? undefined : text(value.clientSecret);
32
- const method = value.tokenEndpointAuthMethod ?? (clientSecret === undefined ? 'none' : 'client_secret_post');
33
- if (method !== 'none' && method !== 'client_secret_post') return invalid();
34
- if ((method === 'client_secret_post') !== (clientSecret !== undefined)) return invalid();
35
- return { clientId: text(value.clientId), tokenEndpointAuthMethod: method, ...(clientSecret === undefined ? {} : { clientSecret }) };
36
- }
37
-
38
- function expiry(raw: unknown): Pick<PortableGrant, 'expiresAt'> {
39
- if (raw === undefined) return {};
40
- if (typeof raw !== 'number' || !Number.isSafeInteger(raw) || raw < 0) return invalid();
41
- return { expiresAt: raw };
42
- }
43
-
44
- function scopeOf(raw: unknown): Pick<PortableGrant, 'scope'> {
45
- if (raw === undefined) return {};
46
- if (typeof raw !== 'string' || raw.length > 16384 || /[^\x20-\x7e]/.test(raw)) return invalid();
47
- return { scope: raw };
48
- }
49
-
50
- export function portableGrant(raw: unknown, target: string): PortableGrant {
51
- const value = deliveryRecord(raw, ['kind', 'accessToken', 'refreshToken', 'expiresAt', 'clientId', 'clientSecret', 'issuer', 'tokenEndpoint', 'scope', 'resource', 'tokenEndpointAuthMethod']);
52
- if (value.kind !== 'oauth') return invalid();
53
- return {
54
- kind: 'oauth', accessToken: text(value.accessToken, 16377), issuer: endpoint(value.issuer), tokenEndpoint: endpoint(value.tokenEndpoint),
55
- resource: grantResource(target, value.resource ?? parsePublicConnectorUrl(target).toString()),
56
- ...binding(value), ...expiry(value.expiresAt), ...scopeOf(value.scope),
57
- ...(value.refreshToken === undefined ? {} : { refreshToken: text(value.refreshToken) }),
58
- };
59
- }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.304"
2
+ "version": "0.1.0-beta.305"
3
3
  }
@@ -6,7 +6,6 @@
6
6
  "type": "module",
7
7
  "exports": {
8
8
  "./log": "./src/log.ts",
9
- "./connector-sync": "./src/connector-sync.ts",
10
9
  "./events": "./src/events.ts",
11
10
  "./is-record": "./src/is-record.ts",
12
11
  "./runner-activity": "./src/runner-activity.ts",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.304",
2
+ "version": "0.1.0-beta.305",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -1,17 +0,0 @@
1
- export interface ConnectorSyncReceipt {
2
- state: 'waiting' | 'received' | 'expired' | 'unavailable';
3
- lastReceivedAt: number | null;
4
- leaseExpiresAt: number | null;
5
- counts: { total: number; ready: number; unavailable: number } | null;
6
- }
7
-
8
- export interface ConnectorSyncStatus extends Omit<ConnectorSyncReceipt, 'state'> {
9
- enabled: boolean;
10
- source: 'saved' | 'environment' | 'default' | 'invalid' | 'scope_changed';
11
- enrolled: boolean;
12
- owner: string | null;
13
- agentId: string | null;
14
- scope: string;
15
- liveEnabled: boolean;
16
- state: 'off' | 'not_enrolled' | ConnectorSyncReceipt['state'];
17
- }
@@ -1,104 +0,0 @@
1
- import type { ToolGroup } from '@metro-labs/core/stations/types';
2
- import { decide, policyFor, setPolicies, type PolicyTarget, type ToolPolicy } from '../policy/policy.js';
3
- import { serverKeysOf } from './plugin-sync.js';
4
- import { NEEDS_APPROVAL, revokeToolPrompts, settlePrompts, takeGrant } from '../approvals/pending.js';
5
- import type { BlockedReason } from './relay-policy.js';
6
-
7
- export interface GateRow {
8
- id: string;
9
- name: string;
10
- config: { policy?: ToolPolicy; toolGroups?: Record<string, ToolGroup> };
11
- }
12
-
13
- export interface ConnectorGate {
14
- id: string;
15
- name: string;
16
- server: string;
17
- policy: ToolPolicy;
18
- tools: Record<string, ToolGroup>;
19
- }
20
-
21
- let gates: ConnectorGate[] = [];
22
-
23
- const normalized = (name: string): string => name.replace(/[^a-zA-Z0-9_-]/g, '_');
24
-
25
- function approvalChanged(gate: ConnectorGate, tool: string, next: GateRow | undefined, keys: ReadonlyMap<string, string>): boolean {
26
- if (next === undefined || gate.server !== keys.get(gate.id) || JSON.stringify([gate.name, gate.policy]) !== JSON.stringify([next.name, next.config.policy ?? {}])) return true;
27
- const before = decide(gate.policy, { name: tool, group: toolGroupOf(gate, tool) });
28
- const after = decide(next.config.policy, { name: tool, group: next.config.toolGroups?.[tool] === 'read' ? 'read' : 'write' });
29
- return before !== after;
30
- }
31
-
32
- export function registerConnectors(rows: readonly GateRow[], keys: ReadonlyMap<string, string> = serverKeysOf([...rows])): void {
33
- const next = rows.map((row) => ({
34
- id: row.id,
35
- name: row.name,
36
- server: keys.get(row.id) ?? '',
37
- policy: row.config.policy ?? {},
38
- tools: row.config.toolGroups ?? {},
39
- }));
40
- revokeToolPrompts((tool) => {
41
- const before = connectorToolOf(tool);
42
- const after = connectorToolOf(tool, next);
43
- if (before?.gate.id !== after?.gate.id) return true;
44
- return before !== undefined && approvalChanged(before.gate, before.tool, rows.find((row) => row.id === before.gate.id), keys);
45
- });
46
- gates = next;
47
- const entries = rows.flatMap((row): [PolicyTarget, ToolPolicy][] =>
48
- row.config.policy === undefined ? [] : [[{ kind: 'connector', id: row.id }, row.config.policy]],
49
- );
50
- setPolicies('connector', entries);
51
- }
52
-
53
- export const connectorGates = (): readonly ConnectorGate[] => gates;
54
-
55
- export const toolGroupOf = (gate: ConnectorGate, tool: string): ToolGroup => (gate.tools[tool] === 'read' ? 'read' : 'write');
56
-
57
- const promptFor = (connectorId: string, tool: string) => (asked: string): boolean => {
58
- const found = connectorToolOf(asked);
59
- return found?.gate.id === connectorId && found.tool === tool;
60
- };
61
-
62
- export function blockedReason(connectorId: string, tool: string, args: Record<string, unknown> = {}): string | null {
63
- const gate = gates.find((g) => g.id === connectorId);
64
- if (gate === undefined) return 'This connector is not available.';
65
- settlePrompts(promptFor(connectorId, tool), args);
66
- const access = policyFor({ kind: 'connector', id: connectorId }, { name: tool, group: toolGroupOf(gate, tool) });
67
- if (access === 'deny') return `Blocked by the owner's policy for ${gate.name} (${tool}).`;
68
- if (access === 'allow' || takeGrant(promptFor(connectorId, tool), args)) return null;
69
- return NEEDS_APPROVAL(gate.name, tool);
70
- }
71
-
72
- export function requestConnectorBlocker(): () => BlockedReason {
73
- const permits = new Map<string, number>();
74
- return () => {
75
- const seen = new Map<string, number>();
76
- return (id, tool, args) => {
77
- const gate = gates.find((entry) => entry.id === id);
78
- if (gate === undefined) return 'This organization connector is not available.';
79
- const group = toolGroupOf(gate, tool);
80
- const access = policyFor({ kind: 'connector', id }, { name: tool, group });
81
- if (access !== 'ask') return blockedReason(id, tool, args);
82
- const key = JSON.stringify([id, gate.server, gate.policy, group, tool, args]);
83
- const occurrence = seen.get(key) ?? 0;
84
- seen.set(key, occurrence + 1);
85
- if (occurrence < (permits.get(key) ?? 0)) return null;
86
- const reason = blockedReason(id, tool, args);
87
- if (reason === null) permits.set(key, (permits.get(key) ?? 0) + 1);
88
- return reason;
89
- };
90
- };
91
- }
92
-
93
- function resolvedTool(gate: ConnectorGate, called: string): { gate: ConnectorGate; tool: string } {
94
- const known = [...Object.keys(gate.tools), ...Object.keys(gate.policy.tools ?? {})];
95
- return { gate, tool: known.find((name) => name === called) ?? known.find((name) => normalized(name) === called) ?? called };
96
- }
97
-
98
- export function connectorToolOf(toolName: string, entries: readonly ConnectorGate[] = gates): { gate: ConnectorGate; tool: string } | undefined {
99
- if (toolName.startsWith('mcp__metro__')) return undefined;
100
- for (const gate of entries)
101
- for (const prefix of [`mcp__plugin_metro_${gate.server}__`, `mcp__${gate.server}__`])
102
- if (toolName.startsWith(prefix) && toolName.length > prefix.length) return resolvedTool(gate, toolName.slice(prefix.length));
103
- return undefined;
104
- }
@@ -1,57 +0,0 @@
1
- import { readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { isRecord } from '@metro-labs/core/is-record';
4
- import { errMsg, log } from '@metro-labs/core/log';
5
- import { agentsDir } from '../agents/files.js';
6
- import { isOrgRelayId, orgRelayId } from './relay-id.js';
7
- import { readServerKeys, writeServerKeys } from './server-keys.js';
8
-
9
- interface Alias {
10
- sourceId: string;
11
- relayId: string;
12
- }
13
-
14
- function recordsOf(dir: string): unknown[] {
15
- let raw: unknown;
16
- try {
17
- raw = JSON.parse(readFileSync(join(dir, 'connector-migrations.json'), 'utf8'));
18
- } catch (err) {
19
- if (isRecord(err) && err.code === 'ENOENT') return [];
20
- log.warn({ err: errMsg(err) }, 'connectors: the old migration records could not be read; server keys left as they are');
21
- return [];
22
- }
23
- if (isRecord(raw) && raw.version === 1 && Array.isArray(raw.records)) return raw.records;
24
- log.warn('connectors: the old migration records are not valid; server keys left as they are');
25
- return [];
26
- }
27
-
28
- function aliasOf(record: unknown): Alias[] {
29
- if (!isRecord(record) || record.phase !== 'active' || typeof record.sourceId !== 'string' || typeof record.destinationId !== 'string') return [];
30
- const relayId = orgRelayId(record.destinationId);
31
- return isOrgRelayId(relayId) && record.sourceId !== relayId ? [{ sourceId: record.sourceId, relayId }] : [];
32
- }
33
-
34
- export function adoptOrgServerKeys(dir = agentsDir()): number {
35
- try {
36
- const aliases = recordsOf(dir).flatMap(aliasOf);
37
- if (aliases.length === 0) return 0;
38
- const keys = readServerKeys(dir);
39
- if (keys === undefined) return 0;
40
- let moved = 0;
41
- for (const { sourceId, relayId } of aliases) {
42
- const key = keys.get(sourceId);
43
- if (key === undefined || keys.has(relayId)) continue;
44
- keys.delete(sourceId);
45
- keys.set(relayId, key);
46
- moved += 1;
47
- }
48
- if (moved > 0) {
49
- writeServerKeys(keys, dir);
50
- log.info({ moved }, 'connectors: server keys moved to organization connector ids, tool names unchanged');
51
- }
52
- return moved;
53
- } catch (err) {
54
- log.warn({ err: errMsg(err) }, 'connectors: the server keys could not be moved to organization connector ids');
55
- return 0;
56
- }
57
- }
@@ -1,61 +0,0 @@
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
- }
@@ -1,115 +0,0 @@
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 { validConnectorToolName, 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') {
60
- await boundedResponse(response, signal);
61
- return new Response(null, { status: response.status });
62
- }
63
- return boundedResponse(response, signal);
64
- }
65
-
66
- async function guardedRequest(input: string, init: RequestInit | undefined, guard: DiscoveryGuard, budget: { refreshed: boolean }): Promise<Response> {
67
- let revision: string | undefined;
68
- for (let attempt = 0; attempt < 2; attempt += 1) {
69
- guard.signal.throwIfAborted();
70
- await guard.fresh(revision);
71
- guard.signal.throwIfAborted();
72
- const row = guard.current();
73
- if (input !== new URL(row.url).toString()) throw orgUnavailable();
74
- const options = requestInit(row, init, guard.signal);
75
- const response = await guard.fetch(input, options);
76
- try {
77
- guard.current();
78
- options.signal.throwIfAborted();
79
- if (response.status === 401 || response.status === 403) {
80
- if (row.auth !== 'oauth' || budget.refreshed) throw orgSignin();
81
- budget.refreshed = true;
82
- revision = row.revision;
83
- continue;
84
- }
85
- return await discoveryResponse(response, init, options.signal);
86
- } finally {
87
- if (!response.bodyUsed) await response.body?.cancel();
88
- }
89
- }
90
- throw orgSignin();
91
- }
92
-
93
- export async function discoverOrgGroups(url: string, guard: DiscoveryGuard): Promise<Record<string, ToolGroup>> {
94
- try {
95
- const failure: { error: ApiError | null } = { error: null };
96
- const budget = { refreshed: false };
97
- const send: FetchLike = async (input, init) => {
98
- try {
99
- return await guardedRequest(input, init, guard, budget);
100
- } catch (error) {
101
- const safe = orgRuntimeError(error);
102
- if (init?.method !== 'DELETE' || safe.status !== 502) failure.error = safe;
103
- throw safe;
104
- }
105
- };
106
- const tools = await listRemoteTools(new URL(url), { kind: 'none' }, send);
107
- if (failure.error !== null) throw failure.error;
108
- guard.current();
109
- guard.signal.throwIfAborted();
110
- if (tools.length > MAX_TOOLS || tools.some((tool) => !validConnectorToolName(tool.name))) throw orgUnavailable();
111
- return Object.fromEntries(tools.map((tool) => [tool.name, tool.readOnly ? 'read' : 'write']));
112
- } catch (error) {
113
- throw orgRuntimeError(error);
114
- }
115
- }