@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 +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +6 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +19 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +108 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +6 -0
- package/runtime/runtime.json +1 -1
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.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.
|
|
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,
|
|
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
|
|
package/runtime/runtime.json
CHANGED