@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.
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +3 -4
- package/runtime/marketplace/plugin/METRO.md +1 -1
- package/runtime/marketplace/plugin/bin/metro-plugin.mjs +4 -28
- package/runtime/marketplace/plugin/bin/policy.mjs +1 -32
- package/runtime/node_modules/@metro-labs/core/package.json +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +3 -5
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +1 -7
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -9
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +1 -3
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +9 -54
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +1 -13
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-snapshot.ts +0 -14
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/server-approvals.ts +14 -15
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/announced.ts +68 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp-server/config.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/policy/policy.ts +6 -5
- package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -27
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -5
- package/runtime/node_modules/@metro-labs/http/package.json +1 -2
- package/runtime/node_modules/@metro-labs/http/src/connector-grant.ts +0 -51
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/package.json +0 -1
- package/runtime/sdk-runner/runner.json +1 -1
- package/runtime/node_modules/@metro-labs/core/src/connector-sync.ts +0 -17
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +0 -104
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-adopt.ts +0 -57
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-relay.ts +0 -61
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +0 -115
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +0 -271
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-api.ts +0 -25
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +0 -173
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-setting.ts +0 -60
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +0 -38
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +0 -208
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +0 -40
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-id.ts +0 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-policy.ts +0 -90
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +0 -10
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-target.ts +0 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +0 -287
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +0 -59
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +0 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +0 -116
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +0 -19
- package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +0 -178
- package/runtime/node_modules/@metro-labs/http/src/transfer-crypto.ts +0 -44
- 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
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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:
|
|
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-
|
|
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
|
-
}
|
package/runtime/runtime.json
CHANGED
|
@@ -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
|
-
}
|