@stage-labs/metro 0.1.0-beta.30 → 0.1.0-beta.31

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 (28) hide show
  1. package/dist/api.js +12 -12
  2. package/dist/bedrock.js +5 -3
  3. package/dist/cli.js +18 -11
  4. package/dist/store.js +26 -3
  5. package/package.json +1 -1
  6. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-api.ts +9 -14
  7. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-connector-api.ts +150 -0
  8. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-pair.ts +20 -0
  9. package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +29 -6
  10. package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +27 -18
  11. package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair-api.ts +21 -19
  12. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +4 -30
  13. package/runtime/node_modules/@metro-labs/mcp/src/daemon/relay.ts +13 -12
  14. package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-api.ts +13 -4
  15. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +8 -3
  16. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +13 -13
  17. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-connectors.ts +143 -0
  18. package/runtime/node_modules/@metro-labs/mcp/src/db/connector-relay.ts +8 -8
  19. package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +1 -1
  20. package/runtime/node_modules/@metro-labs/mcp/src/db/projects.ts +1 -1
  21. package/runtime/node_modules/@metro-labs/mcp/src/db/runtime-admin.ts +8 -15
  22. package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +6 -20
  23. package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +10 -5
  24. package/runtime/runtime.json +1 -1
  25. package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair.ts +0 -20
  26. package/runtime/node_modules/@metro-labs/mcp/src/daemon/collection-api.ts +0 -202
  27. package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-pair.ts +0 -18
  28. 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
@@ -23,10 +23,11 @@ const USAGE = `metro — the command line for your MCP connectors
23
23
  show the detached daemon's log
24
24
  metro tail <agent-id>
25
25
  follow this machine's inbound events, one JSON line each
26
- metro login authorize a connector collection with a code from the web UI
26
+ metro login [code]
27
+ authorize this machine for one agent with a code from its page
27
28
  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
29
+ metro whoami print the account and agent this machine may read
30
+ metro mcp print the mcpServers block of the authorized agent's connectors
30
31
  metro plugin set up the Claude Code plugin (connector servers + /metro:login)
31
32
  metro claude [args...]
32
33
  open Claude Code with the metro channel; every argument is passed through
@@ -42,7 +43,8 @@ Start Claude Code with all of them, without writing them to disk:
42
43
 
43
44
  METRO_URL the metro to talk to (default https://mcp.metro.box)
44
45
  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
46
+ METRO_TOKEN use this agent sign-in instead of the stored one; with neither,
47
+ a machine running metro start uses its run token
46
48
  METRO_RUN_TOKEN use this runtime authorization instead of the stored one
47
49
  METRO_BEDROCK_MODEL
48
50
  send every request to this Bedrock model id (default: derive from the
@@ -109,23 +111,28 @@ function logs(argv) {
109
111
  const agentId = assertAgentId(argv[0]);
110
112
  return tail(agentId, argv.includes('-f') || argv.includes('--follow'));
111
113
  }
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): '));
114
+ async function pastedCode(given) {
115
+ if (given !== undefined && given.trim() !== '')
116
+ return given.trim();
117
+ process.stderr.write(`Get a pairing code from the agent's page, or pick the agent at ${metroWebUrl()}/#/authorize\n`);
118
+ return askSecret('Paste the code here (input is hidden): ');
119
+ }
120
+ async function login(given) {
121
+ const { token, email, agent } = await claimCode(await pastedCode(given));
115
122
  writeToken(token);
116
- process.stderr.write(`Authorized '${collection}' for ${email}. Stored in ${credentialsPath()}\n`);
123
+ process.stderr.write(`Authorized '${agent}' for ${email}. Stored in ${credentialsPath()}\n`);
117
124
  if (syncPluginServers())
118
125
  process.stderr.write('Claude Code plugin refreshed — new sessions have the connectors; ' +
119
126
  'run /reload-plugins in any session already open.\n');
120
127
  }
121
128
  async function whoami() {
122
- const { email, collection } = await whoisAuthorized();
123
- process.stdout.write(`${email} · collection '${collection}' on ${metroUrl()}\n`);
129
+ const { email, agent } = await whoisAuthorized();
130
+ process.stdout.write(`${email} · agent '${agent}' on ${metroUrl()}\n`);
124
131
  }
125
132
  const HELP = new Set([undefined, 'help', '--help', '-h']);
126
133
  const COMMANDS = {
127
134
  login: async () => {
128
- await login();
135
+ await login(process.argv[3]);
129
136
  return 0;
130
137
  },
131
138
  logout: async () => {
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.31",
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": {
@@ -50,12 +50,9 @@ export interface AgentApiDeps extends AccountApiDeps {
50
50
  }>;
51
51
  capabilities: () => Record<string, string[]>;
52
52
  liveness: () => Map<string, { connected: boolean; lastSeenAt: number }>;
53
- mintRuntimeCode: (
54
- email: string,
55
- agentId: string,
56
- ) => Promise<{ code: string; expiresAt: number }>;
57
53
  releaseRuntime: (email: string, agentId: string) => Promise<void>;
58
54
  runtimes: (agentIds: string[]) => Promise<Map<string, string>>;
55
+ connectorIds: (agentIds: string[]) => Promise<Map<string, string[]>>;
59
56
  }
60
57
 
61
58
  type Routable =
@@ -134,12 +131,14 @@ function agentPayload(
134
131
  agent: AgentSummary,
135
132
  live: Map<string, { connected: boolean; lastSeenAt: number }>,
136
133
  runtimes: Map<string, string>,
134
+ connectors: Map<string, string[]>,
137
135
  ): Record<string, unknown> {
138
136
  return {
139
137
  id: agent.id,
140
138
  name: agent.name,
141
139
  owned: agent.owned,
142
140
  runtime: agent.owned ? (runtimes.get(agent.id) ?? null) : null,
141
+ connector_ids: connectors.get(agent.id) ?? [],
143
142
  ...livenessPayload(agent, live),
144
143
  ...keyPayload(agent),
145
144
  };
@@ -166,10 +165,11 @@ async function handleList(
166
165
  const held = await deps.runtimes(
167
166
  list.filter((a) => a.owned).map((a) => a.id),
168
167
  );
168
+ const connectors = await deps.connectorIds(list.map((a) => a.id));
169
169
  const base = {
170
170
  email: session.email,
171
171
  endpoint: mcpEndpoint(),
172
- agents: list.map((a) => agentPayload(a, live, held)),
172
+ agents: list.map((a) => agentPayload(a, live, held, connectors)),
173
173
  capabilities: deps.capabilities(),
174
174
  attachable: ATTACHABLE,
175
175
  };
@@ -190,14 +190,9 @@ async function handleRuntime(
190
190
  session: ApiSession,
191
191
  id: string,
192
192
  ): 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);
193
+ await deps.releaseRuntime(session.email, id);
194
+ log.info({ agent: id }, 'agent-api: runtime released');
195
+ sendJson(req, res, 200, { agent: id, runtime: null });
201
196
  }
202
197
 
203
198
  async function handleCreate(
@@ -286,7 +281,7 @@ const ALLOWED: Record<AgentTarget['kind'], string[]> = {
286
281
  collection: ['GET', 'POST'],
287
282
  agent: ['DELETE'],
288
283
  key: ['POST'],
289
- runtime: ['POST', 'DELETE'],
284
+ runtime: ['DELETE'],
290
285
  };
291
286
 
292
287
  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: (email: string, agentId: string) => Promise<AgentConnectors>;
19
+ addConnector: (
20
+ email: string,
21
+ agentId: string,
22
+ connectorId: string,
23
+ ) => Promise<AgentConnectors>;
24
+ removeConnector: (
25
+ email: string,
26
+ agentId: string,
27
+ connectorId: string,
28
+ ) => Promise<AgentConnectors>;
29
+ mintCode: (
30
+ email: 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.email, 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.email, 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.email, id);
95
+ log.info({ agent: id, email: session.email }, '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.email, 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
+ email: 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();
@@ -2,10 +2,10 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { ApiError } from './api-error.js';
3
3
  import { errMsg, log } from './log.js';
4
4
  import {
5
- verifyCliToken,
5
+ verifyAgentToken,
6
6
  verifyRunToken,
7
7
  verifySession,
8
- type CliClaims,
8
+ type AgentClaims,
9
9
  type RunClaims,
10
10
  } from './session.js';
11
11
  import { extractToken } from '../mcp/request-identity.js';
@@ -67,16 +67,39 @@ export function runIdentity(req: IncomingMessage): RunClaims | null {
67
67
  }
68
68
  }
69
69
 
70
- export function cliIdentity(req: IncomingMessage): CliClaims | null {
70
+ export interface AgentIdentity extends AgentClaims {
71
+ runtimeId?: string;
72
+ }
73
+
74
+ export function agentIdentity(req: IncomingMessage): AgentIdentity | null {
71
75
  const secret = process.env.METRO_SESSION_SECRET?.trim() ?? '';
72
76
  if (secret === '') return null;
73
77
  const token = extractToken(req) ?? '';
74
78
  if (token === '') return null;
75
79
  try {
76
- const { email, collectionId } = verifyCliToken(token, secret);
77
- return { email: email.toLowerCase(), collectionId };
80
+ const { email, agentId } = verifyAgentToken(token, secret);
81
+ return { email: email.toLowerCase(), agentId };
78
82
  } catch {
79
- return null;
83
+ const run = runIdentity(req);
84
+ if (run === null) return null;
85
+ return { email: run.email, agentId: run.agentId, runtimeId: run.runtimeId };
86
+ }
87
+ }
88
+
89
+ export type Fence = (runtimeId: string, agentId: string) => Promise<void>;
90
+
91
+ const RELEASED = 'this machine no longer holds the agent';
92
+
93
+ export async function assertLease(
94
+ who: AgentIdentity,
95
+ fence: Fence,
96
+ ): Promise<void> {
97
+ if (who.runtimeId === undefined) return;
98
+ try {
99
+ await fence(who.runtimeId, who.agentId);
100
+ } catch (err) {
101
+ if (err instanceof ApiError) throw new ApiError(RELEASED, 401);
102
+ throw err;
80
103
  }
81
104
  }
82
105
 
@@ -26,7 +26,8 @@ import {
26
26
  touchRuntime,
27
27
  } from '../db/runtimes.js';
28
28
  import {
29
- mintRuntimeCodeForEmail,
29
+ blockedStationsFor,
30
+ mintAgentCodeForEmail,
30
31
  releaseRuntimeForEmail,
31
32
  } from '../db/runtime-admin.js';
32
33
  import { loadAgentForRuntime, localAgentKey } from '../db/materialize.js';
@@ -82,14 +83,12 @@ import {
82
83
  setMemberRoleForEmail,
83
84
  } from '../db/projects.js';
84
85
  import {
85
- addToCollectionForEmail,
86
- createCollectionForEmail,
87
- deleteCollectionForEmail,
88
- getCollectionForEmail,
89
- listCollectionsForEmail,
90
- removeFromCollectionForEmail,
91
- renameCollectionForEmail,
92
- } from '../db/connector-collections.js';
86
+ addConnectorToAgentForEmail,
87
+ agentConnectorsForEmail,
88
+ connectorIdsByAgent,
89
+ removeConnectorFromAgentForEmail,
90
+ } from '../db/agent-connectors.js';
91
+ import type { AgentConnectorApiDeps } from './agent-connector-api.js';
93
92
  import type { StationName } from '../db/schema.js';
94
93
  import { AttachSessions } from './attach-session.js';
95
94
  import { startUploadReaper } from './upload-store.js';
@@ -208,8 +207,8 @@ const agentApi: AgentApiDeps = {
208
207
  gatherAccounts: gatherAccountsForAgents,
209
208
  capabilities: accountStationCapabilities,
210
209
  liveness: agentLiveness,
211
- mintRuntimeCode: mintRuntimeCodeForEmail,
212
210
  releaseRuntime: releaseRuntimeForEmail,
211
+ connectorIds: connectorIdsByAgent,
213
212
  runtimes: runtimeLabels,
214
213
  prepareAccount,
215
214
  attachAccount: attachAccountToAgent,
@@ -222,6 +221,14 @@ const runApi: RunApiDeps = {
222
221
  fenceRuntime,
223
222
  touchRuntime,
224
223
  loadAgent: loadAgentForRuntime,
224
+ blockedStations: blockedStationsFor,
225
+ };
226
+
227
+ const agentConnectorApi: AgentConnectorApiDeps = {
228
+ agentConnectors: agentConnectorsForEmail,
229
+ addConnector: addConnectorToAgentForEmail,
230
+ removeConnector: removeConnectorFromAgentForEmail,
231
+ mintCode: mintAgentCodeForEmail,
225
232
  };
226
233
 
227
234
  const projectApi: ProjectApiDeps = {
@@ -238,13 +245,8 @@ const projectApi: ProjectApiDeps = {
238
245
  const connectorApi: ConnectorApiDeps = {
239
246
  listConnectors: listConnectorsForEmail,
240
247
  connectorNamesByIds,
241
- listCollections: listCollectionsForEmail,
242
- getCollection: getCollectionForEmail,
243
- createCollection: createCollectionForEmail,
244
- renameCollection: renameCollectionForEmail,
245
- deleteCollection: deleteCollectionForEmail,
246
- addToCollection: addToCollectionForEmail,
247
- removeFromCollection: removeFromCollectionForEmail,
248
+ agentConnectors: agentConnectorsForEmail,
249
+ fenceRuntime,
248
250
  renameConnector: renameConnectorForEmail,
249
251
  createConnector: createConnectorForEmail,
250
252
  createPendingConnector: createPendingConnectorForEmail,
@@ -263,7 +265,14 @@ async function main(): Promise<void> {
263
265
  const metroMcp = await createMetroMcp();
264
266
  webhookServer = await startWebhookServer(
265
267
  emit,
266
- { agentApi, connectorApi, projectApi, runApi, relayApi: { target: relayTarget } },
268
+ {
269
+ agentApi,
270
+ agentConnectorApi,
271
+ connectorApi,
272
+ projectApi,
273
+ runApi,
274
+ relayApi: { target: relayTarget, fence: fenceRuntime },
275
+ },
267
276
  metroMcp.httpHandler,
268
277
  metroCall,
269
278
  );
@@ -1,18 +1,20 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
- import { errMsg, log } from './log.js';
2
+ import { log } from './log.js';
3
3
  import {
4
+ agentIdentity,
5
+ apiFailure,
6
+ assertLease,
4
7
  bodyField,
5
- cliIdentity,
6
8
  cors,
7
9
  readJsonBody,
8
10
  sendJson,
9
11
  } from './api-http.js';
10
12
  import { extractToken } from '../mcp/request-identity.js';
11
13
  import { publicBaseOrDefault } from './attach-serve.js';
12
- import { CLI_CODE_RE, takeCliCode } from './cli-pair.js';
14
+ import { AGENT_CODE_RE, takeAgentCode } from './agent-pair.js';
13
15
  import { relayServersJson } from './connector-json.js';
14
16
  import { sessionTtlFromEnv } from './google-oauth.js';
15
- import { signCliToken } from './session.js';
17
+ import { signAgentToken } from './session.js';
16
18
  import type { ConnectorApiDeps } from './connector-api.js';
17
19
 
18
20
  const CLAIM_PATH = '/api/cli/claim';
@@ -42,28 +44,28 @@ async function handleClaim(
42
44
  }
43
45
  const raw = bodyField(await readJsonBody(req), 'code');
44
46
  const code = typeof raw === 'string' ? raw.trim() : '';
45
- if (!CLI_CODE_RE.test(code)) {
47
+ if (!AGENT_CODE_RE.test(code)) {
46
48
  sendJson(req, res, 400, {
47
49
  error:
48
- 'that does not look like a collection code — metro login wants the mc_… code from metro.box/#/authorize',
50
+ 'that does not look like an agent code — metro login wants the ma_… code from the agent page',
49
51
  });
50
52
  return;
51
53
  }
52
- const taken = takeCliCode(code);
54
+ const taken = takeAgentCode(code);
53
55
  if (taken === undefined) {
54
56
  sendJson(req, res, 400, {
55
57
  error: 'that code has expired or was already used',
56
58
  });
57
59
  return;
58
60
  }
59
- const collection = await deps.getCollection(taken.email, taken.collectionId);
60
- const token = signCliToken(
61
- { email: taken.email, collectionId: collection.id },
61
+ const agent = await deps.agentConnectors(taken.email, taken.agentId);
62
+ const token = signAgentToken(
63
+ { email: taken.email, agentId: agent.id },
62
64
  secret,
63
65
  { ttlSec: sessionTtlFromEnv() },
64
66
  );
65
- log.info({ email: taken.email, collection: collection.id }, 'cli: code claimed');
66
- sendJson(req, res, 200, { token, email: taken.email, collection: collection.name });
67
+ log.info({ email: taken.email, agent: agent.id }, 'cli: code claimed');
68
+ sendJson(req, res, 200, { token, email: taken.email, agent: agent.name });
67
69
  }
68
70
 
69
71
  async function handleRead(
@@ -72,23 +74,24 @@ async function handleRead(
72
74
  deps: ConnectorApiDeps,
73
75
  path: string,
74
76
  ): Promise<void> {
75
- const who = cliIdentity(req);
77
+ const who = agentIdentity(req);
76
78
  if (who === null) {
77
79
  sendJson(req, res, 401, { error: 'unauthorized' });
78
80
  return;
79
81
  }
80
- const collection = await deps.getCollection(who.email, who.collectionId);
82
+ await assertLease(who, deps.fenceRuntime);
83
+ const agent = await deps.agentConnectors(who.email, who.agentId);
81
84
  if (path === SESSION_PATH) {
82
- sendJson(req, res, 200, { email: who.email, collection: collection.name });
85
+ sendJson(req, res, 200, { email: who.email, agent: agent.name });
83
86
  return;
84
87
  }
85
- const entries = await deps.connectorNamesByIds(collection.connectorIds);
88
+ const entries = await deps.connectorNamesByIds(agent.connectorIds);
86
89
  const json = relayServersJson(
87
90
  entries,
88
91
  publicBaseOrDefault(),
89
92
  extractToken(req) ?? '',
90
93
  );
91
- sendJson(req, res, 200, { json, collection: collection.name });
94
+ sendJson(req, res, 200, { json, agent: agent.name });
92
95
  }
93
96
 
94
97
  export function handleCliPairRequest(
@@ -116,8 +119,7 @@ export function handleCliPairRequest(
116
119
  ? handleClaim(req, res, deps)
117
120
  : handleRead(req, res, deps, path);
118
121
  work.catch((err: unknown) => {
119
- log.warn({ err: errMsg(err) }, 'cli: unhandled error');
120
- if (!res.headersSent) sendJson(req, res, 500, { error: 'cli api failed' });
122
+ apiFailure(req, res, err, 'cli');
121
123
  });
122
124
  return true;
123
125
  }