@stage-labs/metro 0.1.0-beta.142 → 0.1.0-beta.143
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/store.ts +9 -0
- 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 +2 -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.143",
|
|
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.143",
|
|
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'],
|
|
@@ -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 {
|
|
@@ -226,6 +227,14 @@ export async function localVerifyConnector(subject: string, id: string, dir = ag
|
|
|
226
227
|
}
|
|
227
228
|
}
|
|
228
229
|
|
|
230
|
+
export async function localConnectorTools(subject: string, id: string, dir = agentsDir()): Promise<RemoteTool[]> {
|
|
231
|
+
const row = rowOrThrow(subject, id, dir);
|
|
232
|
+
const url = parseConnectorUrl(row.url);
|
|
233
|
+
const auth = await freshAuth(row.config.auth, url.toString());
|
|
234
|
+
if (auth !== row.config.auth) replace(dir, { ...row, config: { ...row.config, auth } });
|
|
235
|
+
return listRemoteTools(url, auth);
|
|
236
|
+
}
|
|
237
|
+
|
|
229
238
|
export async function localRenameConnector(subject: string, id: string, raw: string, dir = agentsDir()): Promise<Connector> {
|
|
230
239
|
const name = connectorName(raw);
|
|
231
240
|
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,7 @@ import {
|
|
|
27
27
|
localRelayTarget,
|
|
28
28
|
localRenameConnector,
|
|
29
29
|
localVerifyConnector,
|
|
30
|
+
localConnectorTools,
|
|
30
31
|
} from '../connectors/store.js';
|
|
31
32
|
import {
|
|
32
33
|
assertLocalOwner,
|
|
@@ -126,6 +127,7 @@ const connectorApi: ConnectorApiDeps = {
|
|
|
126
127
|
},
|
|
127
128
|
createConnector: localCreateConnector,
|
|
128
129
|
verifyConnector: localVerifyConnector,
|
|
130
|
+
connectorTools: localConnectorTools,
|
|
129
131
|
disconnectConnector: localDisconnectConnector,
|
|
130
132
|
renameConnector: localRenameConnector,
|
|
131
133
|
deleteConnector: localDeleteConnector,
|
package/runtime/runtime.json
CHANGED