@stage-labs/metro 0.1.0-beta.39 → 0.1.0-beta.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.39",
3
+ "version": "0.1.0-beta.40",
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": {
@@ -83,6 +83,7 @@ import {
83
83
  deleteConnectorForUser,
84
84
  disconnectConnectorForUser,
85
85
  connectorNamesByIds,
86
+ connectorSummariesByIds,
86
87
  getConnectorForUser,
87
88
  listConnectorsForUser,
88
89
  renameConnectorForUser,
@@ -276,6 +277,7 @@ const projectApi: ProjectApiDeps = {
276
277
  const connectorApi: ConnectorApiDeps = {
277
278
  listConnectors: listConnectorsForUser,
278
279
  connectorNamesByIds,
280
+ connectorSummariesByIds,
279
281
  agentConnectors: agentConnectorsForUser,
280
282
  fenceRuntime,
281
283
  renameConnector: renameConnectorForUser,
@@ -20,11 +20,13 @@ import type { ConnectorApiDeps } from './connector-api.js';
20
20
  const CLAIM_PATH = '/api/cli/claim';
21
21
  const MCP_PATH = '/api/cli/mcp';
22
22
  const SESSION_PATH = '/api/cli/session';
23
+ const CONNECTORS_PATH = '/api/cli/connectors';
23
24
 
24
25
  const ALLOWED: Record<string, string> = {
25
26
  [CLAIM_PATH]: 'POST',
26
27
  [MCP_PATH]: 'GET',
27
28
  [SESSION_PATH]: 'GET',
29
+ [CONNECTORS_PATH]: 'GET',
28
30
  };
29
31
 
30
32
  function secretOrNull(): string | null {
@@ -94,6 +96,13 @@ async function handleRead(
94
96
  });
95
97
  return;
96
98
  }
99
+ if (path === CONNECTORS_PATH) {
100
+ sendJson(req, res, 200, {
101
+ agent: agent.name,
102
+ connectors: await deps.connectorSummariesByIds(agent.connectorIds),
103
+ });
104
+ return;
105
+ }
97
106
  const entries = await deps.connectorNamesByIds(agent.connectorIds);
98
107
  const json = relayServersJson(
99
108
  entries,
@@ -1,3 +1,4 @@
1
+ import type { ConnectorSummary } from '../db/connectors.js';
1
2
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
3
  import { errMsg, log } from './log.js';
3
4
  import {
@@ -36,6 +37,7 @@ const asText = (value: unknown): string =>
36
37
 
37
38
  export interface ConnectorApiDeps extends OAuthRouteDeps {
38
39
  listConnectors: (subject: string, project: string) => Promise<Connector[]>;
40
+ connectorSummariesByIds: (ids: string[]) => Promise<ConnectorSummary[]>;
39
41
  connectorNamesByIds: (ids: string[]) => Promise<RelayServerEntry[]>;
40
42
  agentConnectors: (subject: string, agentId: string) => Promise<AgentConnectors>;
41
43
  fenceRuntime: Fence;
@@ -0,0 +1,91 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { errMsg, log } from './log.js';
4
+ import { configDir } from './paths.js';
5
+ import { metroBaseUrl } from './agent-import.js';
6
+
7
+ const FETCH_TIMEOUT_MS = 15_000;
8
+ const CACHE_MS = 60_000;
9
+
10
+ export interface HostedConnector {
11
+ id: string;
12
+ name: string;
13
+ url: string;
14
+ transport: string;
15
+ signIn: 'connected' | 'disconnected' | null;
16
+ }
17
+
18
+ export interface HostedCredential {
19
+ token: string;
20
+ url: string;
21
+ }
22
+
23
+ function readJsonFile(path: string): Record<string, unknown> | null {
24
+ try {
25
+ const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
26
+ return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function agentOfToken(token: string): string | null {
33
+ const body = token.split('.')[1];
34
+ if (body === undefined) return null;
35
+ try {
36
+ const claims = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as { agent?: unknown };
37
+ return typeof claims.agent === 'string' ? claims.agent : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function credential(file: Record<string, unknown> | null): HostedCredential | null {
44
+ if (file === null || typeof file.token !== 'string' || file.token === '') return null;
45
+ return { token: file.token, url: typeof file.url === 'string' && file.url !== '' ? file.url : metroBaseUrl() };
46
+ }
47
+
48
+ export function hostedCredentialFor(agentId: string, dir = configDir()): HostedCredential | null {
49
+ const runtime = join(dir, `runtime-${agentId}.json`);
50
+ if (existsSync(runtime)) return credential(readJsonFile(runtime));
51
+ const login = credential(readJsonFile(join(dir, 'credentials.json')));
52
+ return login !== null && agentOfToken(login.token) === agentId ? login : null;
53
+ }
54
+
55
+ const isConnector = (v: unknown): v is HostedConnector =>
56
+ typeof v === 'object' &&
57
+ v !== null &&
58
+ typeof (v as { id?: unknown }).id === 'string' &&
59
+ typeof (v as { name?: unknown }).name === 'string';
60
+
61
+ async function fetchHosted(cred: HostedCredential): Promise<HostedConnector[]> {
62
+ const res = await fetch(`${cred.url}/api/cli/connectors`, {
63
+ headers: { authorization: `Bearer ${cred.token}` },
64
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
65
+ redirect: 'manual',
66
+ });
67
+ if (!res.ok) throw new Error(`metro answered ${String(res.status)} for the connectors`);
68
+ const body = (await res.json()) as { connectors?: unknown };
69
+ return Array.isArray(body.connectors) ? body.connectors.filter(isConnector) : [];
70
+ }
71
+
72
+ const cache = new Map<string, { at: number; list: HostedConnector[] }>();
73
+
74
+ export async function hostedConnectorsFor(agentId: string, dir = configDir()): Promise<HostedConnector[]> {
75
+ const hit = cache.get(agentId);
76
+ if (hit !== undefined && Date.now() - hit.at < CACHE_MS) return hit.list;
77
+ const cred = hostedCredentialFor(agentId, dir);
78
+ if (cred === null) return [];
79
+ try {
80
+ const list = await fetchHosted(cred);
81
+ cache.set(agentId, { at: Date.now(), list });
82
+ return list;
83
+ } catch (err) {
84
+ log.warn({ agent: agentId, err: errMsg(err) }, 'hosted connectors: unavailable, listing none');
85
+ return [];
86
+ }
87
+ }
88
+
89
+ export function forgetHostedConnectors(): void {
90
+ cache.clear();
91
+ }
@@ -0,0 +1,56 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { errMsg, log } from './log.js';
3
+ import { apiSession, cors, sendJson } from './api-http.js';
4
+ import type { HostedConnector } from './hosted-connectors.js';
5
+
6
+ const PATH = '/api/connectors';
7
+
8
+ export interface LocalConnectorsDeps {
9
+ listConnectors: (subject: string) => Promise<HostedConnector[]>;
10
+ }
11
+
12
+ const payload = (c: HostedConnector): Record<string, unknown> => ({
13
+ id: c.id,
14
+ name: c.name,
15
+ url: c.url,
16
+ transport: c.transport,
17
+ auth: c.signIn === null ? 'none' : 'oauth',
18
+ header: null,
19
+ signIn: c.signIn,
20
+ verified: null,
21
+ managed: 'metro.box',
22
+ });
23
+
24
+ export function handleLocalConnectorsRequest(
25
+ req: IncomingMessage,
26
+ res: ServerResponse,
27
+ deps: LocalConnectorsDeps,
28
+ ): boolean {
29
+ const path = (req.url ?? '').split('?')[0] ?? '';
30
+ if (path !== PATH && !path.startsWith(`${PATH}/`)) return false;
31
+ if (req.method === 'OPTIONS') {
32
+ res.writeHead(204, cors(req)).end();
33
+ return true;
34
+ }
35
+ if (path !== PATH || req.method !== 'GET') {
36
+ sendJson(req, res, path === PATH ? 405 : 404, {
37
+ error: 'connectors are managed on metro.box, not on a local daemon',
38
+ });
39
+ return true;
40
+ }
41
+ const session = apiSession(req);
42
+ if (!session) {
43
+ sendJson(req, res, 401, { error: 'unauthorized' });
44
+ return true;
45
+ }
46
+ deps
47
+ .listConnectors(session.subject)
48
+ .then((list) => {
49
+ sendJson(req, res, 200, { connectors: list.map(payload) });
50
+ })
51
+ .catch((err: unknown) => {
52
+ log.warn({ err: errMsg(err) }, 'local connectors: request failed');
53
+ if (!res.headersSent) sendJson(req, res, 500, { error: 'connectors failed' });
54
+ });
55
+ return true;
56
+ }
@@ -10,6 +10,7 @@ import type { SessionApis } from './session-apis.js';
10
10
  import type { ModeInfo } from './mode-api.js';
11
11
  import type { ImportApiDeps } from './import-api.js';
12
12
  import { fetchAgentWithCode } from './agent-import.js';
13
+ import { forgetHostedConnectors, hostedConnectorsFor, type HostedConnector } from './hosted-connectors.js';
13
14
  import {
14
15
  assertLocalOwner,
15
16
  claimLocalOwner,
@@ -93,7 +94,7 @@ function agentApi(deps: LocalModeDeps): AgentApiDeps {
93
94
  liveness: deps.liveness,
94
95
  releaseRuntime: () => notHere('runtime leases'),
95
96
  runtimes: () => Promise.resolve(new Map()),
96
- connectorIds: () => Promise.resolve(new Map()),
97
+ connectorIds: connectorIdsOfLocalAgents,
97
98
  prepareAccount: deps.prepareAccount,
98
99
  attachAccount: localAttachAccount,
99
100
  detachAccount: localDetachAccount,
@@ -123,10 +124,25 @@ const projectApi: ProjectApiDeps = {
123
124
  removeMember: () => notHere('members'),
124
125
  };
125
126
 
127
+ async function connectorIdsOfLocalAgents(ids: string[]): Promise<Map<string, string[]>> {
128
+ const pairs = await Promise.all(
129
+ ids.map(async (id) => [id, (await hostedConnectorsFor(id)).map((c) => c.id)] as const),
130
+ );
131
+ return new Map(pairs);
132
+ }
133
+
134
+ async function connectorsOfOwner(subject: string): Promise<HostedConnector[]> {
135
+ const agents = await localListAgents(subject, LOCAL_PROJECT_ID).catch(() => []);
136
+ const lists = await Promise.all(agents.map((a) => hostedConnectorsFor(a.id)));
137
+ const seen = new Map<string, HostedConnector>();
138
+ for (const c of lists.flat()) seen.set(c.id, c);
139
+ return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
140
+ }
141
+
126
142
  const agentConnectorApi: AgentConnectorApiDeps = {
127
143
  agentConnectors: async (subject, id) => {
128
144
  const { agent } = await localOwnedAgentOrThrow(subject, id);
129
- return { ...agent, connectorIds: [] };
145
+ return { ...agent, connectorIds: (await hostedConnectorsFor(id)).map((c) => c.id) };
130
146
  },
131
147
  addConnector: () => notHere('connectors'),
132
148
  removeConnector: () => notHere('connectors'),
@@ -141,6 +157,7 @@ function importApi(deps: LocalModeDeps): ImportApiDeps {
141
157
  assertLocalOwner(subject);
142
158
  const agent = await fetchAgent(code, hostname());
143
159
  const made = await localImportAgent(subject, agent);
160
+ forgetHostedConnectors();
144
161
  for (const station of new Set(agent.accounts.map((a) => a.station)))
145
162
  await deps.syncStations(station).catch((err: unknown) => {
146
163
  log.warn(
@@ -162,6 +179,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
162
179
  agentApi: agentApi(deps),
163
180
  agentConnectorApi,
164
181
  importApi: importApi(deps),
182
+ localConnectors: { listConnectors: connectorsOfOwner },
165
183
  projectApi,
166
184
  siwe: { ensureUser: claimLocalOwner },
167
185
  mode: localModeInfo,
@@ -18,12 +18,17 @@ import {
18
18
  } from './agent-connector-api.js';
19
19
  import type { SiweRouteDeps } from './siwe-routes.js';
20
20
  import { handleImportRequest, type ImportApiDeps } from './import-api.js';
21
+ import {
22
+ handleLocalConnectorsRequest,
23
+ type LocalConnectorsDeps,
24
+ } from './local-connectors-api.js';
21
25
  import type { ModeInfo } from './mode-api.js';
22
26
 
23
27
  export interface SessionApis {
24
28
  agentApi?: AgentApiDeps;
25
29
  agentConnectorApi?: AgentConnectorApiDeps;
26
30
  importApi?: ImportApiDeps;
31
+ localConnectors?: LocalConnectorsDeps;
27
32
  connectorApi?: ConnectorApiDeps;
28
33
  projectApi?: ProjectApiDeps;
29
34
  runApi?: RunApiDeps;
@@ -38,6 +43,7 @@ export function handleSessionApis(
38
43
  apis: SessionApis,
39
44
  ): boolean {
40
45
  const { agentApi, agentConnectorApi, connectorApi, projectApi, runApi, importApi } = apis;
46
+ const { localConnectors } = apis;
41
47
  const routes: (() => boolean)[] = [() => handleSessionApiRequest(req, res)];
42
48
  if (runApi) routes.push(() => handleRunApiRequest(req, res, runApi));
43
49
  if (projectApi)
@@ -48,6 +54,8 @@ export function handleSessionApis(
48
54
  () => handleConnectorApiRequest(req, res, connectorApi),
49
55
  );
50
56
  if (importApi) routes.push(() => handleImportRequest(req, res, importApi));
57
+ if (localConnectors)
58
+ routes.push(() => handleLocalConnectorsRequest(req, res, localConnectors));
51
59
  if (agentConnectorApi)
52
60
  routes.push(() => handleAgentConnectorRequest(req, res, agentConnectorApi));
53
61
  if (agentApi) routes.push(() => handleAgentApiRequest(req, res, agentApi));
@@ -139,6 +139,24 @@ async function rowsByIds(ids: string[]): Promise<ConnectorRow[]> {
139
139
  .orderBy(asc(connectors.id));
140
140
  }
141
141
 
142
+ export interface ConnectorSummary {
143
+ id: string;
144
+ name: string;
145
+ url: string;
146
+ transport: string;
147
+ signIn: ConnectorSignIn;
148
+ }
149
+
150
+ export async function connectorSummariesByIds(ids: string[]): Promise<ConnectorSummary[]> {
151
+ return (await rowsByIds(ids)).map((row) => ({
152
+ id: row.id,
153
+ name: row.name,
154
+ url: row.url,
155
+ transport: row.transport,
156
+ signIn: signInState(readConfig(row.config)),
157
+ }));
158
+ }
159
+
142
160
  export async function connectorNamesByIds(
143
161
  ids: string[],
144
162
  ): Promise<{ id: string; name: string }[]> {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.39"
2
+ "version": "0.1.0-beta.40"
3
3
  }