@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.
Files changed (59) hide show
  1. package/dist/api.js +12 -12
  2. package/dist/bedrock.js +5 -3
  3. package/dist/cli.js +23 -11
  4. package/dist/runtime.js +4 -1
  5. package/dist/serve.js +57 -0
  6. package/dist/store.js +26 -3
  7. package/package.json +1 -1
  8. package/runtime/node_modules/@metro-labs/mcp/package.json +1 -0
  9. package/runtime/node_modules/@metro-labs/mcp/src/daemon/account-api.ts +5 -5
  10. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-api.ts +24 -28
  11. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-connector-api.ts +150 -0
  12. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-pair.ts +20 -0
  13. package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +34 -10
  14. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-serve.ts +5 -3
  15. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-session.ts +2 -2
  16. package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +119 -72
  17. package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair-api.ts +31 -20
  18. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +17 -43
  19. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth-routes.ts +8 -8
  20. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth.ts +3 -3
  21. package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +12 -2
  22. package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity.ts +11 -4
  23. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +158 -0
  24. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-secret.ts +21 -0
  25. package/runtime/node_modules/@metro-labs/mcp/src/daemon/mode-api.ts +31 -0
  26. package/runtime/node_modules/@metro-labs/mcp/src/daemon/oauth-pending.ts +1 -1
  27. package/runtime/node_modules/@metro-labs/mcp/src/daemon/paths.ts +10 -0
  28. package/runtime/node_modules/@metro-labs/mcp/src/daemon/project-api.ts +17 -17
  29. package/runtime/node_modules/@metro-labs/mcp/src/daemon/relay.ts +13 -12
  30. package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-api.ts +15 -6
  31. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-api.ts +1 -1
  32. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +12 -3
  33. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-config.ts +27 -0
  34. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +21 -21
  35. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-auth.ts +82 -0
  36. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-nonces.ts +43 -0
  37. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-routes.ts +89 -0
  38. package/runtime/node_modules/@metro-labs/mcp/src/daemon/supervisor.ts +2 -5
  39. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-admin.ts +15 -16
  40. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-connectors.ts +143 -0
  41. package/runtime/node_modules/@metro-labs/mcp/src/db/connector-relay.ts +8 -8
  42. package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +32 -32
  43. package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +250 -0
  44. package/runtime/node_modules/@metro-labs/mcp/src/db/file-source.ts +152 -0
  45. package/runtime/node_modules/@metro-labs/mcp/src/db/ids.ts +2 -0
  46. package/runtime/node_modules/@metro-labs/mcp/src/db/materialize.ts +16 -7
  47. package/runtime/node_modules/@metro-labs/mcp/src/db/projects.ts +49 -40
  48. package/runtime/node_modules/@metro-labs/mcp/src/db/runtime-admin.ts +13 -20
  49. package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +8 -21
  50. package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +32 -17
  51. package/runtime/node_modules/@metro-labs/mcp/src/mcp/request-identity.ts +4 -4
  52. package/runtime/node_modules/@metro-labs/mcp/src/mcp/session-route.ts +1 -1
  53. package/runtime/runtime.json +1 -1
  54. package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair.ts +0 -20
  55. package/runtime/node_modules/@metro-labs/mcp/src/daemon/collection-api.ts +0 -202
  56. package/runtime/node_modules/@metro-labs/mcp/src/daemon/google-auth.ts +0 -164
  57. package/runtime/node_modules/@metro-labs/mcp/src/daemon/google-oauth.ts +0 -208
  58. package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-pair.ts +0 -18
  59. 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` first');
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('that sign-in has expired — run `metro login` again');
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.collection !== 'string')
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.collection !== 'string')
128
+ typeof body.agent !== 'string')
129
129
  throw new Error('metro returned an unexpected response');
130
- return { email: body.email, collection: body.collection };
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
- join(configDir, 'settings.json'),
29
- join(cwd, '.claude', 'settings.json'),
30
- join(cwd, '.claude', 'settings.local.json'),
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 authorize a connector collection with a code from the web UI
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 collection this machine may read
29
- metro mcp print the mcpServers block for the authorized collection
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 connector sign-in instead of the stored one
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 login() {
113
- process.stderr.write(`Choose a connector collection at ${metroWebUrl()}/#/authorize\n`);
114
- const { token, email, collection } = await claimCode(await askSecret('Paste the code here (input is hidden): '));
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 '${collection}' for ${email}. Stored in ${credentialsPath()}\n`);
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, collection } = await whoisAuthorized();
123
- process.stdout.write(`${email} · collection '${collection}' on ${metroUrl()}\n`);
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 readStored() {
24
+ function readStoredAt(path) {
25
25
  let raw;
26
26
  try {
27
- raw = readFileSync(credentialsPath(), 'utf8');
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.30",
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": {
@@ -54,6 +54,7 @@
54
54
  "pino": "^9.5.0",
55
55
  "pino-pretty": "^13.1.3",
56
56
  "postgres": "^3.4.5",
57
+ "viem": "2.52.2",
57
58
  "zod": "^3.23.8"
58
59
  },
59
60
  "devDependencies": {
@@ -51,13 +51,13 @@ export interface AccountApiDeps {
51
51
  attachSessions: AttachSessionApi;
52
52
  prepareAccount: (input: AttachInput) => Promise<PreparedAccount>;
53
53
  attachAccount: (
54
- email: string,
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
- email: string,
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.email,
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 { email: session.email, agentId };
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.email,
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: (email: string, project: string) => Promise<AgentSummary[]>;
33
+ listAgents: (subject: string, project: string) => Promise<AgentSummary[]>;
34
34
  createAgent: (
35
- email: string,
35
+ subject: string,
36
36
  project: string,
37
37
  name: string,
38
38
  ) => Promise<CreatedAgent>;
39
39
  deleteAgent: (
40
- email: string,
40
+ subject: string,
41
41
  id: string,
42
42
  ) => Promise<DeletedAgent>;
43
43
  resetKey: (
44
- email: string,
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
- mintRuntimeCode: (
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.email, project);
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
- email: session.email,
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
- if (req.method === 'DELETE') {
194
- await deps.releaseRuntime(session.email, id);
195
- log.info({ agent: id }, 'agent-api: runtime released');
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.email, project, name as string);
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.email },
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.email, id);
231
+ const reset = await deps.resetKey(session.subject, id);
236
232
  log.info(
237
- { agent: reset.name, id: reset.id, owner: session.email },
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.email, id);
251
+ const gone = await deps.deleteAgent(session.subject, id);
256
252
  log.info(
257
- { agent: gone.name, id: gone.id, owner: session.email },
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: ['POST', 'DELETE'],
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();