@stage-labs/metro 0.1.0-beta.30 → 0.1.0-beta.32
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 +12 -12
- package/dist/bedrock.js +5 -3
- package/dist/cli.js +23 -11
- package/dist/runtime.js +4 -1
- package/dist/serve.js +57 -0
- package/dist/store.js +26 -3
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/package.json +1 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/account-api.ts +5 -5
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-api.ts +24 -28
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-connector-api.ts +150 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-pair.ts +20 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +34 -10
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-serve.ts +5 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-session.ts +2 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +119 -72
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair-api.ts +31 -20
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +17 -43
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth-routes.ts +8 -8
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth.ts +3 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +12 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity.ts +11 -4
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +158 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-secret.ts +21 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/mode-api.ts +31 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/oauth-pending.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/paths.ts +10 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/project-api.ts +17 -17
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/relay.ts +13 -12
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-api.ts +15 -6
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-api.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +12 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-config.ts +27 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +21 -21
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-auth.ts +82 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-nonces.ts +43 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-routes.ts +89 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/supervisor.ts +2 -5
- package/runtime/node_modules/@metro-labs/mcp/src/db/agent-admin.ts +15 -16
- package/runtime/node_modules/@metro-labs/mcp/src/db/agent-connectors.ts +143 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/connector-relay.ts +8 -8
- package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +32 -32
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +250 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-source.ts +152 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/ids.ts +2 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/materialize.ts +16 -7
- package/runtime/node_modules/@metro-labs/mcp/src/db/projects.ts +49 -40
- package/runtime/node_modules/@metro-labs/mcp/src/db/runtime-admin.ts +13 -20
- package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +8 -21
- package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +32 -17
- package/runtime/node_modules/@metro-labs/mcp/src/mcp/request-identity.ts +4 -4
- package/runtime/node_modules/@metro-labs/mcp/src/mcp/session-route.ts +1 -1
- package/runtime/runtime.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair.ts +0 -20
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/collection-api.ts +0 -202
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/google-auth.ts +0 -164
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/google-oauth.ts +0 -208
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-pair.ts +0 -18
- package/runtime/node_modules/@metro-labs/mcp/src/db/connector-collections.ts +0 -218
package/dist/api.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { metroUrl, readToken } from './store.js';
|
|
1
|
+
import { metroUrl, readRunTokens, readToken } from './store.js';
|
|
2
2
|
export class NotSignedIn extends Error {
|
|
3
3
|
}
|
|
4
4
|
const TIMEOUT_MS = 20_000;
|
|
@@ -18,11 +18,15 @@ export function carriesSecretsSafely(base) {
|
|
|
18
18
|
function tokenOrThrow() {
|
|
19
19
|
if (!carriesSecretsSafely(metroUrl()))
|
|
20
20
|
throw new Error(`refusing to send your session to ${metroUrl()} in the clear — use https, or a loopback address`);
|
|
21
|
-
const token = readToken();
|
|
21
|
+
const token = readToken() ?? soleRunToken();
|
|
22
22
|
if (token === null)
|
|
23
|
-
throw new NotSignedIn('not signed in — run `metro login`
|
|
23
|
+
throw new NotSignedIn('not signed in — run `metro login`, or `metro start <agent-id>` on this machine');
|
|
24
24
|
return token;
|
|
25
25
|
}
|
|
26
|
+
function soleRunToken() {
|
|
27
|
+
const tokens = readRunTokens();
|
|
28
|
+
return tokens.length === 1 ? (tokens[0] ?? null) : null;
|
|
29
|
+
}
|
|
26
30
|
async function get(path) {
|
|
27
31
|
const auth = tokenOrThrow();
|
|
28
32
|
let res;
|
|
@@ -36,7 +40,7 @@ async function get(path) {
|
|
|
36
40
|
throw new Error(`could not reach ${metroUrl()}`);
|
|
37
41
|
}
|
|
38
42
|
if (res.status === 401)
|
|
39
|
-
throw new NotSignedIn('
|
|
43
|
+
throw new NotSignedIn("metro refused this machine's sign-in — run `metro login` again");
|
|
40
44
|
if (!res.ok)
|
|
41
45
|
throw new Error(`metro answered ${String(res.status)}`);
|
|
42
46
|
return res.json();
|
|
@@ -98,13 +102,9 @@ export async function claimCode(code) {
|
|
|
98
102
|
if (!isRecord(body) ||
|
|
99
103
|
typeof body.token !== 'string' ||
|
|
100
104
|
typeof body.email !== 'string' ||
|
|
101
|
-
typeof body.
|
|
105
|
+
typeof body.agent !== 'string')
|
|
102
106
|
throw new Error('metro returned an unexpected response');
|
|
103
|
-
return {
|
|
104
|
-
token: body.token,
|
|
105
|
-
email: body.email,
|
|
106
|
-
collection: body.collection,
|
|
107
|
-
};
|
|
107
|
+
return { token: body.token, email: body.email, agent: body.agent };
|
|
108
108
|
}
|
|
109
109
|
export async function claimRuntime(code, label) {
|
|
110
110
|
const body = await post('/api/run/claim', { code, label });
|
|
@@ -125,7 +125,7 @@ export async function whoisAuthorized() {
|
|
|
125
125
|
const body = await get('/api/cli/session');
|
|
126
126
|
if (!isRecord(body) ||
|
|
127
127
|
typeof body.email !== 'string' ||
|
|
128
|
-
typeof body.
|
|
128
|
+
typeof body.agent !== 'string')
|
|
129
129
|
throw new Error('metro returned an unexpected response');
|
|
130
|
-
return { email: body.email,
|
|
130
|
+
return { email: body.email, agent: body.agent };
|
|
131
131
|
}
|
package/dist/bedrock.js
CHANGED
|
@@ -25,9 +25,11 @@ export function settingsFiles(cwd = process.cwd(), env = process.env) {
|
|
|
25
25
|
const explicit = env.CLAUDE_CONFIG_DIR?.trim() ?? '';
|
|
26
26
|
const configDir = explicit === '' ? join(homedir(), '.claude') : explicit;
|
|
27
27
|
return [
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
...new Set([
|
|
29
|
+
join(configDir, 'settings.json'),
|
|
30
|
+
join(cwd, '.claude', 'settings.json'),
|
|
31
|
+
join(cwd, '.claude', 'settings.local.json'),
|
|
32
|
+
]),
|
|
31
33
|
];
|
|
32
34
|
}
|
|
33
35
|
function envBlock(file) {
|
package/dist/cli.js
CHANGED
|
@@ -10,11 +10,15 @@ import { launchClaude } from './claude.js';
|
|
|
10
10
|
import { bedrock } from './bedrock.js';
|
|
11
11
|
import { installPlugin, syncPluginServers } from './plugin.js';
|
|
12
12
|
import { update } from './update.js';
|
|
13
|
+
import { serve } from './serve.js';
|
|
13
14
|
import { currentVersion } from './version.js';
|
|
14
15
|
const USAGE = `metro — the command line for your MCP connectors
|
|
15
16
|
|
|
16
17
|
metro start <agent-id> [--detach]
|
|
17
18
|
run that agent's stations on this machine
|
|
19
|
+
metro serve [--port <n>]
|
|
20
|
+
run a daemon of your own: its agents and stations live in ~/.metro/agents
|
|
21
|
+
here, and metro.box manages it through the link it prints
|
|
18
22
|
metro stop [agent-id]
|
|
19
23
|
stop every metro daemon on this machine, however it was started
|
|
20
24
|
metro status <agent-id>
|
|
@@ -23,10 +27,11 @@ const USAGE = `metro — the command line for your MCP connectors
|
|
|
23
27
|
show the detached daemon's log
|
|
24
28
|
metro tail <agent-id>
|
|
25
29
|
follow this machine's inbound events, one JSON line each
|
|
26
|
-
metro login
|
|
30
|
+
metro login [code]
|
|
31
|
+
authorize this machine for one agent with a code from its page
|
|
27
32
|
metro logout forget this machine's sign-in
|
|
28
|
-
metro whoami print the account and
|
|
29
|
-
metro mcp print the mcpServers block
|
|
33
|
+
metro whoami print the account and agent this machine may read
|
|
34
|
+
metro mcp print the mcpServers block of the authorized agent's connectors
|
|
30
35
|
metro plugin set up the Claude Code plugin (connector servers + /metro:login)
|
|
31
36
|
metro claude [args...]
|
|
32
37
|
open Claude Code with the metro channel; every argument is passed through
|
|
@@ -42,7 +47,8 @@ Start Claude Code with all of them, without writing them to disk:
|
|
|
42
47
|
|
|
43
48
|
METRO_URL the metro to talk to (default https://mcp.metro.box)
|
|
44
49
|
METRO_UI_URL where the web UI lives (default https://metro.box)
|
|
45
|
-
METRO_TOKEN use this
|
|
50
|
+
METRO_TOKEN use this agent sign-in instead of the stored one; with neither,
|
|
51
|
+
a machine running metro start uses its run token
|
|
46
52
|
METRO_RUN_TOKEN use this runtime authorization instead of the stored one
|
|
47
53
|
METRO_BEDROCK_MODEL
|
|
48
54
|
send every request to this Bedrock model id (default: derive from the
|
|
@@ -109,23 +115,28 @@ function logs(argv) {
|
|
|
109
115
|
const agentId = assertAgentId(argv[0]);
|
|
110
116
|
return tail(agentId, argv.includes('-f') || argv.includes('--follow'));
|
|
111
117
|
}
|
|
112
|
-
async function
|
|
113
|
-
|
|
114
|
-
|
|
118
|
+
async function pastedCode(given) {
|
|
119
|
+
if (given !== undefined && given.trim() !== '')
|
|
120
|
+
return given.trim();
|
|
121
|
+
process.stderr.write(`Get a pairing code from the agent's page, or pick the agent at ${metroWebUrl()}/#/authorize\n`);
|
|
122
|
+
return askSecret('Paste the code here (input is hidden): ');
|
|
123
|
+
}
|
|
124
|
+
async function login(given) {
|
|
125
|
+
const { token, email, agent } = await claimCode(await pastedCode(given));
|
|
115
126
|
writeToken(token);
|
|
116
|
-
process.stderr.write(`Authorized '${
|
|
127
|
+
process.stderr.write(`Authorized '${agent}' for ${email}. Stored in ${credentialsPath()}\n`);
|
|
117
128
|
if (syncPluginServers())
|
|
118
129
|
process.stderr.write('Claude Code plugin refreshed — new sessions have the connectors; ' +
|
|
119
130
|
'run /reload-plugins in any session already open.\n');
|
|
120
131
|
}
|
|
121
132
|
async function whoami() {
|
|
122
|
-
const { email,
|
|
123
|
-
process.stdout.write(`${email} ·
|
|
133
|
+
const { email, agent } = await whoisAuthorized();
|
|
134
|
+
process.stdout.write(`${email} · agent '${agent}' on ${metroUrl()}\n`);
|
|
124
135
|
}
|
|
125
136
|
const HELP = new Set([undefined, 'help', '--help', '-h']);
|
|
126
137
|
const COMMANDS = {
|
|
127
138
|
login: async () => {
|
|
128
|
-
await login();
|
|
139
|
+
await login(process.argv[3]);
|
|
129
140
|
return 0;
|
|
130
141
|
},
|
|
131
142
|
logout: async () => {
|
|
@@ -138,6 +149,7 @@ const COMMANDS = {
|
|
|
138
149
|
return 0;
|
|
139
150
|
},
|
|
140
151
|
start: () => start(process.argv.slice(3)),
|
|
152
|
+
serve: () => serve(process.argv.slice(3)),
|
|
141
153
|
stop: () => stopDaemon(process.argv.slice(3)),
|
|
142
154
|
status: () => status(process.argv.slice(3)),
|
|
143
155
|
logs: () => logs(process.argv.slice(3)),
|
package/dist/runtime.js
CHANGED
|
@@ -6,7 +6,7 @@ import { existsSync } from 'node:fs';
|
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import { metroUrl } from './store.js';
|
|
8
8
|
const AGENT_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{10}$/;
|
|
9
|
-
const SERVER_ENTRY = join('node_modules', '@metro-labs', 'mcp', 'src', 'server.ts');
|
|
9
|
+
export const SERVER_ENTRY = join('node_modules', '@metro-labs', 'mcp', 'src', 'server.ts');
|
|
10
10
|
export class MissingRuntime extends Error {
|
|
11
11
|
}
|
|
12
12
|
export function runtimeDir() {
|
|
@@ -76,6 +76,9 @@ export function daemonPlan(opts) {
|
|
|
76
76
|
}
|
|
77
77
|
export function runDaemon(agentId, plan) {
|
|
78
78
|
process.stderr.write(`Starting metro for ${agentId}\n`);
|
|
79
|
+
return spawnPlan(plan);
|
|
80
|
+
}
|
|
81
|
+
export function spawnPlan(plan) {
|
|
79
82
|
const child = spawn(plan.command, plan.args, {
|
|
80
83
|
cwd: plan.cwd,
|
|
81
84
|
env: plan.env,
|
package/dist/serve.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { findBun, localPort, runtimeDir, SERVER_ENTRY, spawnPlan, } from './runtime.js';
|
|
4
|
+
const SCRUBBED = new Set(['METRO_RUN_TOKEN', 'METRO_AGENT', 'DATABASE_URL']);
|
|
5
|
+
const PORT_FLAG = /^--port=(.*)$/;
|
|
6
|
+
const USAGE = 'usage: metro serve [--port <n>]';
|
|
7
|
+
function portOf(raw) {
|
|
8
|
+
const n = Number(raw);
|
|
9
|
+
if (raw === undefined || raw === '' || !Number.isInteger(n) || n < 1 || n > 65535)
|
|
10
|
+
throw new Error(`'${raw ?? ''}' is not a port — ${USAGE}`);
|
|
11
|
+
return n;
|
|
12
|
+
}
|
|
13
|
+
export function parseServeArgs(argv) {
|
|
14
|
+
let port = localPort();
|
|
15
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
16
|
+
const arg = argv[i] ?? '';
|
|
17
|
+
const inline = PORT_FLAG.exec(arg);
|
|
18
|
+
if (inline) {
|
|
19
|
+
port = portOf(inline[1]);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (arg === '--port' || arg === '-p') {
|
|
23
|
+
port = portOf(argv[i + 1]);
|
|
24
|
+
i += 1;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`unknown argument '${arg}' — ${USAGE}`);
|
|
28
|
+
}
|
|
29
|
+
return { port };
|
|
30
|
+
}
|
|
31
|
+
export function serveStateDir() {
|
|
32
|
+
const xdg = process.env.XDG_CACHE_HOME?.trim();
|
|
33
|
+
const base = xdg === undefined || xdg === '' ? join(homedir(), '.cache') : xdg;
|
|
34
|
+
return join(base, 'metro', 'serve');
|
|
35
|
+
}
|
|
36
|
+
export function servePlan(opts) {
|
|
37
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !SCRUBBED.has(key)));
|
|
38
|
+
return {
|
|
39
|
+
command: findBun(),
|
|
40
|
+
args: [SERVER_ENTRY],
|
|
41
|
+
cwd: opts.dir,
|
|
42
|
+
env: {
|
|
43
|
+
...env,
|
|
44
|
+
METRO_MODE: 'local',
|
|
45
|
+
METRO_WEBHOOK_PORT: String(opts.port),
|
|
46
|
+
METRO_HTTP_HOST: process.env.METRO_HTTP_HOST ?? '127.0.0.1',
|
|
47
|
+
METRO_TRAINS_DIR: join(opts.dir, 'trains'),
|
|
48
|
+
METRO_STATE_DIR: process.env.METRO_STATE_DIR ?? serveStateDir(),
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export function serve(argv) {
|
|
53
|
+
const { port } = parseServeArgs(argv);
|
|
54
|
+
const plan = servePlan({ dir: runtimeDir(), port });
|
|
55
|
+
process.stderr.write(`Starting a metro daemon of your own on http://127.0.0.1:${String(port)}\n`);
|
|
56
|
+
return spawnPlan(plan);
|
|
57
|
+
}
|
package/dist/store.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { chmodSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
const DEFAULT_BASE = 'https://mcp.metro.box';
|
|
@@ -21,10 +21,10 @@ function configDir() {
|
|
|
21
21
|
export function credentialsPath() {
|
|
22
22
|
return join(configDir(), 'credentials.json');
|
|
23
23
|
}
|
|
24
|
-
function
|
|
24
|
+
function readStoredAt(path) {
|
|
25
25
|
let raw;
|
|
26
26
|
try {
|
|
27
|
-
raw = readFileSync(
|
|
27
|
+
raw = readFileSync(path, 'utf8');
|
|
28
28
|
}
|
|
29
29
|
catch {
|
|
30
30
|
return null;
|
|
@@ -43,6 +43,29 @@ function readStored() {
|
|
|
43
43
|
return null;
|
|
44
44
|
return { token, url: typeof url === 'string' ? url : DEFAULT_BASE };
|
|
45
45
|
}
|
|
46
|
+
const readStored = () => readStoredAt(credentialsPath());
|
|
47
|
+
const RUN_TOKEN_FILE = /^runtime-[A-Za-z0-9_-]{11}\.json$/;
|
|
48
|
+
export function readRunTokens() {
|
|
49
|
+
const fromEnv = process.env.METRO_RUN_TOKEN?.trim();
|
|
50
|
+
if (fromEnv !== undefined && fromEnv !== '')
|
|
51
|
+
return [fromEnv];
|
|
52
|
+
let names;
|
|
53
|
+
try {
|
|
54
|
+
names = readdirSync(configDir());
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const name of names) {
|
|
61
|
+
if (!RUN_TOKEN_FILE.test(name))
|
|
62
|
+
continue;
|
|
63
|
+
const stored = readStoredAt(join(configDir(), name));
|
|
64
|
+
if (stored !== null && stored.url === metroUrl())
|
|
65
|
+
out.push(stored.token);
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
46
69
|
export function readToken() {
|
|
47
70
|
const fromEnv = process.env.METRO_TOKEN?.trim();
|
|
48
71
|
if (fromEnv !== undefined && fromEnv !== '')
|
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.32",
|
|
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": {
|
|
@@ -51,13 +51,13 @@ export interface AccountApiDeps {
|
|
|
51
51
|
attachSessions: AttachSessionApi;
|
|
52
52
|
prepareAccount: (input: AttachInput) => Promise<PreparedAccount>;
|
|
53
53
|
attachAccount: (
|
|
54
|
-
|
|
54
|
+
subject: string,
|
|
55
55
|
agentId: string,
|
|
56
56
|
station: StationName,
|
|
57
57
|
config: Record<string, unknown>,
|
|
58
58
|
) => Promise<AccountRef>;
|
|
59
59
|
detachAccount: (
|
|
60
|
-
|
|
60
|
+
subject: string,
|
|
61
61
|
agentId: string,
|
|
62
62
|
station: StationName,
|
|
63
63
|
accountId: string,
|
|
@@ -146,7 +146,7 @@ async function storeAccount(
|
|
|
146
146
|
): Promise<AccountRef> {
|
|
147
147
|
try {
|
|
148
148
|
return await deps.attachAccount(
|
|
149
|
-
session.
|
|
149
|
+
session.subject,
|
|
150
150
|
agentId,
|
|
151
151
|
station,
|
|
152
152
|
prepared.config,
|
|
@@ -158,7 +158,7 @@ async function storeAccount(
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
function ownerOf(session: ApiSession, agentId: string): AttachOwner {
|
|
161
|
-
return {
|
|
161
|
+
return { subject: session.subject, agentId };
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
function asInput(body: unknown): Record<string, unknown> {
|
|
@@ -224,7 +224,7 @@ async function handleDetach(
|
|
|
224
224
|
target: { station: StationName; accountId: string },
|
|
225
225
|
): Promise<void> {
|
|
226
226
|
const ref = await deps.detachAccount(
|
|
227
|
-
session.
|
|
227
|
+
session.subject,
|
|
228
228
|
agentId,
|
|
229
229
|
target.station,
|
|
230
230
|
target.accountId,
|
|
@@ -30,18 +30,18 @@ const PREFIX = '/api/agents';
|
|
|
30
30
|
const SERVER_NAME = 'metro';
|
|
31
31
|
|
|
32
32
|
export interface AgentApiDeps extends AccountApiDeps {
|
|
33
|
-
listAgents: (
|
|
33
|
+
listAgents: (subject: string, project: string) => Promise<AgentSummary[]>;
|
|
34
34
|
createAgent: (
|
|
35
|
-
|
|
35
|
+
subject: string,
|
|
36
36
|
project: string,
|
|
37
37
|
name: string,
|
|
38
38
|
) => Promise<CreatedAgent>;
|
|
39
39
|
deleteAgent: (
|
|
40
|
-
|
|
40
|
+
subject: string,
|
|
41
41
|
id: string,
|
|
42
42
|
) => Promise<DeletedAgent>;
|
|
43
43
|
resetKey: (
|
|
44
|
-
|
|
44
|
+
subject: string,
|
|
45
45
|
id: string,
|
|
46
46
|
) => Promise<ResetAgentKey>;
|
|
47
47
|
gatherAccounts: (allowed: Set<string>) => Promise<{
|
|
@@ -49,13 +49,11 @@ export interface AgentApiDeps extends AccountApiDeps {
|
|
|
49
49
|
unavailable: string[];
|
|
50
50
|
}>;
|
|
51
51
|
capabilities: () => Record<string, string[]>;
|
|
52
|
+
attachable?: string[];
|
|
52
53
|
liveness: () => Map<string, { connected: boolean; lastSeenAt: number }>;
|
|
53
|
-
|
|
54
|
-
email: string,
|
|
55
|
-
agentId: string,
|
|
56
|
-
) => Promise<{ code: string; expiresAt: number }>;
|
|
57
|
-
releaseRuntime: (email: string, agentId: string) => Promise<void>;
|
|
54
|
+
releaseRuntime: (subject: string, agentId: string) => Promise<void>;
|
|
58
55
|
runtimes: (agentIds: string[]) => Promise<Map<string, string>>;
|
|
56
|
+
connectorIds: (agentIds: string[]) => Promise<Map<string, string[]>>;
|
|
59
57
|
}
|
|
60
58
|
|
|
61
59
|
type Routable =
|
|
@@ -134,12 +132,14 @@ function agentPayload(
|
|
|
134
132
|
agent: AgentSummary,
|
|
135
133
|
live: Map<string, { connected: boolean; lastSeenAt: number }>,
|
|
136
134
|
runtimes: Map<string, string>,
|
|
135
|
+
connectors: Map<string, string[]>,
|
|
137
136
|
): Record<string, unknown> {
|
|
138
137
|
return {
|
|
139
138
|
id: agent.id,
|
|
140
139
|
name: agent.name,
|
|
141
140
|
owned: agent.owned,
|
|
142
141
|
runtime: agent.owned ? (runtimes.get(agent.id) ?? null) : null,
|
|
142
|
+
connector_ids: connectors.get(agent.id) ?? [],
|
|
143
143
|
...livenessPayload(agent, live),
|
|
144
144
|
...keyPayload(agent),
|
|
145
145
|
};
|
|
@@ -161,17 +161,18 @@ async function handleList(
|
|
|
161
161
|
sendJson(req, res, 400, { error: 'a project is required' });
|
|
162
162
|
return;
|
|
163
163
|
}
|
|
164
|
-
const list = await deps.listAgents(session.
|
|
164
|
+
const list = await deps.listAgents(session.subject, project);
|
|
165
165
|
const live = deps.liveness();
|
|
166
166
|
const held = await deps.runtimes(
|
|
167
167
|
list.filter((a) => a.owned).map((a) => a.id),
|
|
168
168
|
);
|
|
169
|
+
const connectors = await deps.connectorIds(list.map((a) => a.id));
|
|
169
170
|
const base = {
|
|
170
|
-
|
|
171
|
+
subject: session.subject,
|
|
171
172
|
endpoint: mcpEndpoint(),
|
|
172
|
-
agents: list.map((a) => agentPayload(a, live, held)),
|
|
173
|
+
agents: list.map((a) => agentPayload(a, live, held, connectors)),
|
|
173
174
|
capabilities: deps.capabilities(),
|
|
174
|
-
attachable: ATTACHABLE,
|
|
175
|
+
attachable: deps.attachable ?? ATTACHABLE,
|
|
175
176
|
};
|
|
176
177
|
if (!wantsAccounts(req)) {
|
|
177
178
|
sendJson(req, res, 200, base);
|
|
@@ -190,14 +191,9 @@ async function handleRuntime(
|
|
|
190
191
|
session: ApiSession,
|
|
191
192
|
id: string,
|
|
192
193
|
): Promise<void> {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
sendJson(req, res, 200, { agent: id, runtime: null });
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
const minted = await deps.mintRuntimeCode(session.email, id);
|
|
200
|
-
sendJson(req, res, 201, minted);
|
|
194
|
+
await deps.releaseRuntime(session.subject, id);
|
|
195
|
+
log.info({ agent: id }, 'agent-api: runtime released');
|
|
196
|
+
sendJson(req, res, 200, { agent: id, runtime: null });
|
|
201
197
|
}
|
|
202
198
|
|
|
203
199
|
async function handleCreate(
|
|
@@ -213,9 +209,9 @@ async function handleCreate(
|
|
|
213
209
|
sendJson(req, res, 400, { error: 'a project is required' });
|
|
214
210
|
return;
|
|
215
211
|
}
|
|
216
|
-
const created = await deps.createAgent(session.
|
|
212
|
+
const created = await deps.createAgent(session.subject, project, name as string);
|
|
217
213
|
log.info(
|
|
218
|
-
{ agent: created.name, id: created.id, owner: session.
|
|
214
|
+
{ agent: created.name, id: created.id, owner: session.subject },
|
|
219
215
|
'agent-api: created agent',
|
|
220
216
|
);
|
|
221
217
|
sendJson(req, res, 201, {
|
|
@@ -232,9 +228,9 @@ async function handleResetKey(
|
|
|
232
228
|
session: ApiSession,
|
|
233
229
|
id: string,
|
|
234
230
|
): Promise<void> {
|
|
235
|
-
const reset = await deps.resetKey(session.
|
|
231
|
+
const reset = await deps.resetKey(session.subject, id);
|
|
236
232
|
log.info(
|
|
237
|
-
{ agent: reset.name, id: reset.id, owner: session.
|
|
233
|
+
{ agent: reset.name, id: reset.id, owner: session.subject },
|
|
238
234
|
'agent-api: reset agent key',
|
|
239
235
|
);
|
|
240
236
|
sendJson(req, res, 200, {
|
|
@@ -252,9 +248,9 @@ async function handleDelete(
|
|
|
252
248
|
session: ApiSession,
|
|
253
249
|
id: string,
|
|
254
250
|
): Promise<void> {
|
|
255
|
-
const gone = await deps.deleteAgent(session.
|
|
251
|
+
const gone = await deps.deleteAgent(session.subject, id);
|
|
256
252
|
log.info(
|
|
257
|
-
{ agent: gone.name, id: gone.id, owner: session.
|
|
253
|
+
{ agent: gone.name, id: gone.id, owner: session.subject },
|
|
258
254
|
'agent-api: deleted agent',
|
|
259
255
|
);
|
|
260
256
|
sendJson(req, res, 200, { id: gone.id, name: gone.name, deleted: true });
|
|
@@ -286,7 +282,7 @@ const ALLOWED: Record<AgentTarget['kind'], string[]> = {
|
|
|
286
282
|
collection: ['GET', 'POST'],
|
|
287
283
|
agent: ['DELETE'],
|
|
288
284
|
key: ['POST'],
|
|
289
|
-
runtime: ['
|
|
285
|
+
runtime: ['DELETE'],
|
|
290
286
|
};
|
|
291
287
|
|
|
292
288
|
function methodAllowed(tgt: Routable, method: string | undefined): boolean {
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { errMsg, log } from './log.js';
|
|
3
|
+
import {
|
|
4
|
+
apiFailure,
|
|
5
|
+
apiSession,
|
|
6
|
+
bodyField,
|
|
7
|
+
cors,
|
|
8
|
+
readJsonBody,
|
|
9
|
+
sendJson,
|
|
10
|
+
type ApiSession,
|
|
11
|
+
} from './api-http.js';
|
|
12
|
+
import { parseId } from '../db/ids.js';
|
|
13
|
+
import type { AgentConnectors } from '../db/agent-connectors.js';
|
|
14
|
+
|
|
15
|
+
const PREFIX = '/api/agents';
|
|
16
|
+
|
|
17
|
+
export interface AgentConnectorApiDeps {
|
|
18
|
+
agentConnectors: (subject: string, agentId: string) => Promise<AgentConnectors>;
|
|
19
|
+
addConnector: (
|
|
20
|
+
subject: string,
|
|
21
|
+
agentId: string,
|
|
22
|
+
connectorId: string,
|
|
23
|
+
) => Promise<AgentConnectors>;
|
|
24
|
+
removeConnector: (
|
|
25
|
+
subject: string,
|
|
26
|
+
agentId: string,
|
|
27
|
+
connectorId: string,
|
|
28
|
+
) => Promise<AgentConnectors>;
|
|
29
|
+
mintCode: (
|
|
30
|
+
subject: string,
|
|
31
|
+
agentId: string,
|
|
32
|
+
) => Promise<{ code: string; expiresAt: number; agent: string }>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Routable =
|
|
36
|
+
| { kind: 'connectors'; id: string }
|
|
37
|
+
| { kind: 'connector'; id: string; connectorId: string }
|
|
38
|
+
| { kind: 'code'; id: string };
|
|
39
|
+
|
|
40
|
+
type Target = Routable | { kind: 'unknown' } | null;
|
|
41
|
+
|
|
42
|
+
const ALLOWED: Record<Routable['kind'], string[]> = {
|
|
43
|
+
connectors: ['GET', 'POST'],
|
|
44
|
+
connector: ['DELETE'],
|
|
45
|
+
code: ['POST'],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function connectorTarget(id: string, rest: string[]): Target {
|
|
49
|
+
if (rest.length === 1) return { kind: 'connectors', id };
|
|
50
|
+
if (rest.length > 2) return { kind: 'unknown' };
|
|
51
|
+
const connectorId = parseId(rest[1] ?? '');
|
|
52
|
+
return connectorId === null
|
|
53
|
+
? { kind: 'unknown' }
|
|
54
|
+
: { kind: 'connector', id, connectorId };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function target(path: string): Target {
|
|
58
|
+
if (!path.startsWith(`${PREFIX}/`)) return null;
|
|
59
|
+
const segments = path.slice(PREFIX.length + 1).split('/').filter(Boolean);
|
|
60
|
+
const id = parseId(segments[0] ?? '');
|
|
61
|
+
const head = segments[1];
|
|
62
|
+
if (id === null || head === undefined) return null;
|
|
63
|
+
if (head === 'connectors') return connectorTarget(id, segments.slice(1));
|
|
64
|
+
if (head === 'code' && segments.length === 2) return { kind: 'code', id };
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function handleConnectors(
|
|
69
|
+
req: IncomingMessage,
|
|
70
|
+
res: ServerResponse,
|
|
71
|
+
deps: AgentConnectorApiDeps,
|
|
72
|
+
session: ApiSession,
|
|
73
|
+
id: string,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
if (req.method === 'GET') {
|
|
76
|
+
sendJson(req, res, 200, await deps.agentConnectors(session.subject, id));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const connectorId = bodyField(await readJsonBody(req), 'connectorId');
|
|
80
|
+
if (typeof connectorId !== 'string' || parseId(connectorId) === null) {
|
|
81
|
+
sendJson(req, res, 400, { error: 'connectorId is required' });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
sendJson(req, res, 200, await deps.addConnector(session.subject, id, connectorId));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function handleCode(
|
|
88
|
+
req: IncomingMessage,
|
|
89
|
+
res: ServerResponse,
|
|
90
|
+
deps: AgentConnectorApiDeps,
|
|
91
|
+
session: ApiSession,
|
|
92
|
+
id: string,
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
const minted = await deps.mintCode(session.subject, id);
|
|
95
|
+
log.info({ agent: id, subject: session.subject }, 'agent-api: minted pairing code');
|
|
96
|
+
sendJson(req, res, 201, minted);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function route(
|
|
100
|
+
req: IncomingMessage,
|
|
101
|
+
res: ServerResponse,
|
|
102
|
+
deps: AgentConnectorApiDeps,
|
|
103
|
+
session: ApiSession,
|
|
104
|
+
tgt: Routable,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
try {
|
|
107
|
+
if (tgt.kind === 'connectors') await handleConnectors(req, res, deps, session, tgt.id);
|
|
108
|
+
else if (tgt.kind === 'code') await handleCode(req, res, deps, session, tgt.id);
|
|
109
|
+
else
|
|
110
|
+
sendJson(
|
|
111
|
+
req,
|
|
112
|
+
res,
|
|
113
|
+
200,
|
|
114
|
+
await deps.removeConnector(session.subject, tgt.id, tgt.connectorId),
|
|
115
|
+
);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
apiFailure(req, res, err, 'agent-api');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function handleAgentConnectorRequest(
|
|
122
|
+
req: IncomingMessage,
|
|
123
|
+
res: ServerResponse,
|
|
124
|
+
deps: AgentConnectorApiDeps,
|
|
125
|
+
): boolean {
|
|
126
|
+
const tgt = target((req.url ?? '').split('?')[0] ?? '');
|
|
127
|
+
if (tgt === null) return false;
|
|
128
|
+
if (req.method === 'OPTIONS') {
|
|
129
|
+
res.writeHead(204, cors(req)).end();
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
if (tgt.kind === 'unknown') {
|
|
133
|
+
sendJson(req, res, 404, { error: 'no such connector' });
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
if (!ALLOWED[tgt.kind].includes(req.method ?? '')) {
|
|
137
|
+
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
const session = apiSession(req);
|
|
141
|
+
if (!session) {
|
|
142
|
+
sendJson(req, res, 401, { error: 'unauthorized' });
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
route(req, res, deps, session, tgt).catch((err: unknown) => {
|
|
146
|
+
log.warn({ err: errMsg(err) }, 'agent-api: unhandled error');
|
|
147
|
+
if (!res.headersSent) sendJson(req, res, 500, { error: 'agent api failed' });
|
|
148
|
+
});
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { makeCodeStore } from './pair-codes.js';
|
|
2
|
+
|
|
3
|
+
export interface AgentCode {
|
|
4
|
+
subject: string;
|
|
5
|
+
agentId: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const store = makeCodeStore<AgentCode>('ma');
|
|
9
|
+
|
|
10
|
+
export const AGENT_CODE_RE = store.pattern;
|
|
11
|
+
|
|
12
|
+
export const mintAgentCode = (
|
|
13
|
+
entry: AgentCode,
|
|
14
|
+
now?: number,
|
|
15
|
+
): { code: string; expiresAt: number } => store.mint(entry, now);
|
|
16
|
+
|
|
17
|
+
export const takeAgentCode = (code: string, now?: number): AgentCode | undefined =>
|
|
18
|
+
store.take(code, now);
|
|
19
|
+
|
|
20
|
+
export const agentCodeCount = (): number => store.count();
|