@stage-labs/metro 0.1.0-beta.292 → 0.1.0-beta.293
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/bin/policy.mjs +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +27 -23
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +4 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +12 -9
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +20 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +4 -6
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +39 -12
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +22 -11
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-passive.ts +96 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +19 -10
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +46 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +10 -15
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +1 -4
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +6 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/policy-gate.ts +1 -2
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.293",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -59,7 +59,10 @@ function decide(policy, name, group) {
|
|
|
59
59
|
const normalized = (name) => name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
60
60
|
|
|
61
61
|
function connectorCall(snap, toolName) {
|
|
62
|
-
|
|
62
|
+
const entries = Object.entries(isRecord(snap.connectors) ? snap.connectors : {});
|
|
63
|
+
const legacy = entries.filter(([, entry]) => isRecord(entry) && !/^org-[A-Za-z0-9][A-Za-z0-9_-]{10}$/.test(text(entry.id)) && toolName.startsWith(`mcp__${normalized(`metro.box ${text(entry.name)}`)}__`));
|
|
64
|
+
if (legacy.length > 1) return { name: 'ambiguous connector name; use its instance server key', tool: toolName, group: 'write', policy: { write: 'deny' } };
|
|
65
|
+
for (const [server, entry] of entries) {
|
|
63
66
|
if (!isRecord(entry)) continue;
|
|
64
67
|
const name = text(entry.name);
|
|
65
68
|
const prefixes = [`mcp__plugin_metro_${server}__`, `mcp__${server}__`];
|
|
@@ -20,7 +20,6 @@ export interface ConnectorGate {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
let gates: ConnectorGate[] = [];
|
|
23
|
-
let managed = new Set<string>();
|
|
24
23
|
|
|
25
24
|
const normalized = (name: string): string => name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
26
25
|
|
|
@@ -31,21 +30,21 @@ function approvalChanged(gate: ConnectorGate, tool: string, next: GateRow | unde
|
|
|
31
30
|
return before !== after;
|
|
32
31
|
}
|
|
33
32
|
|
|
34
|
-
export function registerConnectors(rows: readonly GateRow[],
|
|
35
|
-
const
|
|
36
|
-
revokeToolPrompts((tool) => {
|
|
37
|
-
const found = connectorToolOf(tool);
|
|
38
|
-
if (found === undefined || (!isOrgRelayId(found.gate.id) && !managed.has(found.gate.id) && !nextManaged.has(found.gate.id))) return false;
|
|
39
|
-
return approvalChanged(found.gate, found.tool, rows.find((row) => row.id === found.gate.id), keys);
|
|
40
|
-
});
|
|
41
|
-
managed = nextManaged;
|
|
42
|
-
gates = rows.map((row) => ({
|
|
33
|
+
export function registerConnectors(rows: readonly GateRow[], keys: ReadonlyMap<string, string> = serverKeysOf([...rows])): void {
|
|
34
|
+
const next = rows.map((row) => ({
|
|
43
35
|
id: row.id,
|
|
44
36
|
name: row.name,
|
|
45
37
|
server: keys.get(row.id) ?? '',
|
|
46
38
|
policy: row.config.policy ?? {},
|
|
47
39
|
tools: row.config.toolGroups ?? {},
|
|
48
40
|
}));
|
|
41
|
+
revokeToolPrompts((tool) => {
|
|
42
|
+
const before = connectorToolOf(tool);
|
|
43
|
+
const after = connectorToolOf(tool, next);
|
|
44
|
+
if (before?.gate.id !== after?.gate.id) return true;
|
|
45
|
+
return before !== undefined && approvalChanged(before.gate, before.tool, rows.find((row) => row.id === before.gate.id), keys);
|
|
46
|
+
});
|
|
47
|
+
gates = next;
|
|
49
48
|
const entries = rows.flatMap((row): [PolicyTarget, ToolPolicy][] =>
|
|
50
49
|
row.config.policy === undefined ? [] : [[{ kind: 'connector', id: row.id }, row.config.policy]],
|
|
51
50
|
);
|
|
@@ -63,7 +62,7 @@ const promptFor = (connectorId: string, tool: string) => (asked: string): boolea
|
|
|
63
62
|
|
|
64
63
|
export function blockedReason(connectorId: string, tool: string, args: Record<string, unknown> = {}): string | null {
|
|
65
64
|
const gate = gates.find((g) => g.id === connectorId);
|
|
66
|
-
if (gate === undefined) return
|
|
65
|
+
if (gate === undefined) return 'This connector is not available.';
|
|
67
66
|
settlePrompts(promptFor(connectorId, tool), args);
|
|
68
67
|
const access = policyFor({ kind: 'connector', id: connectorId }, { name: tool, group: toolGroupOf(gate, tool) });
|
|
69
68
|
if (access === 'deny') return `Blocked by the owner's policy for ${gate.name} (${tool}).`;
|
|
@@ -92,19 +91,24 @@ export function requestConnectorBlocker(): () => BlockedReason {
|
|
|
92
91
|
};
|
|
93
92
|
}
|
|
94
93
|
|
|
95
|
-
function
|
|
96
|
-
const
|
|
97
|
-
return
|
|
94
|
+
function resolvedTool(gate: ConnectorGate, called: string): { gate: ConnectorGate; tool: string } {
|
|
95
|
+
const known = [...Object.keys(gate.tools), ...Object.keys(gate.policy.tools ?? {})];
|
|
96
|
+
return { gate, tool: known.find((name) => name === called) ?? known.find((name) => normalized(name) === called) ?? called };
|
|
98
97
|
}
|
|
99
98
|
|
|
100
|
-
export function connectorToolOf(toolName: string): { gate: ConnectorGate; tool: string } | undefined {
|
|
99
|
+
export function connectorToolOf(toolName: string, entries: readonly ConnectorGate[] = gates): { gate: ConnectorGate; tool: string } | undefined {
|
|
101
100
|
if (toolName.startsWith('mcp__metro__')) return undefined;
|
|
102
|
-
for (const gate of
|
|
103
|
-
for (const prefix of
|
|
104
|
-
if (toolName.startsWith(prefix) && toolName.length > prefix.length)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return undefined;
|
|
101
|
+
for (const gate of entries)
|
|
102
|
+
for (const prefix of [`mcp__plugin_metro_${gate.server}__`, `mcp__${gate.server}__`])
|
|
103
|
+
if (toolName.startsWith(prefix) && toolName.length > prefix.length) return resolvedTool(gate, toolName.slice(prefix.length));
|
|
104
|
+
return legacyToolOf(toolName, entries);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function legacyToolOf(toolName: string, entries: readonly ConnectorGate[]): { gate: ConnectorGate; tool: string } | undefined {
|
|
108
|
+
if (!toolName.startsWith('mcp__metro_box_')) return undefined;
|
|
109
|
+
const legacy = entries.filter((gate) => !isOrgRelayId(gate.id) && toolName.startsWith(`mcp__${normalized(`metro.box ${gate.name}`)}__`));
|
|
110
|
+
const gate = legacy.length === 1 ? legacy[0] : undefined;
|
|
111
|
+
if (gate === undefined) return undefined;
|
|
112
|
+
const called = toolName.slice(`mcp__${normalized(`metro.box ${gate.name}`)}__`.length);
|
|
113
|
+
return called === '' ? undefined : resolvedTool(gate, called);
|
|
110
114
|
}
|
|
@@ -62,7 +62,7 @@ function checkResponse(record: MigrationRecord, remote: RemoteMigration): void {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
function callFailure(error: unknown, action: string): ApiError {
|
|
65
|
-
if (error instanceof MetroApiRefusal && error.code === 'migration_conflict') return new MigrationBlocked('destination_changed', 'The destination already has credentials
|
|
65
|
+
if (error instanceof MetroApiRefusal && error.code === 'migration_conflict') return new MigrationBlocked('destination_changed', 'The mapped destination already has credentials or changed settings. Inspect it and reconcile; Metro will not overwrite credentials or create a duplicate.');
|
|
66
66
|
if (action === 'prepare' && error instanceof MetroApiRefusal && error.upstreamStatus === 400 && error.code === 'oauth_resource_required') {
|
|
67
67
|
return new ApiError("Enter the provider's public OAuth resource. Roll back this preparation, then prepare again with that public value; never enter credentials.", 400);
|
|
68
68
|
}
|
|
@@ -210,7 +210,8 @@ export class ConnectorMigrations {
|
|
|
210
210
|
|
|
211
211
|
private waiting(context: Context, record: MigrationRecord, includeOrganization = false): boolean {
|
|
212
212
|
const uncertain = migrationUncertaintyReceipt(record.sourceId, this.dir) !== null;
|
|
213
|
-
|
|
213
|
+
const localIdle = this.localIdle(record.sourceId);
|
|
214
|
+
if (!uncertain && localIdle && (!includeOrganization || this.orgIdle(record.sourceId))) return false;
|
|
214
215
|
this.save(context, { ...record, reason: uncertain ? migrationRefreshUncertain(record.sourceId, this.dir) ? 'local_refresh_uncertain' : 'requests_uncertain' : 'waiting_for_local' });
|
|
215
216
|
return true;
|
|
216
217
|
}
|
|
@@ -300,6 +301,7 @@ export class ConnectorMigrations {
|
|
|
300
301
|
private async resolve(context: Context, record: MigrationRecord, receipt: string | null): Promise<void> {
|
|
301
302
|
this.check(context, record);
|
|
302
303
|
if (receipt === null || receipt !== migrationUncertaintyReceipt(record.sourceId, this.dir)) throw conflict('The outstanding request receipt changed. Refresh before confirming that vendor requests have settled.');
|
|
304
|
+
localMigrationIdle(record.sourceId, this.dir, true);
|
|
303
305
|
if (migrationRefreshUncertain(record.sourceId, this.dir)) return this.recoverRefresh(context, record, receipt);
|
|
304
306
|
if (!resolveMigrationUncertainty(record.sourceId, receipt, this.dir)) throw conflict('Requests are still running or their receipt changed. Wait for vendor completion, then refresh before confirming settlement.');
|
|
305
307
|
this.save(context, { ...record, reason: record.phase === 'prepared' ? 'not_verified' : null });
|
|
@@ -3,7 +3,7 @@ import type { FetchLike } from '@metro-labs/core/stations/oauth';
|
|
|
3
3
|
import { agentsDir } from '../agents/files.js';
|
|
4
4
|
import type { AgentKey } from './agent-key.js';
|
|
5
5
|
import { migrationRecord, migrationRecords } from './migration-state.js';
|
|
6
|
-
import { assertMigrationOutcomeKnown, beginMigrationOperation, migrationOperationsIdle, migrationRequestEffectful, type MigrationOperation } from './migration-operations.js';
|
|
6
|
+
import { assertMigrationOutcomeKnown, beginMigrationOperation, drainMigrationListeners, migrationOperationsIdle, migrationRequestEffectful, type MigrationOperation } from './migration-operations.js';
|
|
7
7
|
|
|
8
8
|
const locked = (phase: string): boolean => phase !== 'rolled_back';
|
|
9
9
|
const retired = (record: NonNullable<ReturnType<typeof migrationRecord>>): boolean => record.move === true || record.phase === 'cutover' || record.phase === 'active' || record.phase === 'rollback';
|
|
@@ -26,17 +26,19 @@ export function assertLocalMigrationUse(id: string, dir = agentsDir()): void {
|
|
|
26
26
|
assertMigrationOutcomeKnown(id, dir);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export function assertLocalMigrationEdit(id: string,
|
|
30
|
-
if (migrationRecords(dir).some((record) => locked(record.phase) &&
|
|
29
|
+
export function assertLocalMigrationEdit(id: string, dir = agentsDir()): void {
|
|
30
|
+
if (migrationRecords(dir).some((record) => locked(record.phase) && record.sourceId === id)) throw refused();
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
export function migrationExportableRows<T extends { id: string; name: string }>(rows: readonly T[], dir = agentsDir()): T[] {
|
|
34
34
|
const records = migrationRecords(dir).filter((record) => locked(record.phase));
|
|
35
|
-
return rows.filter((row) => !records.some((record) => record.sourceId === row.id
|
|
35
|
+
return rows.filter((row) => !records.some((record) => record.sourceId === row.id));
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export function localMigrationIdle(id: string, dir = agentsDir(), allowUncertain = false): boolean {
|
|
39
|
-
|
|
39
|
+
const record = migrationSource(id, dir);
|
|
40
|
+
if (record !== null && retired(record)) drainMigrationListeners(id, dir);
|
|
41
|
+
return migrationOperationsIdle(id, dir, !allowUncertain && record !== null);
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
export interface LocalMigrationOperation extends MigrationOperation {
|
|
@@ -45,11 +47,11 @@ export interface LocalMigrationOperation extends MigrationOperation {
|
|
|
45
47
|
close: () => void;
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
export function beginLocalMigrationOperation(id: string, dir = agentsDir(), edit = false
|
|
50
|
+
export function beginLocalMigrationOperation(id: string, dir = agentsDir(), edit = false): LocalMigrationOperation {
|
|
49
51
|
let closed = false;
|
|
50
52
|
const check = (): void => {
|
|
51
53
|
if (closed) throw refused();
|
|
52
|
-
if (edit) assertLocalMigrationEdit(id,
|
|
54
|
+
if (edit) assertLocalMigrationEdit(id, dir);
|
|
53
55
|
else assertLocalMigrationUse(id, dir);
|
|
54
56
|
};
|
|
55
57
|
check();
|
|
@@ -61,14 +63,15 @@ export function beginLocalMigrationOperation(id: string, dir = agentsDir(), edit
|
|
|
61
63
|
return {
|
|
62
64
|
check,
|
|
63
65
|
dispatch,
|
|
66
|
+
passiveListener: tracked.passiveListener,
|
|
64
67
|
persistedRefresh: tracked.persistedRefresh,
|
|
65
68
|
fetch: (input, init) => dispatch(() => fetch(input, init), { effectful: migrationRequestEffectful(init?.method, init?.body) }),
|
|
66
69
|
close: () => { closed = true; tracked.close(); },
|
|
67
70
|
};
|
|
68
71
|
}
|
|
69
72
|
|
|
70
|
-
export async function withLocalMigrationOperation<T>(id: string, dir: string, run: (operation: LocalMigrationOperation) => Promise<T>, edit = false
|
|
71
|
-
const operation = beginLocalMigrationOperation(id, dir, edit
|
|
73
|
+
export async function withLocalMigrationOperation<T>(id: string, dir: string, run: (operation: LocalMigrationOperation) => Promise<T>, edit = false): Promise<T> {
|
|
74
|
+
const operation = beginLocalMigrationOperation(id, dir, edit);
|
|
72
75
|
try { return await run(operation); }
|
|
73
76
|
finally { operation.close(); }
|
|
74
77
|
}
|
|
@@ -17,13 +17,20 @@ interface RequestOperation {
|
|
|
17
17
|
refresh: boolean;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
interface PassiveListener {
|
|
21
|
+
idle: () => boolean;
|
|
22
|
+
stop: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
export interface MigrationOperation {
|
|
26
|
+
passiveListener: (listener: PassiveListener) => void;
|
|
21
27
|
dispatch: (send: () => Promise<Response>, options?: { protectedSource?: boolean; effectful?: boolean; refresh?: boolean }) => Promise<Response>;
|
|
22
28
|
close: () => void;
|
|
23
29
|
persistedRefresh: () => void;
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
const leases = new Map<string, Set<string>>();
|
|
33
|
+
const listeners = new Map<string, PassiveListener>();
|
|
27
34
|
const requests = new Map<string, RequestOperation>();
|
|
28
35
|
const held = new Map<string, RequestOperation>();
|
|
29
36
|
const keyOf = (source: string, dir: string): string => JSON.stringify([resolve(dir), source]);
|
|
@@ -103,6 +110,17 @@ export function assertMigrationOutcomeKnown(source: string, dir = agentsDir()):
|
|
|
103
110
|
if (migrationUncertaintyReceipt(source, dir) !== null) throw uncertain();
|
|
104
111
|
}
|
|
105
112
|
|
|
113
|
+
export function drainMigrationListeners(source: string, dir: string): void {
|
|
114
|
+
const active = [...(leases.get(keyOf(source, dir)) ?? [])];
|
|
115
|
+
if (active.length === 0 || active.some((lease) => listeners.get(lease)?.idle() !== true)) return;
|
|
116
|
+
if (readPending(source, dir).some((id) => requests.has(id))) return;
|
|
117
|
+
for (const lease of active) {
|
|
118
|
+
const listener = listeners.get(lease);
|
|
119
|
+
listeners.delete(lease);
|
|
120
|
+
listener?.stop();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
106
124
|
export function migrationOperationsIdle(source: string, dir: string, protectedSource: boolean): boolean {
|
|
107
125
|
if (leases.has(keyOf(source, dir))) return false;
|
|
108
126
|
return !protectedSource || readPending(source, dir).length === 0;
|
|
@@ -181,6 +199,7 @@ export function beginMigrationOperation(source: string, dir: string, protectedSo
|
|
|
181
199
|
leases.set(key, active);
|
|
182
200
|
let closed = false;
|
|
183
201
|
return {
|
|
202
|
+
passiveListener: (listener) => { if (!closed) listeners.set(lease, listener); },
|
|
184
203
|
dispatch: (send, options = {}) => {
|
|
185
204
|
if (closed) return Promise.reject(uncertain());
|
|
186
205
|
return dispatch(source, dir, lease, options.protectedSource ?? protectedSource, options.effectful ?? true, options.refresh ?? false, send);
|
|
@@ -191,6 +210,7 @@ export function beginMigrationOperation(source: string, dir: string, protectedSo
|
|
|
191
210
|
close: () => {
|
|
192
211
|
if (closed) return;
|
|
193
212
|
closed = true;
|
|
213
|
+
listeners.delete(lease);
|
|
194
214
|
for (const request of held.values()) if (request.lease === lease) {
|
|
195
215
|
finish(request, false);
|
|
196
216
|
held.delete(request.id);
|
|
@@ -201,13 +201,11 @@ function previewRows(dir: string): MigrationConnector[] {
|
|
|
201
201
|
const rows = readRows(dir).map(previewRow);
|
|
202
202
|
const seen = new Map<string, MigrationConnector>();
|
|
203
203
|
for (const row of rows) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
for (const duplicate of [prior, row]) if (!duplicate.issues.includes('invalid_config')) duplicate.issues.push('invalid_config');
|
|
208
|
-
}
|
|
209
|
-
seen.set(key, row);
|
|
204
|
+
const prior = seen.get(row.id);
|
|
205
|
+
if (prior !== undefined) {
|
|
206
|
+
for (const duplicate of [prior, row]) if (!duplicate.issues.includes('invalid_config')) duplicate.issues.push('invalid_config');
|
|
210
207
|
}
|
|
208
|
+
seen.set(row.id, row);
|
|
211
209
|
}
|
|
212
210
|
return rows;
|
|
213
211
|
}
|
|
@@ -51,7 +51,7 @@ export async function startOAuth(
|
|
|
51
51
|
returnTo,
|
|
52
52
|
}, operation.fetch);
|
|
53
53
|
const row = await deps.createPendingConnector({
|
|
54
|
-
name
|
|
54
|
+
name,
|
|
55
55
|
url: bodyField(body, 'url'),
|
|
56
56
|
clientId,
|
|
57
57
|
clientSecret,
|
|
@@ -69,7 +69,7 @@ export async function startOAuth(
|
|
|
69
69
|
'connector-api: stored unconnected, sending the user to sign in',
|
|
70
70
|
);
|
|
71
71
|
sendJson(req, res, 201, { ...payload(row), authorizeUrl: authorize });
|
|
72
|
-
}, true
|
|
72
|
+
}, true);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
export async function handleConnect(
|
|
@@ -33,21 +33,48 @@ export function serverKey(name: string): string {
|
|
|
33
33
|
return slug === '' ? 'connector' : slug;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
function legacyOwners(rows: PluginRow[], published: PluginRow[]): Map<string, string> {
|
|
37
|
+
const visible = new Set(published.map((row) => row.id));
|
|
37
38
|
const taken = new Map<string, number>();
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
taken.
|
|
43
|
-
|
|
39
|
+
const owners = new Map<string, string>();
|
|
40
|
+
const reserved = new Set<string>();
|
|
41
|
+
for (const row of rows.filter((entry) => !isOrgRelayId(entry.id))) {
|
|
42
|
+
const slug = serverKey(row.name);
|
|
43
|
+
const seen = taken.get(slug) ?? 0;
|
|
44
|
+
taken.set(slug, seen + 1);
|
|
45
|
+
const key = seen === 0 ? slug : `${slug}-${row.id.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 4)}`;
|
|
46
|
+
reserved.add(key);
|
|
47
|
+
if (visible.has(row.id)) owners.set(key, row.id);
|
|
48
|
+
}
|
|
49
|
+
for (const row of rows.filter((entry) => isOrgRelayId(entry.id) && visible.has(entry.id)).sort((a, b) => a.id.localeCompare(b.id))) {
|
|
50
|
+
const key = availableKey(orgKey(row), reserved);
|
|
51
|
+
reserved.add(key);
|
|
52
|
+
owners.set(key, row.id);
|
|
44
53
|
}
|
|
54
|
+
return new Map([...owners].map(([key, id]) => [id, key]));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const orgKey = (row: PluginRow): string => `org-${serverKey(row.name).slice(0, 35)}-${Buffer.from(row.id.slice(4)).toString('hex')}`;
|
|
58
|
+
|
|
59
|
+
function availableKey(wanted: string, reserved: ReadonlySet<string>): string {
|
|
60
|
+
let key = wanted;
|
|
61
|
+
let suffix = 1;
|
|
62
|
+
while (reserved.has(key)) key = `${wanted}-${String(suffix++)}`;
|
|
63
|
+
return key;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function serverKeysOf(rows: PluginRow[], saved?: ReadonlyMap<string, string>, published: PluginRow[] = rows): Map<string, string> {
|
|
67
|
+
const keys = new Map(saved ?? legacyOwners(rows, published));
|
|
45
68
|
const reserved = new Set(keys.values());
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
69
|
+
const local = rows.filter((row) => !isOrgRelayId(row.id));
|
|
70
|
+
const organization = rows.filter((row) => isOrgRelayId(row.id)).sort((a, b) => a.id.localeCompare(b.id));
|
|
71
|
+
for (const row of [...local, ...organization]) {
|
|
72
|
+
if (keys.has(row.id)) continue;
|
|
73
|
+
const slug = serverKey(row.name);
|
|
74
|
+
const wanted = isOrgRelayId(row.id)
|
|
75
|
+
? orgKey(row)
|
|
76
|
+
: reserved.has(slug) ? `${slug}-${row.id.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 4)}` : slug;
|
|
77
|
+
const key = availableKey(wanted, reserved);
|
|
51
78
|
reserved.add(key);
|
|
52
79
|
keys.set(row.id, key);
|
|
53
80
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { registerConnectors, type GateRow } from './gates.js';
|
|
2
|
-
import {
|
|
2
|
+
import { syncPluginServers } from './plugin-sync.js';
|
|
3
|
+
import { persistentServerKeys } from './server-keys.js';
|
|
3
4
|
import { isOrgRelayId } from './relay-id.js';
|
|
4
5
|
import { migrationRecords } from './migration-state.js';
|
|
5
6
|
|
|
@@ -17,14 +18,7 @@ function metadata(rows: readonly GateRow[]): GateRow[] {
|
|
|
17
18
|
}));
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
let records: ReturnType<typeof migrationRecords>;
|
|
22
|
-
try { records = migrationRecords().filter((record) => record.phase !== 'rolled_back'); }
|
|
23
|
-
catch (error) {
|
|
24
|
-
registerConnectors([], local.map((row) => row.id));
|
|
25
|
-
syncPluginServers([]);
|
|
26
|
-
throw error;
|
|
27
|
-
}
|
|
21
|
+
function publishedRows(records: ReturnType<typeof migrationRecords>): GateRow[] {
|
|
28
22
|
const retired = new Set(records.filter((record) => !['preparing', 'prepared'].includes(record.phase)).map((record) => record.sourceId));
|
|
29
23
|
const aliases = new Map(organization.filter((row) => records.some((record) => record.phase === 'active' && record.sourceId === row.id && record.name === row.name)).map((row) => [row.id, row]));
|
|
30
24
|
const rows = local.flatMap((row) => {
|
|
@@ -32,8 +26,25 @@ export function publishConnectors(): void {
|
|
|
32
26
|
return alias !== undefined ? [alias] : retired.has(row.id) ? [] : [row];
|
|
33
27
|
});
|
|
34
28
|
rows.push(...organization.filter((row) => isOrgRelayId(row.id) && !records.some((record) => row.id === `org-${record.destinationId ?? ''}`)));
|
|
35
|
-
|
|
36
|
-
|
|
29
|
+
return rows;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function publishConnectors(): void {
|
|
33
|
+
let rows: GateRow[];
|
|
34
|
+
let keys: Map<string, string>;
|
|
35
|
+
try {
|
|
36
|
+
const records = migrationRecords().filter((record) => record.phase !== 'rolled_back');
|
|
37
|
+
rows = publishedRows(records);
|
|
38
|
+
const active = new Set(records.filter((record) => record.phase === 'active').map((record) => record.sourceId));
|
|
39
|
+
const reserved = [...rows, ...local.filter((row) => active.has(row.id))];
|
|
40
|
+
keys = persistentServerKeys([...local, ...organization.filter((row) => isOrgRelayId(row.id))], undefined, reserved);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
registerConnectors([]);
|
|
44
|
+
syncPluginServers([]);
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
registerConnectors(rows, keys);
|
|
37
48
|
syncPluginServers(rows, { keys });
|
|
38
49
|
}
|
|
39
50
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { JSONRPCNotificationSchema, ServerNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
+
import type { MigrationOperation } from './migration-operations.js';
|
|
5
|
+
|
|
6
|
+
export const isRelaySse = (contentType: string | null | undefined): boolean => /^text\/event-stream\s*(?:;|$)/i.test(contentType ?? '');
|
|
7
|
+
const MAX_FRAME = 64 * 1024;
|
|
8
|
+
const NOTIFICATIONS = new Set(['notifications/tools/list_changed', 'notifications/resources/list_changed', 'notifications/prompts/list_changed', 'notifications/resources/updated', 'notifications/message']);
|
|
9
|
+
|
|
10
|
+
export class PassiveSseListener {
|
|
11
|
+
forwarding = false;
|
|
12
|
+
private pending: Buffer[] = [];
|
|
13
|
+
private length = 0;
|
|
14
|
+
private data: string[] = [];
|
|
15
|
+
private fields = false;
|
|
16
|
+
private size = 0;
|
|
17
|
+
private unsafe = false;
|
|
18
|
+
private skipLf = false;
|
|
19
|
+
private first = true;
|
|
20
|
+
private readonly decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
21
|
+
|
|
22
|
+
idle(): boolean { return !this.unsafe && this.length === 0 && !this.fields && !this.forwarding; }
|
|
23
|
+
keepaliveAllowed(): boolean { return this.unsafe || this.idle(); }
|
|
24
|
+
|
|
25
|
+
observe(chunk: Uint8Array): void {
|
|
26
|
+
if (this.unsafe) return;
|
|
27
|
+
this.scan(chunk);
|
|
28
|
+
if (this.unsafe) { this.pending = []; this.length = 0; this.data = []; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private append(bytes: Uint8Array): void {
|
|
32
|
+
this.length += bytes.length;
|
|
33
|
+
if (this.length + this.size > MAX_FRAME) this.unsafe = true;
|
|
34
|
+
else if (bytes.length > 0) this.pending.push(Buffer.from(bytes));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private scan(bytes: Uint8Array): void {
|
|
38
|
+
let start = 0;
|
|
39
|
+
for (let at = 0; at < bytes.length; at += 1) {
|
|
40
|
+
if (at - start + this.length + this.size > MAX_FRAME) { this.unsafe = true; return; }
|
|
41
|
+
const byte = bytes[at];
|
|
42
|
+
if (this.skipLf && byte === 10) { this.skipLf = false; start = at + 1; continue; }
|
|
43
|
+
this.skipLf = false;
|
|
44
|
+
if (byte !== 10 && byte !== 13) continue;
|
|
45
|
+
this.append(bytes.subarray(start, at));
|
|
46
|
+
try { this.line(this.decoder.decode(Buffer.concat(this.pending, this.length)), this.length); }
|
|
47
|
+
catch { this.unsafe = true; }
|
|
48
|
+
this.pending = []; this.length = 0;
|
|
49
|
+
this.skipLf = byte === 13;
|
|
50
|
+
start = at + 1;
|
|
51
|
+
if (this.unsafe) return;
|
|
52
|
+
}
|
|
53
|
+
this.append(bytes.subarray(start));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private line(raw: string, length: number): void {
|
|
57
|
+
const line = this.first && raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
|
|
58
|
+
this.first = false;
|
|
59
|
+
if (line === '') {
|
|
60
|
+
if (this.data.length > 0 && !this.notification()) this.unsafe = true;
|
|
61
|
+
this.data = []; this.fields = false; this.size = 0;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (line.startsWith(':')) return;
|
|
65
|
+
this.size += length;
|
|
66
|
+
if (this.size > MAX_FRAME) { this.unsafe = true; return; }
|
|
67
|
+
this.fields = true;
|
|
68
|
+
const colon = line.indexOf(':');
|
|
69
|
+
const field = colon < 0 ? line : line.slice(0, colon);
|
|
70
|
+
const value = colon < 0 ? '' : line.slice(colon + 1).replace(/^ /, '');
|
|
71
|
+
this.field(field, value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private field(field: string, value: string): void {
|
|
75
|
+
if (field === 'data') this.data.push(value);
|
|
76
|
+
else if (field === 'event') { if (value !== '' && value !== 'message') this.unsafe = true; }
|
|
77
|
+
else if (field === 'retry') { if (!/^\d+$/.test(value)) this.unsafe = true; }
|
|
78
|
+
else this.unsafe = true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private notification(): boolean {
|
|
82
|
+
const text = this.data.join('\n');
|
|
83
|
+
if (text === '') return true;
|
|
84
|
+
const value: unknown = JSON.parse(text);
|
|
85
|
+
return isRecord(value) && typeof value.method === 'string' && NOTIFICATIONS.has(value.method)
|
|
86
|
+
&& JSONRPCNotificationSchema.safeParse(value).success && ServerNotificationSchema.safeParse(value).success;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function passiveRelayListener(req: IncomingMessage, res: ServerResponse, upstream: Response, operation: MigrationOperation, stop: () => void): PassiveSseListener | undefined {
|
|
91
|
+
if (req.method !== 'GET' || req.headers['last-event-id'] !== undefined || req.headers['transfer-encoding'] !== undefined || Number(req.headers['content-length'] ?? 0) !== 0) return undefined;
|
|
92
|
+
if (upstream.status !== 200 || upstream.body === null || !isRelaySse(upstream.headers.get('content-type'))) return undefined;
|
|
93
|
+
const listener = new PassiveSseListener();
|
|
94
|
+
operation.passiveListener({ idle: () => listener.idle() && res.writableLength === 0 && !res.destroyed, stop });
|
|
95
|
+
return listener;
|
|
96
|
+
}
|
|
@@ -3,6 +3,7 @@ import { whyUnreachable } from '@metro-labs/http/connector-reach';
|
|
|
3
3
|
import { noteHealth } from './health.js';
|
|
4
4
|
import type { RelayTarget } from './relay-target.js';
|
|
5
5
|
import type { RelaySession } from './relay-session.js';
|
|
6
|
+
import { isRelaySse, passiveRelayListener, type PassiveSseListener } from './relay-passive.js';
|
|
6
7
|
import { isOrgRelayId } from './relay-id.js';
|
|
7
8
|
import { exchangeOrg } from './org-relay.js';
|
|
8
9
|
import { assertLocalMigrationUse, migrationSource, withLocalMigrationOperation } from './migration-guard.js';
|
|
@@ -131,12 +132,12 @@ interface Keepalive {
|
|
|
131
132
|
stop: () => void;
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
function startKeepalive(res: ServerResponse): Keepalive {
|
|
135
|
+
function startKeepalive(res: ServerResponse, listener?: PassiveSseListener): Keepalive {
|
|
135
136
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
136
137
|
const arm = (): void => {
|
|
137
138
|
timer = setTimeout(() => {
|
|
138
139
|
try {
|
|
139
|
-
res.write(': keepalive\n\n');
|
|
140
|
+
if (listener?.keepaliveAllowed() !== false) res.write(': keepalive\n\n');
|
|
140
141
|
} catch {
|
|
141
142
|
log.debug('relay: keepalive write failed');
|
|
142
143
|
}
|
|
@@ -172,13 +173,17 @@ async function pumpBody(
|
|
|
172
173
|
res: ServerResponse,
|
|
173
174
|
body: ReadableStream<Uint8Array>,
|
|
174
175
|
touch: () => void,
|
|
176
|
+
listener?: PassiveSseListener,
|
|
175
177
|
): Promise<void> {
|
|
176
178
|
const reader = body.getReader();
|
|
177
179
|
for (;;) {
|
|
178
180
|
const { done, value } = await reader.read();
|
|
179
181
|
if (done) return;
|
|
182
|
+
listener?.observe(value);
|
|
180
183
|
touch();
|
|
184
|
+
if (listener !== undefined) listener.forwarding = true;
|
|
181
185
|
if (!res.write(value)) await drainedOrClosed(res);
|
|
186
|
+
if (listener !== undefined) listener.forwarding = false;
|
|
182
187
|
}
|
|
183
188
|
}
|
|
184
189
|
|
|
@@ -191,22 +196,23 @@ function passedHeaders(upstream: Response): Record<string, string> {
|
|
|
191
196
|
return headers;
|
|
192
197
|
}
|
|
193
198
|
|
|
194
|
-
async function pipe(res: ServerResponse, upstream: Response, answers: Record<string, unknown>[]): Promise<void> {
|
|
199
|
+
async function pipe(res: ServerResponse, upstream: Response, answers: Record<string, unknown>[], listener?: PassiveSseListener): Promise<void> {
|
|
195
200
|
const headers = passedHeaders(upstream);
|
|
196
201
|
if (answers.length > 0 && (upstream.status === 200 || upstream.status === 202)) {
|
|
197
202
|
await mergeBlocked(res, upstream, headers, answers);
|
|
198
203
|
return;
|
|
199
204
|
}
|
|
200
|
-
const sse = (headers['content-type']
|
|
205
|
+
const sse = isRelaySse(headers['content-type']);
|
|
201
206
|
if (sse) headers['x-accel-buffering'] = 'no';
|
|
202
207
|
res.writeHead(upstream.status, headers);
|
|
208
|
+
if (sse) res.flushHeaders();
|
|
203
209
|
if (upstream.body === null) {
|
|
204
210
|
res.end();
|
|
205
211
|
return;
|
|
206
212
|
}
|
|
207
|
-
const keepalive = sse ? startKeepalive(res) : null;
|
|
213
|
+
const keepalive = sse ? startKeepalive(res, listener) : null;
|
|
208
214
|
try {
|
|
209
|
-
await pumpBody(res, upstream.body, () => keepalive?.touch());
|
|
215
|
+
await pumpBody(res, upstream.body, () => keepalive?.touch(), listener);
|
|
210
216
|
} finally {
|
|
211
217
|
keepalive?.stop();
|
|
212
218
|
}
|
|
@@ -262,7 +268,7 @@ async function relayExchange(
|
|
|
262
268
|
res.socket?.removeListener('close', bail);
|
|
263
269
|
});
|
|
264
270
|
try {
|
|
265
|
-
await relayOnce(req, res, connectorId, deps, control
|
|
271
|
+
await relayOnce(req, res, connectorId, deps, control);
|
|
266
272
|
} catch (err) {
|
|
267
273
|
if (!control.signal.aborted) throw err;
|
|
268
274
|
log.debug({ connector: connectorId }, 'relay: the client left before the answer ended');
|
|
@@ -276,6 +282,7 @@ async function deliver(
|
|
|
276
282
|
deps: RelayApiDeps,
|
|
277
283
|
out: Exchanged,
|
|
278
284
|
answers: Record<string, unknown>[],
|
|
285
|
+
listener?: PassiveSseListener,
|
|
279
286
|
): Promise<void> {
|
|
280
287
|
if (out.kind === 'missing') {
|
|
281
288
|
answer(res, 404, { error: 'no such connector' });
|
|
@@ -295,7 +302,7 @@ async function deliver(
|
|
|
295
302
|
}
|
|
296
303
|
const { status } = out.upstream;
|
|
297
304
|
noteHealth(connectorId, status < 400, status < 400 ? null : `the connector answered ${String(status)}`);
|
|
298
|
-
await pipe(res, out.upstream, answers);
|
|
305
|
+
await pipe(res, out.upstream, answers, listener);
|
|
299
306
|
}
|
|
300
307
|
|
|
301
308
|
async function relayOrg(req: IncomingMessage, res: ServerResponse, id: string, deps: RelayApiDeps, read: Uint8Array<ArrayBuffer> | null, signal: AbortSignal): Promise<void> {
|
|
@@ -316,8 +323,9 @@ async function relayOnce(
|
|
|
316
323
|
res: ServerResponse,
|
|
317
324
|
connectorId: string,
|
|
318
325
|
deps: RelayApiDeps,
|
|
319
|
-
|
|
326
|
+
control: AbortController,
|
|
320
327
|
): Promise<void> {
|
|
328
|
+
const { signal } = control;
|
|
321
329
|
const read = req.method === 'POST' ? await readCapped(req) : null;
|
|
322
330
|
const dir = deps.migrationDir ?? agentsDir();
|
|
323
331
|
if (isOrgRelayId(connectorId) || migrationSource(connectorId, dir)?.phase === 'active') return relayOrg(req, res, connectorId, deps, read, signal);
|
|
@@ -328,7 +336,8 @@ async function relayOnce(
|
|
|
328
336
|
return;
|
|
329
337
|
}
|
|
330
338
|
const out = await exchange(req, connectorId, deps, screened === null ? read : screened.forward, signal, operation.dispatch);
|
|
331
|
-
|
|
339
|
+
const listener = out.kind === 'response' ? passiveRelayListener(req, res, out.upstream, operation, () => { control.abort(); }) : undefined;
|
|
340
|
+
await deliver(res, connectorId, deps, out, screened?.answers ?? [], listener);
|
|
332
341
|
});
|
|
333
342
|
}
|
|
334
343
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
+
import { writeSecure } from '@metro-labs/core/secure-fs';
|
|
5
|
+
import { agentsDir } from '../agents/files.js';
|
|
6
|
+
import { serverKeysOf, type PluginRow } from './plugin-sync.js';
|
|
7
|
+
|
|
8
|
+
const invalid = (): Error => new Error('Invalid connector server keys. Restore the saved namespace mapping and initialization marker.');
|
|
9
|
+
|
|
10
|
+
function readKeys(path: string, marker: string): Map<string, string> | undefined {
|
|
11
|
+
try {
|
|
12
|
+
const marked = existsSync(marker);
|
|
13
|
+
if (marked && readFileSync(marker, 'utf8') !== '1\n') throw invalid();
|
|
14
|
+
if (!existsSync(path)) {
|
|
15
|
+
if (marked) throw invalid();
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
if (!marked) throw invalid();
|
|
19
|
+
return parseKeys(readFileSync(path, 'utf8'));
|
|
20
|
+
} catch { throw invalid(); }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseKeys(text: string): Map<string, string> {
|
|
24
|
+
const raw: unknown = JSON.parse(text);
|
|
25
|
+
if (!isRecord(raw) || raw.version !== 1 || !isRecord(raw.keys)) throw invalid();
|
|
26
|
+
const keys = new Map<string, string>();
|
|
27
|
+
const reserved = new Set<string>();
|
|
28
|
+
for (const [id, key] of Object.entries(raw.keys)) {
|
|
29
|
+
if (typeof key !== 'string' || !/^[a-z0-9-]+$/.test(key) || reserved.has(key)) throw invalid();
|
|
30
|
+
reserved.add(key);
|
|
31
|
+
keys.set(id, key);
|
|
32
|
+
}
|
|
33
|
+
return keys;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function persistentServerKeys(rows: PluginRow[], dir = agentsDir(), published: PluginRow[] = rows): Map<string, string> {
|
|
37
|
+
const path = join(dir, 'connector-server-keys.json');
|
|
38
|
+
const marker = join(dir, 'connector-server-keys.initialized');
|
|
39
|
+
const saved = readKeys(path, marker);
|
|
40
|
+
const keys = serverKeysOf(rows, saved, published);
|
|
41
|
+
if (keys.size > 0 && keys.size !== saved?.size) {
|
|
42
|
+
if (saved === undefined) writeSecure(marker, '1\n');
|
|
43
|
+
writeSecure(path, `${JSON.stringify({ version: 1, keys: Object.fromEntries(keys) }, null, 2)}\n`);
|
|
44
|
+
}
|
|
45
|
+
return keys;
|
|
46
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { closeSync, fsyncSync, openSync } from 'node:fs';
|
|
3
3
|
import { publishLocalConnectors } from './publication.js';
|
|
4
|
+
import { persistentServerKeys } from './server-keys.js';
|
|
4
5
|
import { assertLocalMigrationEdit, localMigrationEnabled, localMigrationIdle, migrationSource, withLocalMigrationOperation } from './migration-guard.js';
|
|
5
6
|
import type { FetchLike } from '@metro-labs/core/stations/oauth';
|
|
6
7
|
import type { RelayTarget } from './relay-target.js';
|
|
@@ -116,6 +117,8 @@ export function readLocalConnectors(dir = agentsDir()): LocalConnectorRow[] {
|
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
function writeRows(dir: string, rows: LocalConnectorRow[]): void {
|
|
120
|
+
persistentServerKeys(readLocalConnectors(dir), dir);
|
|
121
|
+
persistentServerKeys(rows, dir);
|
|
119
122
|
writeSecure(filePath(dir), `${JSON.stringify({ version: 1, connectors: rows }, null, 2)}\n`);
|
|
120
123
|
if (dir !== agentsDir()) return;
|
|
121
124
|
publishLocalConnectors(rows);
|
|
@@ -130,7 +133,7 @@ function rowOrThrow(id: string, dir: string): LocalConnectorRow {
|
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
function replace(dir: string, row: LocalConnectorRow, refresh = false): Connector {
|
|
133
|
-
if (!refresh) assertLocalMigrationEdit(row.id,
|
|
136
|
+
if (!refresh) assertLocalMigrationEdit(row.id, dir);
|
|
134
137
|
writeRows(dir, readLocalConnectors(dir).map((r) => (r.id === row.id ? row : r)));
|
|
135
138
|
return connectorFromRow(row);
|
|
136
139
|
}
|
|
@@ -150,17 +153,17 @@ export function localImportConnectors(
|
|
|
150
153
|
const fresh =
|
|
151
154
|
mode === 'overwrite'
|
|
152
155
|
? rows
|
|
153
|
-
: rows.filter((row) => !current.some((r) => r.id === row.id
|
|
156
|
+
: rows.filter((row) => !current.some((r) => r.id === row.id));
|
|
154
157
|
const imported: LocalConnectorRow[] = fresh.map((row) => ({
|
|
155
158
|
id: row.id,
|
|
156
159
|
name: row.name,
|
|
157
160
|
url: row.url,
|
|
158
161
|
config: readConfig(row.config),
|
|
159
162
|
}));
|
|
160
|
-
for (const row of imported) assertLocalMigrationEdit(row.id,
|
|
163
|
+
for (const row of imported) assertLocalMigrationEdit(row.id, dir);
|
|
161
164
|
const ids = new Set(imported.map((r) => r.id));
|
|
162
|
-
|
|
163
|
-
writeRows(dir, [...current.filter((r) => !ids.has(r.id)
|
|
165
|
+
if (ids.size !== imported.length) throw new ConnectorError('The import contains duplicate connector IDs.', 400);
|
|
166
|
+
writeRows(dir, [...current.filter((r) => !ids.has(r.id)), ...imported]);
|
|
164
167
|
return imported.length;
|
|
165
168
|
}
|
|
166
169
|
|
|
@@ -172,14 +175,7 @@ export async function localGetConnector(id: string, dir = agentsDir()): Promise<
|
|
|
172
175
|
return Promise.resolve(connectorFromRow(rowOrThrow(id, dir)));
|
|
173
176
|
}
|
|
174
177
|
|
|
175
|
-
function assertNameFree(name: string, exceptId: string | null, dir: string): void {
|
|
176
|
-
if (readLocalConnectors(dir).some((r) => r.id !== exceptId && r.name === name))
|
|
177
|
-
throw new ConnectorError(`a connector named '${name}' already exists on this daemon`, 409);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
178
|
function insert(dir: string, name: string, url: URL, config: ConnectorConfig): Connector {
|
|
181
|
-
assertLocalMigrationEdit('', name, dir);
|
|
182
|
-
assertNameFree(name, null, dir);
|
|
183
179
|
const row: LocalConnectorRow = { id: newId(), name, url: connectorUrlText(url), config };
|
|
184
180
|
writeRows(dir, [...readLocalConnectors(dir), row]);
|
|
185
181
|
return connectorFromRow(row);
|
|
@@ -193,7 +189,7 @@ export async function localCreateConnector(input: ConnectorInput, dir = agentsDi
|
|
|
193
189
|
return withLocalMigrationOperation('', dir, async (operation) => {
|
|
194
190
|
const verified = stamp(await verifyRemoteMcp(url, auth, operation.fetch));
|
|
195
191
|
return insert(dir, name, url, { auth, createdAt: new Date().toISOString(), verified, oauth: await oauthCapable(url, auth, operation.fetch), client });
|
|
196
|
-
}, true
|
|
192
|
+
}, true);
|
|
197
193
|
}
|
|
198
194
|
|
|
199
195
|
export async function localCreatePendingConnector(input: PendingConnectorInput, dir = agentsDir()): Promise<Connector> {
|
|
@@ -309,13 +305,12 @@ export async function loadConnectorPolicies(dir = agentsDir()): Promise<void> {
|
|
|
309
305
|
export async function localRenameConnector(id: string, raw: string, dir = agentsDir()): Promise<Connector> {
|
|
310
306
|
const name = connectorName(raw);
|
|
311
307
|
const row = rowOrThrow(id, dir);
|
|
312
|
-
assertNameFree(name, id, dir);
|
|
313
308
|
return Promise.resolve(replace(dir, { ...row, name }));
|
|
314
309
|
}
|
|
315
310
|
|
|
316
311
|
export async function localDeleteConnector(id: string, dir = agentsDir()): Promise<DeletedConnector> {
|
|
317
312
|
const row = rowOrThrow(id, dir);
|
|
318
|
-
assertLocalMigrationEdit(id,
|
|
313
|
+
assertLocalMigrationEdit(id, dir);
|
|
319
314
|
writeRows(dir, readLocalConnectors(dir).filter((r) => r.id !== id));
|
|
320
315
|
return Promise.resolve({ id: row.id, name: row.name });
|
|
321
316
|
}
|
|
@@ -6,7 +6,7 @@ import { connectorAuth, connectorName } from '@metro-labs/http/connector-input';
|
|
|
6
6
|
import { readConfig } from './config.js';
|
|
7
7
|
import { parseConnectorUrl } from '@metro-labs/http/connector-url';
|
|
8
8
|
import { normalizePolicy } from '../policy/policy.js';
|
|
9
|
-
import { localImportConnectors,
|
|
9
|
+
import { localImportConnectors, type LocalConnectorRow } from './store.js';
|
|
10
10
|
|
|
11
11
|
export interface ConnectorCopyResult {
|
|
12
12
|
sourceId: string | null;
|
|
@@ -28,7 +28,6 @@ function copyRow(raw: unknown): LocalConnectorRow {
|
|
|
28
28
|
export function copyConnectorRows(value: unknown, dir?: string): ConnectorCopyResult[] {
|
|
29
29
|
if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.connectors) || value.connectors.length > 500)
|
|
30
30
|
throw new ApiError('invalid connector transfer', 400);
|
|
31
|
-
const names = new Set(readLocalConnectors(dir).map((row) => row.name));
|
|
32
31
|
const imported: LocalConnectorRow[] = [];
|
|
33
32
|
const results: ConnectorCopyResult[] = value.connectors.map((raw: unknown) => {
|
|
34
33
|
const sourceId = isRecord(raw) && typeof raw.id === 'string' ? parseId(raw.id) : null;
|
|
@@ -38,8 +37,6 @@ export function copyConnectorRows(value: unknown, dir?: string): ConnectorCopyRe
|
|
|
38
37
|
} catch {
|
|
39
38
|
return { sourceId, status: 'invalid' };
|
|
40
39
|
}
|
|
41
|
-
if (names.has(row.name)) return { sourceId, status: 'skipped' };
|
|
42
|
-
names.add(row.name);
|
|
43
40
|
const id = newId();
|
|
44
41
|
imported.push({ ...row, id });
|
|
45
42
|
return { sourceId, id, status: 'copied' };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { str } from '@metro-labs/core/str';
|
|
2
2
|
import { readPreview, shownValue } from '../approvals/preview.js';
|
|
3
|
-
import { connectorToolOf } from '../connectors/gates.js';
|
|
3
|
+
import { connectorGates, connectorToolOf } from '../connectors/gates.js';
|
|
4
4
|
import { stationByName } from '../stations/registry.js';
|
|
5
5
|
import { stationOfAccount } from './read-tool.js';
|
|
6
6
|
|
|
@@ -92,7 +92,11 @@ function bodyLines(params: PermissionParams): string[] {
|
|
|
92
92
|
const raw = params.input_preview.trim() === '' ? [] : [shorten(params.input_preview, RAW_MAX)];
|
|
93
93
|
if (metro !== undefined) return input === undefined ? [`Approval needed: ${metro}`, ...raw] : metroLines(metro, input);
|
|
94
94
|
const connector = connectorToolOf(params.tool_name);
|
|
95
|
-
if (connector !== undefined)
|
|
95
|
+
if (connector !== undefined) {
|
|
96
|
+
const { gate, tool } = connector;
|
|
97
|
+
const duplicate = connectorGates().some((entry) => entry.id !== gate.id && entry.name === gate.name);
|
|
98
|
+
return connectorLines(duplicate ? `${gate.name} (${gate.id})` : gate.name, tool, input);
|
|
99
|
+
}
|
|
96
100
|
return [`Claude wants to run ${params.tool_name}: ${shorten(params.description)}`, '', ...raw];
|
|
97
101
|
}
|
|
98
102
|
|
|
@@ -4,7 +4,6 @@ import { accountFromLine, knownAccounts } from '../agents/map.js';
|
|
|
4
4
|
import { policyFor, storedPolicy, strictest, type Access, type PolicyTarget } from '../policy/policy.js';
|
|
5
5
|
import { errResult } from './ctx.js';
|
|
6
6
|
import { NEEDS_APPROVAL, takeGrant } from '../approvals/pending.js';
|
|
7
|
-
import { connectorToolOf } from '../connectors/gates.js';
|
|
8
7
|
import { profileScopeLine } from './profile-lookup.js';
|
|
9
8
|
import { stationOfAccount } from './read-tool.js';
|
|
10
9
|
import { stationForTool, toolGroupOf } from './tool-catalog.js';
|
|
@@ -56,7 +55,7 @@ export function channelDecision(name: string, a: Record<string, unknown>): Polic
|
|
|
56
55
|
return verdict;
|
|
57
56
|
}
|
|
58
57
|
|
|
59
|
-
const isChannelTool = (name: string) => (tool: string): boolean => tool
|
|
58
|
+
const isChannelTool = (name: string) => (tool: string): boolean => tool === `mcp__metro__${name}`;
|
|
60
59
|
|
|
61
60
|
export function policyGate(name: string, a: Record<string, unknown>): ToolResult | undefined {
|
|
62
61
|
const { access, target } = channelDecision(name, a);
|
package/runtime/runtime.json
CHANGED