@stage-labs/metro 0.1.0-beta.142 → 0.1.0-beta.144

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.142",
3
+ "version": "0.1.0-beta.144",
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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.142",
3
+ "version": "0.1.0-beta.144",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { log } from '@metro-labs/core/log';
3
3
  import { healthOf } from './health.js';
4
+ import type { RemoteTool } from './tools.js';
4
5
  import {
5
6
  apiFailure,
6
7
  apiSession,
@@ -40,6 +41,7 @@ export interface ConnectorApiDeps extends OAuthRouteDeps {
40
41
  input: ConnectorInput,
41
42
  ) => Promise<Connector>;
42
43
  verifyConnector: (subject: string, id: string) => Promise<ConnectorCheck>;
44
+ connectorTools: (subject: string, id: string) => Promise<RemoteTool[]>;
43
45
  disconnectConnector: (subject: string, id: string) => Promise<Connector>;
44
46
  renameConnector: (
45
47
  subject: string,
@@ -54,6 +56,7 @@ type Routable =
54
56
  | { kind: 'callback' }
55
57
  | { kind: 'connector'; id: string }
56
58
  | { kind: 'verify'; id: string }
59
+ | { kind: 'tools'; id: string }
57
60
  | { kind: 'connect'; id: string }
58
61
  | { kind: 'disconnect'; id: string }
59
62
  | { kind: 'rename'; id: string };
@@ -65,6 +68,7 @@ function subTarget(id: string, rest: string[]): Target {
65
68
  if (rest.length > 1) return { kind: 'unknown' };
66
69
  const head = rest[0];
67
70
  if (head === 'verify') return { kind: 'verify', id };
71
+ if (head === 'tools') return { kind: 'tools', id };
68
72
  if (head === 'connect') return { kind: 'connect', id };
69
73
  if (head === 'disconnect') return { kind: 'disconnect', id };
70
74
  if (head === 'rename') return { kind: 'rename', id };
@@ -237,6 +241,7 @@ async function route(
237
241
  if (tgt.kind === 'callback') return;
238
242
  if (tgt.kind === 'verify')
239
243
  await handleVerify(req, res, deps, session, tgt.id);
244
+ else if (tgt.kind === 'tools') sendJson(req, res, 200, { tools: await deps.connectorTools(session.subject, tgt.id) });
240
245
  else if (tgt.kind === 'connect')
241
246
  await handleConnect(req, res, deps, session, tgt.id);
242
247
  else if (tgt.kind === 'disconnect')
@@ -257,6 +262,7 @@ const ALLOWED: Record<Routable['kind'], string[]> = {
257
262
  callback: ['GET'],
258
263
  connector: ['GET', 'DELETE'],
259
264
  verify: ['POST'],
265
+ tools: ['GET'],
260
266
  connect: ['POST'],
261
267
  disconnect: ['POST'],
262
268
  rename: ['POST'],
@@ -9,6 +9,7 @@ import { errMsg, log } from '@metro-labs/core/log';
9
9
  export interface RelayApiDeps {
10
10
  target: (connectorId: string, force: boolean) => Promise<RelayTarget>;
11
11
  identify: (req: IncomingMessage) => AgentIdentity | null;
12
+ signedOut: (connectorId: string) => void;
12
13
  }
13
14
 
14
15
  const ID_PATH_RE = /^\/relay\/([A-Za-z0-9][A-Za-z0-9_-]{10})$/;
@@ -242,6 +243,7 @@ async function relayExchange(
242
243
  }
243
244
  if (out.kind === 'signin') {
244
245
  noteHealth(connectorId, false, 'the sign-in has expired, connect it again');
246
+ deps.signedOut(connectorId);
245
247
  signinAnswer(res, connectorId);
246
248
  return;
247
249
  }
@@ -1,5 +1,6 @@
1
1
  import { join } from 'node:path';
2
2
  import { syncPluginServers } from './plugin-sync.js';
3
+ import { listRemoteTools, type RemoteTool } from './tools.js';
3
4
  import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
4
5
  import { errMsg, log } from '@metro-labs/core/log';
5
6
  import {
@@ -200,10 +201,19 @@ export async function localReconnectConnector(
200
201
  return replace(dir, { ...row, config: { ...row.config, auth, verified, oauth: true } });
201
202
  }
202
203
 
204
+ const signedOut = (row: LocalConnectorRow): LocalConnectorRow => ({ ...row, config: { ...row.config, auth: { kind: 'none' }, oauth: true } });
205
+
203
206
  export async function localDisconnectConnector(subject: string, id: string, dir = agentsDir()): Promise<Connector> {
204
207
  const row = rowOrThrow(subject, id, dir);
205
208
  if (row.config.auth.kind !== 'oauth') throw new ConnectorError('that connector is not signed in', 400);
206
- return Promise.resolve(replace(dir, { ...row, config: { ...row.config, auth: { kind: 'none' }, oauth: true } }));
209
+ return Promise.resolve(replace(dir, signedOut(row)));
210
+ }
211
+
212
+ export function localMarkSignedOut(id: string, dir = agentsDir()): void {
213
+ const row = readLocalConnectors(dir).find((r) => r.id === id);
214
+ if (row?.config.auth.kind !== 'oauth') return;
215
+ log.info({ id, name: row.name }, 'local relay: the vendor refused the token after a refresh; the connector is signed out');
216
+ replace(dir, signedOut(row));
207
217
  }
208
218
 
209
219
  async function freshAuth(auth: ConnectorAuth, resource: string): Promise<ConnectorAuth> {
@@ -226,6 +236,14 @@ export async function localVerifyConnector(subject: string, id: string, dir = ag
226
236
  }
227
237
  }
228
238
 
239
+ export async function localConnectorTools(subject: string, id: string, dir = agentsDir()): Promise<RemoteTool[]> {
240
+ const row = rowOrThrow(subject, id, dir);
241
+ const url = parseConnectorUrl(row.url);
242
+ const auth = await freshAuth(row.config.auth, url.toString());
243
+ if (auth !== row.config.auth) replace(dir, { ...row, config: { ...row.config, auth } });
244
+ return listRemoteTools(url, auth);
245
+ }
246
+
229
247
  export async function localRenameConnector(subject: string, id: string, raw: string, dir = agentsDir()): Promise<Connector> {
230
248
  const name = connectorName(raw);
231
249
  const row = rowOrThrow(subject, id, dir);
@@ -0,0 +1,108 @@
1
+ import { isRecord } from '@metro-labs/core/is-record';
2
+ import { errMsg, log } from '@metro-labs/core/log';
3
+ import { whyUnreachable } from './reach.js';
4
+ import { refused } from './url.js';
5
+ import { authHeaders, type ConnectorAuth } from './verify.js';
6
+
7
+ export interface RemoteTool {
8
+ name: string;
9
+ description: string;
10
+ readOnly: boolean;
11
+ }
12
+
13
+ const TIMEOUT_MS = 15_000;
14
+ const MAX_PAGES = 10;
15
+ const ACCEPT = 'application/json, text/event-stream';
16
+
17
+ function dataOf(text: string, contentType: string): string {
18
+ if (!contentType.includes('text/event-stream')) return text;
19
+ for (const line of text.replace(/\r\n/g, '\n').split('\n')) if (line.startsWith('data:')) return line.slice(5).trim();
20
+ return '';
21
+ }
22
+
23
+ async function post(url: URL, auth: ConnectorAuth, session: Map<string, string>, body: unknown): Promise<Response> {
24
+ try {
25
+ return await fetch(url, {
26
+ method: 'POST',
27
+ redirect: 'manual',
28
+ signal: AbortSignal.timeout(TIMEOUT_MS),
29
+ headers: { 'content-type': 'application/json', accept: ACCEPT, ...authHeaders(auth), ...Object.fromEntries(session) },
30
+ body: JSON.stringify(body),
31
+ });
32
+ } catch (err) {
33
+ throw refused(`Metro could not reach ${url.hostname}: ${whyUnreachable(err)}`);
34
+ }
35
+ }
36
+
37
+ function resultOf(text: string, contentType: string): Record<string, unknown> | null {
38
+ let parsed: unknown = null;
39
+ try {
40
+ parsed = JSON.parse(dataOf(text, contentType));
41
+ } catch {
42
+ return null;
43
+ }
44
+ return isRecord(parsed) && isRecord(parsed.result) ? parsed.result : null;
45
+ }
46
+
47
+ async function rpc(url: URL, auth: ConnectorAuth, session: Map<string, string>, body: unknown): Promise<Record<string, unknown> | null> {
48
+ const res = await post(url, auth, session, body);
49
+ const id = res.headers.get('mcp-session-id');
50
+ if (id !== null && id !== '') session.set('mcp-session-id', id);
51
+ const text = await res.text().catch(() => '');
52
+ if (res.status === 401 || res.status === 403) throw refused(`${url.hostname} rejected the credential; connect it again`);
53
+ if (!res.ok) throw refused(`${url.hostname} answered ${String(res.status)}`);
54
+ return resultOf(text, res.headers.get('content-type') ?? '');
55
+ }
56
+
57
+ function toTool(raw: unknown): RemoteTool | null {
58
+ if (!isRecord(raw) || typeof raw.name !== 'string') return null;
59
+ const annotations = isRecord(raw.annotations) ? raw.annotations : {};
60
+ const title = typeof raw.title === 'string' && raw.title !== '' ? raw.title : raw.name;
61
+ return { name: title, description: typeof raw.description === 'string' ? raw.description : '', readOnly: annotations.readOnlyHint === true };
62
+ }
63
+
64
+ async function endSession(url: URL, auth: ConnectorAuth, session: Map<string, string>): Promise<void> {
65
+ if (!session.has('mcp-session-id')) return;
66
+ try {
67
+ const res = await fetch(url, { method: 'DELETE', redirect: 'manual', signal: AbortSignal.timeout(TIMEOUT_MS), headers: { ...authHeaders(auth), ...Object.fromEntries(session) } });
68
+ await res.body?.cancel();
69
+ } catch (err) {
70
+ log.debug({ host: url.hostname, err: errMsg(err) }, 'connector-tools: the remote declined to end the session');
71
+ }
72
+ }
73
+
74
+ const toolsOf = (result: Record<string, unknown>): RemoteTool[] =>
75
+ Array.isArray(result.tools) ? result.tools.flatMap((raw: unknown) => toTool(raw) ?? []) : [];
76
+
77
+ function nextCursor(result: Record<string, unknown>, current: string): string | null {
78
+ const next = result.nextCursor;
79
+ return typeof next !== 'string' || next === '' || next === current ? null : next;
80
+ }
81
+
82
+ async function walkTools(url: URL, auth: ConnectorAuth, session: Map<string, string>): Promise<RemoteTool[]> {
83
+ const tools: RemoteTool[] = [];
84
+ let cursor: string | null = '';
85
+ for (let page = 0; page < MAX_PAGES && cursor !== null; page += 1) {
86
+ const result = await rpc(url, auth, session, { jsonrpc: '2.0', id: 2 + page, method: 'tools/list', params: cursor === '' ? {} : { cursor } });
87
+ if (result === null) break;
88
+ tools.push(...toolsOf(result));
89
+ cursor = nextCursor(result, cursor);
90
+ }
91
+ return tools;
92
+ }
93
+
94
+ export async function listRemoteTools(url: URL, auth: ConnectorAuth): Promise<RemoteTool[]> {
95
+ const session = new Map<string, string>();
96
+ const init = await rpc(url, auth, session, {
97
+ jsonrpc: '2.0',
98
+ id: 1,
99
+ method: 'initialize',
100
+ params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'metro', version: '0.1.0' } },
101
+ });
102
+ if (init === null || typeof init.protocolVersion !== 'string') throw refused(`${url.hostname} answered, but it does not speak MCP.`);
103
+ session.set('mcp-protocol-version', init.protocolVersion);
104
+ await rpc(url, auth, session, { jsonrpc: '2.0', method: 'notifications/initialized' });
105
+ const tools = await walkTools(url, auth, session);
106
+ await endSession(url, auth, session);
107
+ return tools;
108
+ }
@@ -27,6 +27,8 @@ import {
27
27
  localRelayTarget,
28
28
  localRenameConnector,
29
29
  localVerifyConnector,
30
+ localMarkSignedOut,
31
+ localConnectorTools,
30
32
  } from '../connectors/store.js';
31
33
  import {
32
34
  assertLocalOwner,
@@ -126,6 +128,7 @@ const connectorApi: ConnectorApiDeps = {
126
128
  },
127
129
  createConnector: localCreateConnector,
128
130
  verifyConnector: localVerifyConnector,
131
+ connectorTools: localConnectorTools,
129
132
  disconnectConnector: localDisconnectConnector,
130
133
  renameConnector: localRenameConnector,
131
134
  deleteConnector: localDeleteConnector,
@@ -140,6 +143,9 @@ const relayApi: RelayApiDeps = {
140
143
  const who = authenticate(req);
141
144
  return who?.kind === 'agent' ? { subject: 'agent-key', agentId: who.agentId } : null;
142
145
  },
146
+ signedOut: (id) => {
147
+ localMarkSignedOut(id);
148
+ },
143
149
  };
144
150
 
145
151
 
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.142"
2
+ "version": "0.1.0-beta.144"
3
3
  }