@stage-labs/metro 0.1.0-beta.42 → 0.1.0-beta.43
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/dist/api.js +19 -2
- package/dist/cli.js +11 -8
- package/dist/local.js +67 -0
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-url.ts +20 -7
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-cli-api.ts +70 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +66 -22
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/relay.ts +3 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +4 -8
- package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +12 -2
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +36 -1
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-source.ts +11 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/local-connectors.ts +312 -0
- package/runtime/runtime.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/hosted-connectors.ts +0 -118
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-connectors-api.ts +0 -56
package/dist/api.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { metroUrl, readRunTokens, readToken } from './store.js';
|
|
2
|
+
import { localAgents, localDaemonUp, localMcpServers, pickLocalAgent } from './local.js';
|
|
3
|
+
import { localUrl } from './runtime.js';
|
|
2
4
|
export class NotSignedIn extends Error {
|
|
3
5
|
}
|
|
4
6
|
const TIMEOUT_MS = 20_000;
|
|
@@ -115,13 +117,28 @@ export async function claimRuntime(code, label) {
|
|
|
115
117
|
throw new Error('metro returned an unexpected response');
|
|
116
118
|
return { token: body.token, agent: body.agent, label: body.label };
|
|
117
119
|
}
|
|
118
|
-
export async function mcpServers() {
|
|
120
|
+
export async function mcpServers(wanted) {
|
|
121
|
+
if (await localDaemonUp()) {
|
|
122
|
+
const agents = localAgents();
|
|
123
|
+
if (agents.length > 0)
|
|
124
|
+
return localMcpServers(pickLocalAgent(agents, wanted));
|
|
125
|
+
}
|
|
119
126
|
const body = await get('/api/cli/mcp');
|
|
120
127
|
if (!isRecord(body) || typeof body.json !== 'string')
|
|
121
128
|
throw new Error('metro returned an unexpected response');
|
|
122
129
|
return body.json;
|
|
123
130
|
}
|
|
124
|
-
export async function whoisAuthorized() {
|
|
131
|
+
export async function whoisAuthorized(wanted) {
|
|
132
|
+
if (await localDaemonUp()) {
|
|
133
|
+
const agents = localAgents();
|
|
134
|
+
if (agents.length > 0) {
|
|
135
|
+
const agent = pickLocalAgent(agents, wanted);
|
|
136
|
+
return { email: 'this machine', agent: agent.name, where: `${localUrl()} (local)` };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { ...(await hostedIdentity()), where: metroUrl() };
|
|
140
|
+
}
|
|
141
|
+
async function hostedIdentity() {
|
|
125
142
|
const body = await get('/api/cli/session');
|
|
126
143
|
if (!isRecord(body) ||
|
|
127
144
|
typeof body.email !== 'string' ||
|
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { hostname } from 'node:os';
|
|
3
3
|
import { claimCode, claimRuntime, mcpServers, NotSignedIn, whoisAuthorized, } from './api.js';
|
|
4
4
|
import { askSecret } from './prompt.js';
|
|
5
|
-
import { clearToken, credentialsPath,
|
|
5
|
+
import { clearToken, credentialsPath, metroWebUrl, writeToken, } from './store.js';
|
|
6
6
|
import { detach, lockedBy, probe, runningPid, stopAll, tail } from './control.js';
|
|
7
7
|
import { tailEvents } from './tail.js';
|
|
8
8
|
import { assertAgentId, daemonPlan, localUrl, readRunToken, runDaemon, runtimeDir, runTokenState, writeRunToken, } from './runtime.js';
|
|
@@ -32,8 +32,11 @@ const USAGE = `metro — the command line for your MCP connectors
|
|
|
32
32
|
metro login [code]
|
|
33
33
|
authorize this machine for one agent with a code from its page
|
|
34
34
|
metro logout forget this machine's sign-in
|
|
35
|
-
metro whoami
|
|
36
|
-
|
|
35
|
+
metro whoami [agent]
|
|
36
|
+
print the account and agent this machine may read
|
|
37
|
+
metro mcp [agent]
|
|
38
|
+
print the mcpServers block of the agent's connectors; with a local daemon
|
|
39
|
+
running, its agent's connectors (name one if several)
|
|
37
40
|
metro plugin set up the Claude Code plugin (connector servers + /metro:login)
|
|
38
41
|
metro claude [args...]
|
|
39
42
|
open Claude Code with the metro channel; every argument is passed through
|
|
@@ -142,9 +145,9 @@ async function login(given) {
|
|
|
142
145
|
process.stderr.write('Claude Code plugin refreshed — new sessions have the connectors; ' +
|
|
143
146
|
'run /reload-plugins in any session already open.\n');
|
|
144
147
|
}
|
|
145
|
-
async function whoami() {
|
|
146
|
-
const { email, agent } = await whoisAuthorized();
|
|
147
|
-
process.stdout.write(`${email} · agent '${agent}' on ${
|
|
148
|
+
async function whoami(wanted) {
|
|
149
|
+
const { email, agent, where } = await whoisAuthorized(wanted);
|
|
150
|
+
process.stdout.write(`${email} · agent '${agent}' on ${where}\n`);
|
|
148
151
|
}
|
|
149
152
|
const HELP = new Set([undefined, 'help', '--help', '-h']);
|
|
150
153
|
const COMMANDS = {
|
|
@@ -158,7 +161,7 @@ const COMMANDS = {
|
|
|
158
161
|
return Promise.resolve(0);
|
|
159
162
|
},
|
|
160
163
|
whoami: async () => {
|
|
161
|
-
await whoami();
|
|
164
|
+
await whoami(process.argv[3]);
|
|
162
165
|
return 0;
|
|
163
166
|
},
|
|
164
167
|
start: () => start(process.argv.slice(3)),
|
|
@@ -168,7 +171,7 @@ const COMMANDS = {
|
|
|
168
171
|
logs: () => logs(process.argv.slice(3)),
|
|
169
172
|
tail: () => tailEvents(process.argv.slice(3)),
|
|
170
173
|
mcp: async () => {
|
|
171
|
-
process.stdout.write(`${await mcpServers()}\n`);
|
|
174
|
+
process.stdout.write(`${await mcpServers(process.argv[3])}\n`);
|
|
172
175
|
return 0;
|
|
173
176
|
},
|
|
174
177
|
plugin: installPlugin,
|
package/dist/local.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { localUrl } from './runtime.js';
|
|
5
|
+
const PROBE_MS = 3_000;
|
|
6
|
+
const READ_MS = 20_000;
|
|
7
|
+
export function agentsDir() {
|
|
8
|
+
const explicit = process.env.METRO_AGENTS_DIR?.trim();
|
|
9
|
+
return explicit !== undefined && explicit !== '' ? explicit : join(homedir(), '.metro', 'agents');
|
|
10
|
+
}
|
|
11
|
+
export function localAgents(dir = agentsDir()) {
|
|
12
|
+
if (!existsSync(dir))
|
|
13
|
+
return [];
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
16
|
+
if (!entry.isDirectory())
|
|
17
|
+
continue;
|
|
18
|
+
const path = join(dir, entry.name, 'agent.json');
|
|
19
|
+
if (!existsSync(path))
|
|
20
|
+
continue;
|
|
21
|
+
try {
|
|
22
|
+
const file = JSON.parse(readFileSync(path, 'utf8'));
|
|
23
|
+
if (typeof file.id === 'string' && typeof file.name === 'string' && typeof file.key === 'string')
|
|
24
|
+
out.push({ id: file.id, name: file.name, key: file.key });
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
31
|
+
}
|
|
32
|
+
export function pickLocalAgent(agents, wanted) {
|
|
33
|
+
if (wanted !== undefined && wanted !== '') {
|
|
34
|
+
const found = agents.find((a) => a.name === wanted || a.id === wanted);
|
|
35
|
+
if (found === undefined)
|
|
36
|
+
throw new Error(`no local agent named '${wanted}'`);
|
|
37
|
+
return found;
|
|
38
|
+
}
|
|
39
|
+
const only = agents[0];
|
|
40
|
+
if (only !== undefined && agents.length === 1)
|
|
41
|
+
return only;
|
|
42
|
+
throw new Error(agents.length === 0
|
|
43
|
+
? 'no agent on this machine yet'
|
|
44
|
+
: `several agents on this machine — name one: ${agents.map((a) => a.name).join(', ')}`);
|
|
45
|
+
}
|
|
46
|
+
export async function localDaemonUp(base = localUrl()) {
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(`${base}/api/mode`, { signal: AbortSignal.timeout(PROBE_MS) });
|
|
49
|
+
const body = (await res.json());
|
|
50
|
+
return res.ok && body.mode === 'local';
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export async function localMcpServers(agent, base = localUrl()) {
|
|
57
|
+
const res = await fetch(`${base}/api/cli/mcp`, {
|
|
58
|
+
headers: { authorization: `Bearer ${agent.key}` },
|
|
59
|
+
signal: AbortSignal.timeout(READ_MS),
|
|
60
|
+
});
|
|
61
|
+
if (!res.ok)
|
|
62
|
+
throw new Error(`the local daemon answered ${String(res.status)} for the connectors`);
|
|
63
|
+
const body = (await res.json());
|
|
64
|
+
if (typeof body.json !== 'string')
|
|
65
|
+
throw new Error('the local daemon returned an unexpected response');
|
|
66
|
+
return body.json;
|
|
67
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.43",
|
|
4
4
|
"description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -9,6 +9,12 @@ export class ConnectorNotMcp extends ConnectorVerifyError {}
|
|
|
9
9
|
export const POLICY_MESSAGE =
|
|
10
10
|
'Metro connects from its own server, so it cannot reach a URL on your machine. localhost and private addresses are not usable as connectors.';
|
|
11
11
|
|
|
12
|
+
let localAllowed = false;
|
|
13
|
+
|
|
14
|
+
export function allowLocalConnectors(on: boolean): void {
|
|
15
|
+
localAllowed = on;
|
|
16
|
+
}
|
|
17
|
+
|
|
12
18
|
const IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
|
|
13
19
|
const PRIVATE_SUFFIX = /(?:^|\.)(?:localhost|local|internal|flycast)$/;
|
|
14
20
|
|
|
@@ -22,6 +28,19 @@ function hostAllowed(host: string): boolean {
|
|
|
22
28
|
return !PRIVATE_SUFFIX.test(host);
|
|
23
29
|
}
|
|
24
30
|
|
|
31
|
+
function schemeAllowed(url: URL): boolean {
|
|
32
|
+
return url.protocol === 'https:' || (localAllowed && url.protocol === 'http:');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function refuseShape(url: URL): void {
|
|
36
|
+
if (!schemeAllowed(url)) throw refused('a connector url must start with https://');
|
|
37
|
+
if (url.username !== '' || url.password !== '')
|
|
38
|
+
throw refused('a connector url must not carry a user:password');
|
|
39
|
+
if (url.hash !== '')
|
|
40
|
+
throw refused('a connector url must not carry a #fragment');
|
|
41
|
+
if (!localAllowed && !hostAllowed(url.hostname.toLowerCase())) throw refused(POLICY_MESSAGE);
|
|
42
|
+
}
|
|
43
|
+
|
|
25
44
|
export function parseConnectorUrl(raw: unknown): URL {
|
|
26
45
|
const text = typeof raw === 'string' ? raw.trim() : '';
|
|
27
46
|
if (text === '') throw refused('a connector url is required');
|
|
@@ -31,13 +50,7 @@ export function parseConnectorUrl(raw: unknown): URL {
|
|
|
31
50
|
} catch {
|
|
32
51
|
throw refused('that is not a valid url');
|
|
33
52
|
}
|
|
34
|
-
|
|
35
|
-
throw refused('a connector url must start with https://');
|
|
36
|
-
if (url.username !== '' || url.password !== '')
|
|
37
|
-
throw refused('a connector url must not carry a user:password');
|
|
38
|
-
if (url.hash !== '')
|
|
39
|
-
throw refused('a connector url must not carry a #fragment');
|
|
40
|
-
if (!hostAllowed(url.hostname.toLowerCase())) throw refused(POLICY_MESSAGE);
|
|
53
|
+
refuseShape(url);
|
|
41
54
|
return url;
|
|
42
55
|
}
|
|
43
56
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { errMsg, log } from './log.js';
|
|
3
|
+
import { cors, sendJson, type AgentIdentity } from './api-http.js';
|
|
4
|
+
import { publicBaseOrDefault } from './attach-serve.js';
|
|
5
|
+
import { relayServersJson, type RelayServerEntry } from './connector-json.js';
|
|
6
|
+
import { agentIdForKey } from '../db/key-map.js';
|
|
7
|
+
import type { ConnectorSummary } from '../db/connectors.js';
|
|
8
|
+
|
|
9
|
+
const PREFIX = '/api/cli';
|
|
10
|
+
|
|
11
|
+
export interface LocalCliDeps {
|
|
12
|
+
agentName: (agentId: string) => string | null;
|
|
13
|
+
connectorEntries: (agentId: string) => Promise<RelayServerEntry[]>;
|
|
14
|
+
connectorSummaries: (agentId: string) => Promise<ConnectorSummary[]>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function keyOf(req: IncomingMessage): string {
|
|
18
|
+
const header = req.headers.authorization ?? '';
|
|
19
|
+
if (header.toLowerCase().startsWith('bearer ')) return header.slice(7).trim();
|
|
20
|
+
const query = new URLSearchParams((req.url ?? '').split('?')[1] ?? '');
|
|
21
|
+
return query.get('token')?.trim() ?? '';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function keyIdentity(req: IncomingMessage): AgentIdentity | null {
|
|
25
|
+
const key = keyOf(req);
|
|
26
|
+
const agentId = key === '' ? undefined : agentIdForKey(key);
|
|
27
|
+
return agentId === undefined ? null : { subject: 'agent-key', agentId };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function answer(path: string, key: string, agentId: string, deps: LocalCliDeps): Promise<unknown> {
|
|
31
|
+
const agent = deps.agentName(agentId) ?? '';
|
|
32
|
+
if (path === `${PREFIX}/session`) return { email: 'this machine', subject: 'this machine', agent };
|
|
33
|
+
if (path === `${PREFIX}/connectors`) return { agent, connectors: await deps.connectorSummaries(agentId) };
|
|
34
|
+
return { json: relayServersJson(await deps.connectorEntries(agentId), publicBaseOrDefault(), key), agent };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function handleLocalCliRequest(
|
|
38
|
+
req: IncomingMessage,
|
|
39
|
+
res: ServerResponse,
|
|
40
|
+
deps: LocalCliDeps,
|
|
41
|
+
): boolean {
|
|
42
|
+
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
43
|
+
if (path !== PREFIX && !path.startsWith(`${PREFIX}/`)) return false;
|
|
44
|
+
if (req.method === 'OPTIONS') {
|
|
45
|
+
res.writeHead(204, cors(req)).end();
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
if (![`${PREFIX}/mcp`, `${PREFIX}/session`, `${PREFIX}/connectors`].includes(path)) {
|
|
49
|
+
sendJson(req, res, 404, { error: 'not on a local daemon' });
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
if (req.method !== 'GET') {
|
|
53
|
+
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
const who = keyIdentity(req);
|
|
57
|
+
if (who === null) {
|
|
58
|
+
sendJson(req, res, 401, { error: 'unauthorized' });
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
answer(path, keyOf(req), who.agentId, deps)
|
|
62
|
+
.then((body) => {
|
|
63
|
+
sendJson(req, res, 200, body);
|
|
64
|
+
})
|
|
65
|
+
.catch((err: unknown) => {
|
|
66
|
+
log.warn({ err: errMsg(err) }, 'local cli: request failed');
|
|
67
|
+
if (!res.headersSent) sendJson(req, res, 500, { error: 'cli failed' });
|
|
68
|
+
});
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
@@ -10,10 +10,31 @@ import type { SessionApis } from './session-apis.js';
|
|
|
10
10
|
import type { ModeInfo } from './mode-api.js';
|
|
11
11
|
import type { ImportApiDeps } from './import-api.js';
|
|
12
12
|
import { fetchAgentWithCode } from './agent-import.js';
|
|
13
|
-
import {
|
|
13
|
+
import type { ConnectorApiDeps } from './connector-api.js';
|
|
14
|
+
import { allowLocalConnectors } from './connector-url.js';
|
|
15
|
+
import { keyIdentity, type LocalCliDeps } from './local-cli-api.js';
|
|
16
|
+
import type { RelayApiDeps } from './relay.js';
|
|
17
|
+
import {
|
|
18
|
+
localAddConnector,
|
|
19
|
+
localAgentConnectors,
|
|
20
|
+
localConnectorNamesByIds,
|
|
21
|
+
localConnectorSummariesByIds,
|
|
22
|
+
localCreateConnector,
|
|
23
|
+
localCreatePendingConnector,
|
|
24
|
+
localDeleteConnector,
|
|
25
|
+
localDisconnectConnector,
|
|
26
|
+
localGetConnector,
|
|
27
|
+
localListConnectors,
|
|
28
|
+
localReconnectConnector,
|
|
29
|
+
localRelayTarget,
|
|
30
|
+
localRemoveConnector,
|
|
31
|
+
localRenameConnector,
|
|
32
|
+
localVerifyConnector,
|
|
33
|
+
} from '../db/local-connectors.js';
|
|
14
34
|
import {
|
|
15
35
|
assertLocalOwner,
|
|
16
36
|
claimLocalOwner,
|
|
37
|
+
connectorIdsOfLocalAgent,
|
|
17
38
|
LOCAL_PROJECT_ID,
|
|
18
39
|
localAttachAccount,
|
|
19
40
|
localCreateAgent,
|
|
@@ -26,6 +47,7 @@ import {
|
|
|
26
47
|
localResetAgentKey,
|
|
27
48
|
} from '../db/file-admin.js';
|
|
28
49
|
import { normalizeAddress } from '../db/users.js';
|
|
50
|
+
import { listAgentFiles, readAgentFile } from '../db/file-source.js';
|
|
29
51
|
import type { Project } from '../db/projects.js';
|
|
30
52
|
import type { StationName } from '../db/schema.js';
|
|
31
53
|
|
|
@@ -124,32 +146,52 @@ const projectApi: ProjectApiDeps = {
|
|
|
124
146
|
removeMember: () => notHere('members'),
|
|
125
147
|
};
|
|
126
148
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
ids.map(async (id) => [id, (await hostedConnectorsFor(id)).map((c) => c.id)] as const),
|
|
130
|
-
);
|
|
131
|
-
return new Map(pairs);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
async function connectorsOfOwner(subject: string): Promise<HostedConnector[]> {
|
|
135
|
-
const agents = await localListAgents(subject, LOCAL_PROJECT_ID).catch(() => []);
|
|
136
|
-
const lists = await Promise.all(agents.map((a) => hostedConnectorsFor(a.id)));
|
|
137
|
-
const seen = new Map<string, HostedConnector>();
|
|
138
|
-
for (const c of lists.flat()) seen.set(c.id, c);
|
|
139
|
-
return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
149
|
+
function connectorIdsOfLocalAgents(ids: string[]): Promise<Map<string, string[]>> {
|
|
150
|
+
return Promise.resolve(new Map(ids.map((id) => [id, connectorIdsOfLocalAgent(id) ?? []] as const)));
|
|
140
151
|
}
|
|
141
152
|
|
|
142
153
|
const agentConnectorApi: AgentConnectorApiDeps = {
|
|
143
|
-
agentConnectors:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
},
|
|
147
|
-
addConnector: () => notHere('connectors'),
|
|
148
|
-
removeConnector: () => notHere('connectors'),
|
|
154
|
+
agentConnectors: localAgentConnectors,
|
|
155
|
+
addConnector: localAddConnector,
|
|
156
|
+
removeConnector: localRemoveConnector,
|
|
149
157
|
mintCode: () =>
|
|
150
158
|
Promise.reject(new ApiError('a local daemon has no pairing codes', 400)),
|
|
151
159
|
};
|
|
152
160
|
|
|
161
|
+
const connectorApi: ConnectorApiDeps = {
|
|
162
|
+
listConnectors: localListConnectors,
|
|
163
|
+
connectorSummariesByIds: (ids) => localConnectorSummariesByIds(ids),
|
|
164
|
+
connectorNamesByIds: (ids) => localConnectorNamesByIds(ids),
|
|
165
|
+
agentConnectors: localAgentConnectors,
|
|
166
|
+
fenceRuntime: () => Promise.resolve(),
|
|
167
|
+
createConnector: localCreateConnector,
|
|
168
|
+
verifyConnector: localVerifyConnector,
|
|
169
|
+
disconnectConnector: localDisconnectConnector,
|
|
170
|
+
renameConnector: localRenameConnector,
|
|
171
|
+
deleteConnector: localDeleteConnector,
|
|
172
|
+
createPendingConnector: localCreatePendingConnector,
|
|
173
|
+
reconnectConnector: localReconnectConnector,
|
|
174
|
+
getConnector: localGetConnector,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const relayApi: RelayApiDeps = {
|
|
178
|
+
target: (agentId, connectorId, force) => localRelayTarget(agentId, connectorId, force),
|
|
179
|
+
fence: () => Promise.resolve(),
|
|
180
|
+
identify: keyIdentity,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
function agentNameOf(agentId: string): string | null {
|
|
184
|
+
return listAgentFiles()
|
|
185
|
+
.map((path) => readAgentFile(path))
|
|
186
|
+
.find((file) => file.id === agentId)?.name ?? null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const localCli: LocalCliDeps = {
|
|
190
|
+
agentName: agentNameOf,
|
|
191
|
+
connectorEntries: (agentId) => localConnectorNamesByIds(connectorIdsOfLocalAgent(agentId) ?? []),
|
|
192
|
+
connectorSummaries: (agentId) => localConnectorSummariesByIds(connectorIdsOfLocalAgent(agentId) ?? []),
|
|
193
|
+
};
|
|
194
|
+
|
|
153
195
|
function importApi(deps: LocalModeDeps): ImportApiDeps {
|
|
154
196
|
const fetchAgent = deps.fetchAgent ?? fetchAgentWithCode;
|
|
155
197
|
return {
|
|
@@ -157,7 +199,6 @@ function importApi(deps: LocalModeDeps): ImportApiDeps {
|
|
|
157
199
|
assertLocalOwner(subject);
|
|
158
200
|
const agent = await fetchAgent(code, hostname());
|
|
159
201
|
const made = await localImportAgent(subject, agent);
|
|
160
|
-
forgetHostedConnectors();
|
|
161
202
|
for (const station of new Set(agent.accounts.map((a) => a.station)))
|
|
162
203
|
await deps.syncStations(station).catch((err: unknown) => {
|
|
163
204
|
log.warn(
|
|
@@ -175,11 +216,14 @@ export function localModeInfo(): ModeInfo {
|
|
|
175
216
|
}
|
|
176
217
|
|
|
177
218
|
export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
219
|
+
allowLocalConnectors(true);
|
|
178
220
|
return {
|
|
179
221
|
agentApi: agentApi(deps),
|
|
180
222
|
agentConnectorApi,
|
|
181
223
|
importApi: importApi(deps),
|
|
182
|
-
|
|
224
|
+
connectorApi,
|
|
225
|
+
relayApi,
|
|
226
|
+
localCli,
|
|
183
227
|
claudeApi: { authorize: (subject) => { assertLocalOwner(subject); } },
|
|
184
228
|
projectApi,
|
|
185
229
|
siwe: { ensureUser: claimLocalOwner },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import type { RelayTarget } from '../db/connector-relay.js';
|
|
3
|
-
import { agentIdentity, assertLease, type Fence } from './api-http.js';
|
|
3
|
+
import { agentIdentity, assertLease, type AgentIdentity, type Fence } from './api-http.js';
|
|
4
4
|
import { ApiError } from './api-error.js';
|
|
5
5
|
import { errMsg, log } from './log.js';
|
|
6
6
|
|
|
@@ -11,6 +11,7 @@ export interface RelayApiDeps {
|
|
|
11
11
|
force: boolean,
|
|
12
12
|
) => Promise<RelayTarget>;
|
|
13
13
|
fence: Fence;
|
|
14
|
+
identify?: (req: IncomingMessage) => AgentIdentity | null;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
const ID_PATH_RE = /^\/relay\/([A-Za-z0-9][A-Za-z0-9_-]{10})$/;
|
|
@@ -263,7 +264,7 @@ function dispatch(
|
|
|
263
264
|
connectorId: string,
|
|
264
265
|
deps: RelayApiDeps,
|
|
265
266
|
): void {
|
|
266
|
-
const who = agentIdentity(req);
|
|
267
|
+
const who = (deps.identify ?? agentIdentity)(req);
|
|
267
268
|
if (who === null) {
|
|
268
269
|
answer(res, 401, { error: 'unauthorized' });
|
|
269
270
|
return;
|
|
@@ -18,10 +18,7 @@ import {
|
|
|
18
18
|
} from './agent-connector-api.js';
|
|
19
19
|
import type { SiweRouteDeps } from './siwe-routes.js';
|
|
20
20
|
import { handleImportRequest, type ImportApiDeps } from './import-api.js';
|
|
21
|
-
import {
|
|
22
|
-
handleLocalConnectorsRequest,
|
|
23
|
-
type LocalConnectorsDeps,
|
|
24
|
-
} from './local-connectors-api.js';
|
|
21
|
+
import { handleLocalCliRequest, type LocalCliDeps } from './local-cli-api.js';
|
|
25
22
|
import { handleClaudeRequest, type ClaudeApiDeps } from './claude-api.js';
|
|
26
23
|
import type { ModeInfo } from './mode-api.js';
|
|
27
24
|
|
|
@@ -29,7 +26,7 @@ export interface SessionApis {
|
|
|
29
26
|
agentApi?: AgentApiDeps;
|
|
30
27
|
agentConnectorApi?: AgentConnectorApiDeps;
|
|
31
28
|
importApi?: ImportApiDeps;
|
|
32
|
-
|
|
29
|
+
localCli?: LocalCliDeps;
|
|
33
30
|
claudeApi?: ClaudeApiDeps;
|
|
34
31
|
connectorApi?: ConnectorApiDeps;
|
|
35
32
|
projectApi?: ProjectApiDeps;
|
|
@@ -45,8 +42,9 @@ export function handleSessionApis(
|
|
|
45
42
|
apis: SessionApis,
|
|
46
43
|
): boolean {
|
|
47
44
|
const { agentApi, agentConnectorApi, connectorApi, projectApi, runApi, importApi } = apis;
|
|
48
|
-
const {
|
|
45
|
+
const { localCli, claudeApi } = apis;
|
|
49
46
|
const routes: (() => boolean)[] = [() => handleSessionApiRequest(req, res)];
|
|
47
|
+
if (localCli) routes.push(() => handleLocalCliRequest(req, res, localCli));
|
|
50
48
|
if (runApi) routes.push(() => handleRunApiRequest(req, res, runApi));
|
|
51
49
|
if (projectApi)
|
|
52
50
|
routes.push(() => handleProjectApiRequest(req, res, projectApi));
|
|
@@ -56,8 +54,6 @@ export function handleSessionApis(
|
|
|
56
54
|
() => handleConnectorApiRequest(req, res, connectorApi),
|
|
57
55
|
);
|
|
58
56
|
if (importApi) routes.push(() => handleImportRequest(req, res, importApi));
|
|
59
|
-
if (localConnectors)
|
|
60
|
-
routes.push(() => handleLocalConnectorsRequest(req, res, localConnectors));
|
|
61
57
|
if (claudeApi) routes.push(() => handleClaudeRequest(req, res, claudeApi));
|
|
62
58
|
if (agentConnectorApi)
|
|
63
59
|
routes.push(() => handleAgentConnectorRequest(req, res, agentConnectorApi));
|
|
@@ -58,7 +58,15 @@ export interface DeletedConnector {
|
|
|
58
58
|
|
|
59
59
|
type ConnectorRow = typeof connectors.$inferSelect;
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
export interface ConnectorLike {
|
|
62
|
+
id: string;
|
|
63
|
+
name: string;
|
|
64
|
+
url: string;
|
|
65
|
+
transport: ConnectorTransport;
|
|
66
|
+
config: unknown;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function connectorFromRow(row: ConnectorLike): Connector {
|
|
62
70
|
const config = readConfig(row.config);
|
|
63
71
|
const auth = config.auth;
|
|
64
72
|
return {
|
|
@@ -76,6 +84,8 @@ function toConnector(row: ConnectorRow): Connector {
|
|
|
76
84
|
};
|
|
77
85
|
}
|
|
78
86
|
|
|
87
|
+
const toConnector = (row: ConnectorRow): Connector => connectorFromRow(row);
|
|
88
|
+
|
|
79
89
|
const missing = (): ConnectorError =>
|
|
80
90
|
new ConnectorError('no such connector', 404);
|
|
81
91
|
|
|
@@ -193,7 +203,7 @@ async function insertConnector(
|
|
|
193
203
|
return toConnector(row);
|
|
194
204
|
}
|
|
195
205
|
|
|
196
|
-
const UNVERIFIED = {
|
|
206
|
+
export const UNVERIFIED = {
|
|
197
207
|
at: '',
|
|
198
208
|
server: '',
|
|
199
209
|
version: '',
|
|
@@ -153,7 +153,7 @@ export async function localCreateAgent(
|
|
|
153
153
|
ensureSecureDir(folder);
|
|
154
154
|
save({
|
|
155
155
|
path: join(folder, AGENT_FILE),
|
|
156
|
-
file: { version: 1, id, name, key, owner: localOwner(dir), stations: [] },
|
|
156
|
+
file: { version: 1, id, name, key, owner: localOwner(dir), stations: [], connectors: [] },
|
|
157
157
|
});
|
|
158
158
|
registerKey(key, id);
|
|
159
159
|
return Promise.resolve({ id, name, key });
|
|
@@ -247,6 +247,7 @@ function fileFor(agent: LoadedAgent, owner: string | null, path: string): AgentF
|
|
|
247
247
|
key: agent.key,
|
|
248
248
|
owner,
|
|
249
249
|
stations: agent.accounts.map((a) => ({ ...a, allowlist: a.allowlist ?? ['*'] })),
|
|
250
|
+
connectors: [],
|
|
250
251
|
}),
|
|
251
252
|
path,
|
|
252
253
|
);
|
|
@@ -257,6 +258,40 @@ function fileFor(agent: LoadedAgent, owner: string | null, path: string): AgentF
|
|
|
257
258
|
}
|
|
258
259
|
}
|
|
259
260
|
|
|
261
|
+
export function connectorIdsOfLocalAgent(agentId: string, dir = agentsDir()): string[] | null {
|
|
262
|
+
const found = storedAgents(dir).find((s) => s.file.id === agentId);
|
|
263
|
+
return found === undefined ? null : found.file.connectors;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function localAgentConnectorIds(subject: string, agentId: string, dir = agentsDir()): string[] {
|
|
267
|
+
return ownedOrThrow(subject, agentId, dir).file.connectors;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function localSetAgentConnectors(
|
|
271
|
+
subject: string,
|
|
272
|
+
agentId: string,
|
|
273
|
+
ids: string[],
|
|
274
|
+
dir = agentsDir(),
|
|
275
|
+
): void {
|
|
276
|
+
const stored = ownedOrThrow(subject, agentId, dir);
|
|
277
|
+
stored.file.connectors = ids;
|
|
278
|
+
save(stored);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function localAgentsWith(connectorId: string, dir = agentsDir()): { id: string; name: string }[] {
|
|
282
|
+
return storedAgents(dir)
|
|
283
|
+
.filter((s) => s.file.connectors.includes(connectorId))
|
|
284
|
+
.map((s) => ({ id: s.file.id, name: s.file.name }));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function localDropConnectorEverywhere(connectorId: string, dir = agentsDir()): void {
|
|
288
|
+
for (const stored of storedAgents(dir)) {
|
|
289
|
+
if (!stored.file.connectors.includes(connectorId)) continue;
|
|
290
|
+
stored.file.connectors = stored.file.connectors.filter((id) => id !== connectorId);
|
|
291
|
+
save(stored);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
260
295
|
function removeIfEmpty(folder: string): void {
|
|
261
296
|
try {
|
|
262
297
|
rmdirSync(folder);
|
|
@@ -20,6 +20,7 @@ export interface AgentFile {
|
|
|
20
20
|
key: string | null;
|
|
21
21
|
owner: string | null;
|
|
22
22
|
stations: LoadedAccount[];
|
|
23
|
+
connectors: string[];
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export function agentsDir(): string {
|
|
@@ -71,6 +72,15 @@ function optionalMatch(
|
|
|
71
72
|
return value;
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
function connectorsOf(raw: unknown, path: string): string[] {
|
|
76
|
+
if (raw === undefined || raw === null) return [];
|
|
77
|
+
const list: unknown[] = Array.isArray(raw) ? raw.map((item: unknown) => item) : [];
|
|
78
|
+
const ids = list.filter((id): id is string => typeof id === 'string' && ID_RE.test(id));
|
|
79
|
+
if (!Array.isArray(raw) || ids.length !== list.length)
|
|
80
|
+
fail(path, 'connectors is not a list of 11-character ids');
|
|
81
|
+
return ids;
|
|
82
|
+
}
|
|
83
|
+
|
|
74
84
|
export function parseAgentFile(raw: string, path: string): AgentFile {
|
|
75
85
|
let parsed: unknown;
|
|
76
86
|
try {
|
|
@@ -100,6 +110,7 @@ export function parseAgentFile(raw: string, path: string): AgentFile {
|
|
|
100
110
|
key,
|
|
101
111
|
owner,
|
|
102
112
|
stations: stations.map((s, i) => stationOf(s, path, i)),
|
|
113
|
+
connectors: connectorsOf(parsed.connectors, path),
|
|
103
114
|
};
|
|
104
115
|
}
|
|
105
116
|
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readJson, writeSecure } from '../daemon/secure-fs.js';
|
|
3
|
+
import { errMsg, log } from '../daemon/log.js';
|
|
4
|
+
import {
|
|
5
|
+
connectorUrlText,
|
|
6
|
+
ConnectorVerifyError,
|
|
7
|
+
parseConnectorUrl,
|
|
8
|
+
verifyRemoteMcp,
|
|
9
|
+
type ConnectorAuth,
|
|
10
|
+
type OAuthAuth,
|
|
11
|
+
} from '../daemon/connector-verify.js';
|
|
12
|
+
import { oauthExpired, refreshOAuth } from '../daemon/connector-oauth.js';
|
|
13
|
+
import { advertisesOAuth } from '../daemon/oauth-discovery.js';
|
|
14
|
+
import {
|
|
15
|
+
ConnectorError,
|
|
16
|
+
connectorAuth,
|
|
17
|
+
connectorName,
|
|
18
|
+
readConfig,
|
|
19
|
+
signInState,
|
|
20
|
+
stamp,
|
|
21
|
+
type ConnectorConfig,
|
|
22
|
+
} from './connector-config.js';
|
|
23
|
+
import {
|
|
24
|
+
bearerHeaders,
|
|
25
|
+
fixedTarget,
|
|
26
|
+
unrefreshedTarget,
|
|
27
|
+
type RelayTarget,
|
|
28
|
+
} from './connector-relay.js';
|
|
29
|
+
import {
|
|
30
|
+
connectorFromRow,
|
|
31
|
+
UNVERIFIED,
|
|
32
|
+
type Connector,
|
|
33
|
+
type ConnectorCheck,
|
|
34
|
+
type ConnectorInput,
|
|
35
|
+
type ConnectorSummary,
|
|
36
|
+
type DeletedConnector,
|
|
37
|
+
} from './connectors.js';
|
|
38
|
+
import type { AgentConnectors } from './agent-connectors.js';
|
|
39
|
+
import { agentsDir } from './file-source.js';
|
|
40
|
+
import {
|
|
41
|
+
assertLocalOwner,
|
|
42
|
+
connectorIdsOfLocalAgent,
|
|
43
|
+
localAgentConnectorIds,
|
|
44
|
+
localAgentsWith,
|
|
45
|
+
localDropConnectorEverywhere,
|
|
46
|
+
localOwnedAgentOrThrow,
|
|
47
|
+
localSetAgentConnectors,
|
|
48
|
+
LOCAL_PROJECT_ID,
|
|
49
|
+
} from './file-admin.js';
|
|
50
|
+
import { newId } from './ids.js';
|
|
51
|
+
|
|
52
|
+
export interface LocalConnectorRow {
|
|
53
|
+
id: string;
|
|
54
|
+
name: string;
|
|
55
|
+
url: string;
|
|
56
|
+
transport: 'http';
|
|
57
|
+
config: ConnectorConfig;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const FILE = 'connectors.json';
|
|
61
|
+
const filePath = (dir: string): string => join(dir, FILE);
|
|
62
|
+
|
|
63
|
+
const isRow = (v: unknown): v is { id: string; name: string; url: string; config?: unknown } =>
|
|
64
|
+
typeof v === 'object' &&
|
|
65
|
+
v !== null &&
|
|
66
|
+
typeof (v as { id?: unknown }).id === 'string' &&
|
|
67
|
+
typeof (v as { name?: unknown }).name === 'string' &&
|
|
68
|
+
typeof (v as { url?: unknown }).url === 'string';
|
|
69
|
+
|
|
70
|
+
const asList = (value: unknown): unknown[] =>
|
|
71
|
+
Array.isArray(value) ? value.map((item: unknown) => item) : [];
|
|
72
|
+
|
|
73
|
+
export function readLocalConnectors(dir = agentsDir()): LocalConnectorRow[] {
|
|
74
|
+
const raw = readJson<{ connectors?: unknown }>(filePath(dir), {}, {
|
|
75
|
+
warn: 'connectors.json: malformed, ignoring',
|
|
76
|
+
});
|
|
77
|
+
return asList(raw.connectors).filter(isRow).map((r) => ({
|
|
78
|
+
id: r.id,
|
|
79
|
+
name: r.name,
|
|
80
|
+
url: r.url,
|
|
81
|
+
transport: 'http' as const,
|
|
82
|
+
config: readConfig(r.config),
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function writeRows(dir: string, rows: LocalConnectorRow[]): void {
|
|
87
|
+
writeSecure(filePath(dir), `${JSON.stringify({ version: 1, connectors: rows }, null, 2)}\n`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const missing = (): ConnectorError => new ConnectorError('no such connector', 404);
|
|
91
|
+
|
|
92
|
+
function ownedRows(subject: string, project: string, dir: string): LocalConnectorRow[] {
|
|
93
|
+
assertLocalOwner(subject, dir);
|
|
94
|
+
if (project !== LOCAL_PROJECT_ID) throw new ConnectorError('no such project', 404);
|
|
95
|
+
return readLocalConnectors(dir);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function rowOrThrow(subject: string, id: string, dir: string): LocalConnectorRow {
|
|
99
|
+
assertLocalOwner(subject, dir);
|
|
100
|
+
const row = readLocalConnectors(dir).find((r) => r.id === id);
|
|
101
|
+
if (row === undefined) throw missing();
|
|
102
|
+
return row;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function replace(dir: string, row: LocalConnectorRow): Connector {
|
|
106
|
+
writeRows(dir, readLocalConnectors(dir).map((r) => (r.id === row.id ? row : r)));
|
|
107
|
+
return connectorFromRow(row);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function oauthCapable(url: URL, auth: ConnectorAuth): Promise<boolean> {
|
|
111
|
+
if (auth.kind === 'oauth') return true;
|
|
112
|
+
if (auth.kind === 'header') return false;
|
|
113
|
+
return advertisesOAuth(url);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function localListConnectors(
|
|
117
|
+
subject: string,
|
|
118
|
+
project: string,
|
|
119
|
+
dir = agentsDir(),
|
|
120
|
+
): Promise<Connector[]> {
|
|
121
|
+
return Promise.resolve(ownedRows(subject, project, dir).map(connectorFromRow));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function localGetConnector(subject: string, id: string, dir = agentsDir()): Promise<Connector> {
|
|
125
|
+
return Promise.resolve(connectorFromRow(rowOrThrow(subject, id, dir)));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function insert(dir: string, name: string, url: URL, config: ConnectorConfig): Connector {
|
|
129
|
+
const row: LocalConnectorRow = { id: newId(), name, url: connectorUrlText(url), transport: 'http', config };
|
|
130
|
+
writeRows(dir, [...readLocalConnectors(dir), row]);
|
|
131
|
+
return connectorFromRow(row);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function localCreateConnector(
|
|
135
|
+
subject: string,
|
|
136
|
+
project: string,
|
|
137
|
+
input: ConnectorInput,
|
|
138
|
+
dir = agentsDir(),
|
|
139
|
+
): Promise<Connector> {
|
|
140
|
+
ownedRows(subject, project, dir);
|
|
141
|
+
const name = connectorName(input.name);
|
|
142
|
+
const url = parseConnectorUrl(input.url);
|
|
143
|
+
const auth = connectorAuth(input.header, input.value);
|
|
144
|
+
const verified = stamp(await verifyRemoteMcp(url, auth));
|
|
145
|
+
return insert(dir, name, url, {
|
|
146
|
+
auth,
|
|
147
|
+
createdAt: new Date().toISOString(),
|
|
148
|
+
verified,
|
|
149
|
+
oauth: await oauthCapable(url, auth),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function localCreatePendingConnector(
|
|
154
|
+
subject: string,
|
|
155
|
+
project: string,
|
|
156
|
+
input: { name: unknown; url: unknown },
|
|
157
|
+
dir = agentsDir(),
|
|
158
|
+
): Promise<Connector> {
|
|
159
|
+
ownedRows(subject, project, dir);
|
|
160
|
+
const name = connectorName(input.name);
|
|
161
|
+
const url = parseConnectorUrl(input.url);
|
|
162
|
+
return Promise.resolve(
|
|
163
|
+
insert(dir, name, url, { auth: { kind: 'none' }, createdAt: new Date().toISOString(), verified: UNVERIFIED, oauth: true }),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function localReconnectConnector(
|
|
168
|
+
subject: string,
|
|
169
|
+
id: string,
|
|
170
|
+
auth: OAuthAuth,
|
|
171
|
+
dir = agentsDir(),
|
|
172
|
+
): Promise<Connector> {
|
|
173
|
+
const row = rowOrThrow(subject, id, dir);
|
|
174
|
+
const verified = stamp(await verifyRemoteMcp(parseConnectorUrl(row.url), auth));
|
|
175
|
+
return replace(dir, { ...row, config: { ...row.config, auth, verified, oauth: true } });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function localDisconnectConnector(subject: string, id: string, dir = agentsDir()): Promise<Connector> {
|
|
179
|
+
const row = rowOrThrow(subject, id, dir);
|
|
180
|
+
if (row.config.auth.kind !== 'oauth') throw new ConnectorError('that connector is not signed in', 400);
|
|
181
|
+
return Promise.resolve(replace(dir, { ...row, config: { ...row.config, auth: { kind: 'none' }, oauth: true } }));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function freshAuth(auth: ConnectorAuth, resource: string): Promise<ConnectorAuth> {
|
|
185
|
+
if (auth.kind !== 'oauth' || !oauthExpired(auth)) return auth;
|
|
186
|
+
return refreshOAuth(auth, resource);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function localVerifyConnector(subject: string, id: string, dir = agentsDir()): Promise<ConnectorCheck> {
|
|
190
|
+
const row = rowOrThrow(subject, id, dir);
|
|
191
|
+
try {
|
|
192
|
+
const url = parseConnectorUrl(row.url);
|
|
193
|
+
const auth = await freshAuth(row.config.auth, url.toString());
|
|
194
|
+
const verified = stamp(await verifyRemoteMcp(url, auth));
|
|
195
|
+
const oauth = row.config.oauth || (await oauthCapable(url, auth));
|
|
196
|
+
replace(dir, { ...row, config: { ...row.config, auth, verified, oauth } });
|
|
197
|
+
return { id: row.id, name: row.name, ok: true, verified };
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (!(err instanceof ConnectorVerifyError)) throw err;
|
|
200
|
+
return { id: row.id, name: row.name, ok: false, reason: err.message };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const nameClash = (name: string, agent: string): ConnectorError =>
|
|
205
|
+
new ConnectorError(`the agent '${agent}' already has a connector named '${name}'`, 409);
|
|
206
|
+
|
|
207
|
+
function clashFor(name: string, exceptId: string, agentIds: string[], dir: string): string | null {
|
|
208
|
+
const rows = readLocalConnectors(dir);
|
|
209
|
+
for (const agentId of agentIds) {
|
|
210
|
+
const held = connectorIdsOfLocalAgent(agentId, dir) ?? [];
|
|
211
|
+
const other = rows.find((r) => r.id !== exceptId && held.includes(r.id) && r.name === name);
|
|
212
|
+
if (other !== undefined) return localAgentsWith(other.id, dir).find((a) => a.id === agentId)?.name ?? agentId;
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function localRenameConnector(subject: string, id: string, raw: string, dir = agentsDir()): Promise<Connector> {
|
|
218
|
+
const name = connectorName(raw);
|
|
219
|
+
const row = rowOrThrow(subject, id, dir);
|
|
220
|
+
const clash = clashFor(name, id, localAgentsWith(id, dir).map((a) => a.id), dir);
|
|
221
|
+
if (clash !== null) throw nameClash(name, clash);
|
|
222
|
+
return Promise.resolve(replace(dir, { ...row, name }));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function localDeleteConnector(subject: string, id: string, dir = agentsDir()): Promise<DeletedConnector> {
|
|
226
|
+
const row = rowOrThrow(subject, id, dir);
|
|
227
|
+
writeRows(dir, readLocalConnectors(dir).filter((r) => r.id !== id));
|
|
228
|
+
localDropConnectorEverywhere(id, dir);
|
|
229
|
+
return Promise.resolve({ id: row.id, name: row.name });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const byIds = (ids: string[], dir: string): LocalConnectorRow[] =>
|
|
233
|
+
readLocalConnectors(dir).filter((r) => ids.includes(r.id));
|
|
234
|
+
|
|
235
|
+
export async function localConnectorSummariesByIds(ids: string[], dir = agentsDir()): Promise<ConnectorSummary[]> {
|
|
236
|
+
return Promise.resolve(
|
|
237
|
+
byIds(ids, dir).map((r) => ({ id: r.id, name: r.name, url: r.url, transport: r.transport, signIn: signInState(r.config) })),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function localConnectorNamesByIds(ids: string[], dir = agentsDir()): Promise<{ id: string; name: string }[]> {
|
|
242
|
+
return Promise.resolve(byIds(ids, dir).map((r) => ({ id: r.id, name: r.name })));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export async function localAgentConnectors(subject: string, agentId: string, dir = agentsDir()): Promise<AgentConnectors> {
|
|
246
|
+
const { agent } = await localOwnedAgentOrThrow(subject, agentId, dir);
|
|
247
|
+
return { ...agent, connectorIds: localAgentConnectorIds(subject, agentId, dir) };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function localAddConnector(subject: string, agentId: string, connectorId: string, dir = agentsDir()): Promise<AgentConnectors> {
|
|
251
|
+
const { agent } = await localOwnedAgentOrThrow(subject, agentId, dir);
|
|
252
|
+
const row = readLocalConnectors(dir).find((r) => r.id === connectorId);
|
|
253
|
+
if (row === undefined) throw missing();
|
|
254
|
+
const held = localAgentConnectorIds(subject, agentId, dir);
|
|
255
|
+
if (!held.includes(connectorId)) {
|
|
256
|
+
const clash = clashFor(row.name, connectorId, [agentId], dir);
|
|
257
|
+
if (clash !== null) throw nameClash(row.name, clash);
|
|
258
|
+
localSetAgentConnectors(subject, agentId, [...held, connectorId], dir);
|
|
259
|
+
}
|
|
260
|
+
return { ...agent, connectorIds: localAgentConnectorIds(subject, agentId, dir) };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function localRemoveConnector(subject: string, agentId: string, connectorId: string, dir = agentsDir()): Promise<AgentConnectors> {
|
|
264
|
+
const { agent } = await localOwnedAgentOrThrow(subject, agentId, dir);
|
|
265
|
+
const held = localAgentConnectorIds(subject, agentId, dir);
|
|
266
|
+
localSetAgentConnectors(subject, agentId, held.filter((id) => id !== connectorId), dir);
|
|
267
|
+
return { ...agent, connectorIds: localAgentConnectorIds(subject, agentId, dir) };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const inflight = new Map<string, Promise<OAuthAuth>>();
|
|
271
|
+
|
|
272
|
+
function refreshOnce(row: LocalConnectorRow, auth: OAuthAuth, dir: string): Promise<OAuthAuth> {
|
|
273
|
+
const running = inflight.get(row.id);
|
|
274
|
+
if (running !== undefined) return running;
|
|
275
|
+
const job = refreshOAuth(auth, parseConnectorUrl(row.url).toString())
|
|
276
|
+
.then((fresh) => {
|
|
277
|
+
replace(dir, { ...row, config: { ...row.config, auth: fresh } });
|
|
278
|
+
return fresh;
|
|
279
|
+
})
|
|
280
|
+
.finally(() => {
|
|
281
|
+
inflight.delete(row.id);
|
|
282
|
+
});
|
|
283
|
+
inflight.set(row.id, job);
|
|
284
|
+
return job;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function oauthTarget(row: LocalConnectorRow, auth: OAuthAuth, force: boolean, dir: string): Promise<RelayTarget> {
|
|
288
|
+
if (!force && !oauthExpired(auth)) return { kind: 'ok', url: row.url, headers: bearerHeaders(auth.accessToken) };
|
|
289
|
+
try {
|
|
290
|
+
const fresh = await refreshOnce(row, auth, dir);
|
|
291
|
+
return { kind: 'ok', url: row.url, headers: bearerHeaders(fresh.accessToken) };
|
|
292
|
+
} catch (err) {
|
|
293
|
+
log.warn({ id: row.id, err: errMsg(err) }, 'local relay: token refresh failed');
|
|
294
|
+
return unrefreshedTarget(row.url, auth, force);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export async function localRelayTarget(
|
|
299
|
+
agentId: string,
|
|
300
|
+
connectorId: string,
|
|
301
|
+
force: boolean,
|
|
302
|
+
dir = agentsDir(),
|
|
303
|
+
): Promise<RelayTarget> {
|
|
304
|
+
const held = connectorIdsOfLocalAgent(agentId, dir);
|
|
305
|
+
if (!held?.includes(connectorId)) return { kind: 'missing' };
|
|
306
|
+
const row = readLocalConnectors(dir).find((r) => r.id === connectorId);
|
|
307
|
+
if (row === undefined) return { kind: 'missing' };
|
|
308
|
+
parseConnectorUrl(row.url);
|
|
309
|
+
const auth = row.config.auth;
|
|
310
|
+
if (auth.kind === 'oauth') return oauthTarget(row, auth, force, dir);
|
|
311
|
+
return fixedTarget(row.url, auth, force);
|
|
312
|
+
}
|
package/runtime/runtime.json
CHANGED
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { errMsg, log } from './log.js';
|
|
4
|
-
import { configDir } from './paths.js';
|
|
5
|
-
import { metroBaseUrl } from './agent-import.js';
|
|
6
|
-
|
|
7
|
-
const FETCH_TIMEOUT_MS = 15_000;
|
|
8
|
-
const CACHE_MS = 60_000;
|
|
9
|
-
|
|
10
|
-
export interface HostedConnector {
|
|
11
|
-
id: string;
|
|
12
|
-
name: string;
|
|
13
|
-
url: string;
|
|
14
|
-
transport: string;
|
|
15
|
-
signIn: 'connected' | 'disconnected' | null;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface HostedCredential {
|
|
19
|
-
token: string;
|
|
20
|
-
url: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function readJsonFile(path: string): Record<string, unknown> | null {
|
|
24
|
-
try {
|
|
25
|
-
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
26
|
-
return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null;
|
|
27
|
-
} catch {
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function agentOfToken(token: string): string | null {
|
|
33
|
-
const body = token.split('.')[1];
|
|
34
|
-
if (body === undefined) return null;
|
|
35
|
-
try {
|
|
36
|
-
const claims = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as { agent?: unknown };
|
|
37
|
-
return typeof claims.agent === 'string' ? claims.agent : null;
|
|
38
|
-
} catch {
|
|
39
|
-
return null;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function credential(file: Record<string, unknown> | null): HostedCredential | null {
|
|
44
|
-
if (file === null || typeof file.token !== 'string' || file.token === '') return null;
|
|
45
|
-
return { token: file.token, url: typeof file.url === 'string' && file.url !== '' ? file.url : metroBaseUrl() };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function hostedCredentialsFor(agentId: string, dir = configDir()): HostedCredential[] {
|
|
49
|
-
const out: HostedCredential[] = [];
|
|
50
|
-
const login = credential(readJsonFile(join(dir, 'credentials.json')));
|
|
51
|
-
if (login !== null && agentOfToken(login.token) === agentId) out.push(login);
|
|
52
|
-
const runtime = join(dir, `runtime-${agentId}.json`);
|
|
53
|
-
if (existsSync(runtime)) {
|
|
54
|
-
const cred = credential(readJsonFile(runtime));
|
|
55
|
-
if (cred !== null) out.push(cred);
|
|
56
|
-
}
|
|
57
|
-
return out;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const isConnector = (v: unknown): v is HostedConnector =>
|
|
61
|
-
typeof v === 'object' &&
|
|
62
|
-
v !== null &&
|
|
63
|
-
typeof (v as { id?: unknown }).id === 'string' &&
|
|
64
|
-
typeof (v as { name?: unknown }).name === 'string';
|
|
65
|
-
|
|
66
|
-
class Refused extends Error {}
|
|
67
|
-
|
|
68
|
-
async function fetchHosted(cred: HostedCredential): Promise<HostedConnector[]> {
|
|
69
|
-
const res = await fetch(`${cred.url}/api/cli/connectors`, {
|
|
70
|
-
headers: { authorization: `Bearer ${cred.token}` },
|
|
71
|
-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
72
|
-
redirect: 'manual',
|
|
73
|
-
});
|
|
74
|
-
if (res.status === 401 || res.status === 403 || res.status === 409) {
|
|
75
|
-
await res.arrayBuffer().catch(() => undefined);
|
|
76
|
-
throw new Refused(`metro no longer accepts this credential (${String(res.status)})`);
|
|
77
|
-
}
|
|
78
|
-
if (!res.ok) throw new Error(`metro answered ${String(res.status)} for the connectors`);
|
|
79
|
-
const body = (await res.json()) as { connectors?: unknown };
|
|
80
|
-
return Array.isArray(body.connectors) ? body.connectors.filter(isConnector) : [];
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async function firstAccepted(agentId: string, creds: HostedCredential[]): Promise<HostedConnector[] | null> {
|
|
84
|
-
for (const cred of creds) {
|
|
85
|
-
try {
|
|
86
|
-
return await fetchHosted(cred);
|
|
87
|
-
} catch (err) {
|
|
88
|
-
if (!(err instanceof Refused)) throw err;
|
|
89
|
-
log.warn({ agent: agentId, reason: errMsg(err) }, 'hosted connectors: credential refused, trying the next');
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const cache = new Map<string, { at: number; list: HostedConnector[] }>();
|
|
96
|
-
|
|
97
|
-
export async function hostedConnectorsFor(agentId: string, dir = configDir()): Promise<HostedConnector[]> {
|
|
98
|
-
const hit = cache.get(agentId);
|
|
99
|
-
if (hit !== undefined && Date.now() - hit.at < CACHE_MS) return hit.list;
|
|
100
|
-
const creds = hostedCredentialsFor(agentId, dir);
|
|
101
|
-
if (creds.length === 0) return [];
|
|
102
|
-
try {
|
|
103
|
-
const list = await firstAccepted(agentId, creds);
|
|
104
|
-
if (list === null) {
|
|
105
|
-
log.warn({ agent: agentId }, 'hosted connectors: every stored credential is stale; metro login again');
|
|
106
|
-
return [];
|
|
107
|
-
}
|
|
108
|
-
cache.set(agentId, { at: Date.now(), list });
|
|
109
|
-
return list;
|
|
110
|
-
} catch (err) {
|
|
111
|
-
log.warn({ agent: agentId, err: errMsg(err) }, 'hosted connectors: unavailable, listing none');
|
|
112
|
-
return [];
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function forgetHostedConnectors(): void {
|
|
117
|
-
cache.clear();
|
|
118
|
-
}
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
-
import { errMsg, log } from './log.js';
|
|
3
|
-
import { apiSession, cors, sendJson } from './api-http.js';
|
|
4
|
-
import type { HostedConnector } from './hosted-connectors.js';
|
|
5
|
-
|
|
6
|
-
const PATH = '/api/connectors';
|
|
7
|
-
|
|
8
|
-
export interface LocalConnectorsDeps {
|
|
9
|
-
listConnectors: (subject: string) => Promise<HostedConnector[]>;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const payload = (c: HostedConnector): Record<string, unknown> => ({
|
|
13
|
-
id: c.id,
|
|
14
|
-
name: c.name,
|
|
15
|
-
url: c.url,
|
|
16
|
-
transport: c.transport,
|
|
17
|
-
auth: c.signIn === null ? 'none' : 'oauth',
|
|
18
|
-
header: null,
|
|
19
|
-
signIn: c.signIn,
|
|
20
|
-
verified: null,
|
|
21
|
-
managed: 'metro.box',
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
export function handleLocalConnectorsRequest(
|
|
25
|
-
req: IncomingMessage,
|
|
26
|
-
res: ServerResponse,
|
|
27
|
-
deps: LocalConnectorsDeps,
|
|
28
|
-
): boolean {
|
|
29
|
-
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
30
|
-
if (path !== PATH && !path.startsWith(`${PATH}/`)) return false;
|
|
31
|
-
if (req.method === 'OPTIONS') {
|
|
32
|
-
res.writeHead(204, cors(req)).end();
|
|
33
|
-
return true;
|
|
34
|
-
}
|
|
35
|
-
if (path !== PATH || req.method !== 'GET') {
|
|
36
|
-
sendJson(req, res, path === PATH ? 405 : 404, {
|
|
37
|
-
error: 'connectors are managed on metro.box, not on a local daemon',
|
|
38
|
-
});
|
|
39
|
-
return true;
|
|
40
|
-
}
|
|
41
|
-
const session = apiSession(req);
|
|
42
|
-
if (!session) {
|
|
43
|
-
sendJson(req, res, 401, { error: 'unauthorized' });
|
|
44
|
-
return true;
|
|
45
|
-
}
|
|
46
|
-
deps
|
|
47
|
-
.listConnectors(session.subject)
|
|
48
|
-
.then((list) => {
|
|
49
|
-
sendJson(req, res, 200, { connectors: list.map(payload) });
|
|
50
|
-
})
|
|
51
|
-
.catch((err: unknown) => {
|
|
52
|
-
log.warn({ err: errMsg(err) }, 'local connectors: request failed');
|
|
53
|
-
if (!res.headersSent) sendJson(req, res, 500, { error: 'connectors failed' });
|
|
54
|
-
});
|
|
55
|
-
return true;
|
|
56
|
-
}
|