@stage-labs/metro 0.1.0-beta.141 → 0.1.0-beta.142
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/dist/api.js +1 -6
- package/dist/cli.js +1 -14
- package/dist/local.js +0 -13
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +2 -2
- package/runtime/marketplace/plugin/bin/metro-plugin.mjs +24 -111
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +3 -13
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +5 -7
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +2 -20
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/health.ts +18 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/model.ts +1 -9
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +8 -8
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +6 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +2 -7
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/url.ts +0 -11
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/verify.ts +42 -273
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +6 -16
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -3
- package/runtime/runtime.json +1 -1
- package/dist/plugin.js +0 -120
- package/runtime/marketplace/plugin/skills/refresh/SKILL.md +0 -15
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/cli-api.ts +0 -63
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/json.ts +0 -21
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +0 -98
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/watch.ts +0 -54
|
@@ -1,12 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
2
|
import { whyUnreachable } from './reach.js';
|
|
3
|
-
import {
|
|
4
|
-
ConnectorNotMcp,
|
|
5
|
-
ConnectorUnauthorized,
|
|
6
|
-
refused,
|
|
7
|
-
safeIconSrc,
|
|
8
|
-
} from './url.js';
|
|
9
|
-
import { toToolList, type ToolInfo } from './tools.js';
|
|
3
|
+
import { ConnectorUnauthorized, refused } from './url.js';
|
|
10
4
|
|
|
11
5
|
export {
|
|
12
6
|
ConnectorUnauthorized,
|
|
@@ -14,7 +8,6 @@ export {
|
|
|
14
8
|
connectorUrlText,
|
|
15
9
|
parseConnectorUrl,
|
|
16
10
|
} from './url.js';
|
|
17
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
18
11
|
|
|
19
12
|
export interface OAuthTokens {
|
|
20
13
|
accessToken: string;
|
|
@@ -38,295 +31,71 @@ export type ConnectorAuth =
|
|
|
38
31
|
|
|
39
32
|
export interface VerifiedServer {
|
|
40
33
|
server: string;
|
|
41
|
-
version: string;
|
|
42
|
-
protocol: string;
|
|
43
|
-
icon: string;
|
|
44
|
-
tools: number;
|
|
45
|
-
catalog: ToolInfo[];
|
|
46
34
|
}
|
|
47
35
|
|
|
48
36
|
export interface VerifiedRecord extends VerifiedServer {
|
|
49
37
|
at: string;
|
|
50
38
|
}
|
|
51
39
|
|
|
52
|
-
const
|
|
53
|
-
const MAX_TOOL_PAGES = 10;
|
|
54
|
-
|
|
55
|
-
function notMcp(url: URL): ConnectorNotMcp {
|
|
56
|
-
return new ConnectorNotMcp(
|
|
57
|
-
`${url.hostname} answered, but it does not speak MCP.`,
|
|
58
|
-
400,
|
|
59
|
-
);
|
|
60
|
-
}
|
|
40
|
+
const PROTOCOL_VERSION = '2025-11-25';
|
|
61
41
|
const PROBE_TIMEOUT_MS = 10_000;
|
|
62
|
-
const ACCEPT = 'application/json, text/event-stream';
|
|
63
42
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
});
|
|
71
|
-
}
|
|
43
|
+
const INITIALIZE = {
|
|
44
|
+
jsonrpc: '2.0',
|
|
45
|
+
id: 1,
|
|
46
|
+
method: 'initialize',
|
|
47
|
+
params: { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'metro', version: '0.1.0' } },
|
|
48
|
+
};
|
|
72
49
|
|
|
73
|
-
function authHeaders(auth: ConnectorAuth): Record<string, string> {
|
|
50
|
+
export function authHeaders(auth: ConnectorAuth): Record<string, string> {
|
|
74
51
|
if (auth.kind === 'header') return { [auth.name]: auth.value };
|
|
75
|
-
if (auth.kind === 'oauth')
|
|
76
|
-
return { authorization: `Bearer ${auth.accessToken}` };
|
|
52
|
+
if (auth.kind === 'oauth') return { authorization: `Bearer ${auth.accessToken}` };
|
|
77
53
|
return {};
|
|
78
54
|
}
|
|
79
55
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
extra: Record<string, string>,
|
|
84
|
-
body: unknown,
|
|
85
|
-
signal: AbortSignal,
|
|
86
|
-
): Promise<Response> {
|
|
87
|
-
let res: Response;
|
|
88
|
-
try {
|
|
89
|
-
res = await fetch(url, {
|
|
90
|
-
method: 'POST',
|
|
91
|
-
redirect: 'manual',
|
|
92
|
-
signal,
|
|
93
|
-
headers: {
|
|
94
|
-
'content-type': 'application/json',
|
|
95
|
-
accept: ACCEPT,
|
|
96
|
-
...authHeaders(auth),
|
|
97
|
-
...extra,
|
|
98
|
-
},
|
|
99
|
-
body: JSON.stringify(body),
|
|
100
|
-
});
|
|
101
|
-
} catch (err) {
|
|
102
|
-
throw refused(`Metro could not reach ${url.hostname}: ${whyUnreachable(err)}`);
|
|
103
|
-
}
|
|
104
|
-
if (res.status >= 300 && res.status < 400) {
|
|
105
|
-
await discard(res);
|
|
106
|
-
throw refused('that url redirects — use the url it redirects to');
|
|
107
|
-
}
|
|
108
|
-
return res;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function sseData(body: string): string {
|
|
112
|
-
for (const line of body.replace(/\r\n/g, '\n').split('\n'))
|
|
113
|
-
if (line.startsWith('data:')) return line.slice(5).trim();
|
|
56
|
+
function payloadOf(text: string, contentType: string): string {
|
|
57
|
+
if (!contentType.includes('text/event-stream')) return text;
|
|
58
|
+
for (const line of text.replace(/\r\n/g, '\n').split('\n')) if (line.startsWith('data:')) return line.slice(5).trim();
|
|
114
59
|
return '';
|
|
115
60
|
}
|
|
116
61
|
|
|
117
|
-
|
|
118
|
-
const contentType = res.headers.get('content-type') ?? '';
|
|
119
|
-
const text = await res.text().catch(() => '');
|
|
120
|
-
return contentType.includes('text/event-stream') ? sseData(text) : text;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
async function resultOrNull(
|
|
124
|
-
res: Response,
|
|
125
|
-
): Promise<Record<string, unknown> | null> {
|
|
126
|
-
const payload = await payloadOf(res);
|
|
62
|
+
function serverNameOf(url: URL, text: string, contentType: string): string {
|
|
127
63
|
let parsed: unknown;
|
|
128
64
|
try {
|
|
129
|
-
parsed = JSON.parse(
|
|
65
|
+
parsed = JSON.parse(payloadOf(text, contentType));
|
|
130
66
|
} catch {
|
|
131
|
-
|
|
67
|
+
throw refused(`${url.hostname} answered, but it does not speak MCP.`);
|
|
132
68
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function initializeBody(version: string): unknown {
|
|
138
|
-
return {
|
|
139
|
-
jsonrpc: '2.0',
|
|
140
|
-
id: 1,
|
|
141
|
-
method: 'initialize',
|
|
142
|
-
params: {
|
|
143
|
-
protocolVersion: version,
|
|
144
|
-
capabilities: {},
|
|
145
|
-
clientInfo: { name: 'metro', version: '0.1.0' },
|
|
146
|
-
},
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function iconOf(info: Record<string, unknown>): string {
|
|
151
|
-
const icons = info.icons;
|
|
152
|
-
if (!Array.isArray(icons)) return '';
|
|
153
|
-
for (const entry of icons) {
|
|
154
|
-
if (!isRecord(entry)) continue;
|
|
155
|
-
const src = safeIconSrc(entry.src);
|
|
156
|
-
if (src !== '') return src;
|
|
157
|
-
}
|
|
158
|
-
return '';
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function serverFrom(url: URL, result: Record<string, unknown>): VerifiedServer {
|
|
162
|
-
const protocol = result.protocolVersion;
|
|
163
|
-
if (typeof protocol !== 'string') throw notMcp(url);
|
|
69
|
+
const result = isRecord(parsed) && isRecord(parsed.result) ? parsed.result : null;
|
|
70
|
+
if (result === null || typeof result.protocolVersion !== 'string') throw refused(`${url.hostname} answered, but it does not speak MCP.`);
|
|
164
71
|
const info = isRecord(result.serverInfo) ? result.serverInfo : {};
|
|
165
72
|
const title = typeof info.title === 'string' ? info.title : '';
|
|
166
|
-
return
|
|
167
|
-
|
|
168
|
-
title !== ''
|
|
169
|
-
? title
|
|
170
|
-
: typeof info.name === 'string'
|
|
171
|
-
? info.name
|
|
172
|
-
: url.hostname,
|
|
173
|
-
version: typeof info.version === 'string' ? info.version : '',
|
|
174
|
-
protocol,
|
|
175
|
-
icon: iconOf(info),
|
|
176
|
-
tools: 0,
|
|
177
|
-
catalog: [],
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
async function initialize(
|
|
182
|
-
url: URL,
|
|
183
|
-
auth: ConnectorAuth,
|
|
184
|
-
signal: AbortSignal,
|
|
185
|
-
version: string,
|
|
186
|
-
): Promise<{ session: string | null; server: VerifiedServer }> {
|
|
187
|
-
const res = await frame(url, auth, {}, initializeBody(version), signal);
|
|
188
|
-
if (res.status === 401 || res.status === 403) {
|
|
189
|
-
await discard(res);
|
|
190
|
-
throw new ConnectorUnauthorized(
|
|
191
|
-
auth.kind === 'none'
|
|
192
|
-
? `${url.hostname} requires authorization.`
|
|
193
|
-
: `${url.hostname} rejected that credential.`,
|
|
194
|
-
400,
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
if (res.status === 404 || res.status === 405) {
|
|
198
|
-
await discard(res);
|
|
199
|
-
throw refused(
|
|
200
|
-
`${url.hostname} did not accept an MCP initialize over HTTP POST. ` +
|
|
201
|
-
'If it is a legacy SSE server, Metro cannot verify it yet.',
|
|
202
|
-
);
|
|
203
|
-
}
|
|
204
|
-
if (!res.ok) {
|
|
205
|
-
await discard(res);
|
|
206
|
-
throw notMcp(url);
|
|
207
|
-
}
|
|
208
|
-
const result = await resultOrNull(res);
|
|
209
|
-
if (result === null) throw notMcp(url);
|
|
210
|
-
return {
|
|
211
|
-
session: res.headers.get('mcp-session-id'),
|
|
212
|
-
server: serverFrom(url, result),
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function sessionHeaders(
|
|
217
|
-
session: string | null,
|
|
218
|
-
version: string,
|
|
219
|
-
): Record<string, string> {
|
|
220
|
-
const headers: Record<string, string> = {
|
|
221
|
-
'mcp-protocol-version': version,
|
|
222
|
-
};
|
|
223
|
-
if (session !== null && session !== '') headers['mcp-session-id'] = session;
|
|
224
|
-
return headers;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
async function announce(
|
|
228
|
-
url: URL,
|
|
229
|
-
auth: ConnectorAuth,
|
|
230
|
-
headers: Record<string, string>,
|
|
231
|
-
signal: AbortSignal,
|
|
232
|
-
): Promise<void> {
|
|
233
|
-
const res = await frame(
|
|
234
|
-
url,
|
|
235
|
-
auth,
|
|
236
|
-
headers,
|
|
237
|
-
{ jsonrpc: '2.0', method: 'notifications/initialized' },
|
|
238
|
-
signal,
|
|
239
|
-
);
|
|
240
|
-
await discard(res);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
async function toolPage(
|
|
244
|
-
url: URL,
|
|
245
|
-
auth: ConnectorAuth,
|
|
246
|
-
headers: Record<string, string>,
|
|
247
|
-
signal: AbortSignal,
|
|
248
|
-
cursor: string,
|
|
249
|
-
): Promise<Record<string, unknown> | null> {
|
|
250
|
-
const params = cursor === '' ? {} : { cursor };
|
|
251
|
-
const res = await frame(
|
|
252
|
-
url,
|
|
253
|
-
auth,
|
|
254
|
-
headers,
|
|
255
|
-
{ jsonrpc: '2.0', id: 2, method: 'tools/list', params },
|
|
256
|
-
signal,
|
|
257
|
-
);
|
|
258
|
-
if (!res.ok) {
|
|
259
|
-
await discard(res);
|
|
260
|
-
return null;
|
|
261
|
-
}
|
|
262
|
-
return resultOrNull(res);
|
|
73
|
+
if (title !== '') return title;
|
|
74
|
+
return typeof info.name === 'string' ? info.name : url.hostname;
|
|
263
75
|
}
|
|
264
76
|
|
|
265
|
-
async function
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
tools.push(...toToolList(result.tools));
|
|
277
|
-
const next = result.nextCursor;
|
|
278
|
-
if (typeof next !== 'string' || next === '' || next === cursor) break;
|
|
279
|
-
cursor = next;
|
|
77
|
+
async function post(url: URL, auth: ConnectorAuth): Promise<Response> {
|
|
78
|
+
try {
|
|
79
|
+
return await fetch(url, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
redirect: 'manual',
|
|
82
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
83
|
+
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', ...authHeaders(auth) },
|
|
84
|
+
body: JSON.stringify(INITIALIZE),
|
|
85
|
+
});
|
|
86
|
+
} catch (err) {
|
|
87
|
+
throw refused(`Metro could not reach ${url.hostname}: ${whyUnreachable(err)}`);
|
|
280
88
|
}
|
|
281
|
-
return tools;
|
|
282
89
|
}
|
|
283
90
|
|
|
284
|
-
async function
|
|
285
|
-
url
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
headers: { ...authHeaders(auth), ...headers },
|
|
295
|
-
});
|
|
296
|
-
await discard(res);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
async function probeWith(
|
|
300
|
-
url: URL,
|
|
301
|
-
auth: ConnectorAuth,
|
|
302
|
-
version: string,
|
|
303
|
-
): Promise<VerifiedServer> {
|
|
304
|
-
const signal = AbortSignal.timeout(PROBE_TIMEOUT_MS);
|
|
305
|
-
const { session, server } = await initialize(url, auth, signal, version);
|
|
306
|
-
const headers = sessionHeaders(session, server.protocol);
|
|
307
|
-
await announce(url, auth, headers, signal);
|
|
308
|
-
const catalog = await listTools(url, auth, headers, signal);
|
|
309
|
-
await terminate(url, auth, headers, signal).catch((err: unknown) => {
|
|
310
|
-
log.debug(
|
|
311
|
-
{ host: url.hostname, err: errMsg(err) },
|
|
312
|
-
'connector-verify: the remote declined to end the session',
|
|
313
|
-
);
|
|
314
|
-
});
|
|
315
|
-
return { ...server, tools: catalog.length, catalog };
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
export async function verifyRemoteMcp(
|
|
319
|
-
url: URL,
|
|
320
|
-
auth: ConnectorAuth,
|
|
321
|
-
): Promise<VerifiedServer> {
|
|
322
|
-
let last: unknown;
|
|
323
|
-
for (const version of PROTOCOL_VERSIONS) {
|
|
324
|
-
try {
|
|
325
|
-
return await probeWith(url, auth, version);
|
|
326
|
-
} catch (err) {
|
|
327
|
-
if (!(err instanceof ConnectorNotMcp)) throw err;
|
|
328
|
-
last = err;
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
throw last;
|
|
91
|
+
export async function verifyRemoteMcp(url: URL, auth: ConnectorAuth): Promise<VerifiedServer> {
|
|
92
|
+
const res = await post(url, auth);
|
|
93
|
+
const text = await res.text().catch(() => '');
|
|
94
|
+
if (res.status >= 300 && res.status < 400) throw refused('that url redirects — use the url it redirects to');
|
|
95
|
+
if (res.status === 401 || res.status === 403)
|
|
96
|
+
throw new ConnectorUnauthorized(auth.kind === 'none' ? `${url.hostname} requires authorization.` : `${url.hostname} rejected that credential.`, 400);
|
|
97
|
+
if (res.status === 404 || res.status === 405)
|
|
98
|
+
throw refused(`${url.hostname} did not accept an MCP initialize over HTTP POST. If it is a legacy SSE server, Metro cannot verify it yet.`);
|
|
99
|
+
if (!res.ok) throw refused(`${url.hostname} answered ${String(res.status)} instead of an MCP initialize.`);
|
|
100
|
+
return { server: serverNameOf(url, text, res.headers.get('content-type') ?? '') };
|
|
332
101
|
}
|
|
@@ -12,12 +12,11 @@ import { loadedAgentOf, type AgentBundle, type BundleApiDeps } from '../agents/b
|
|
|
12
12
|
import { METRO_VERSION } from '@metro-labs/core/version';
|
|
13
13
|
import type { ConnectorApiDeps } from '../connectors/api.js';
|
|
14
14
|
import { allowLocalConnectors } from '../connectors/url.js';
|
|
15
|
-
import {
|
|
15
|
+
import { authenticate } from '../mcp/request-identity.js';
|
|
16
16
|
import type { RelayApiDeps } from '../connectors/relay.js';
|
|
17
17
|
import {
|
|
18
18
|
readLocalConnectors,
|
|
19
19
|
localImportConnectors,
|
|
20
|
-
localConnectorNamesByIds,
|
|
21
20
|
localCreateConnector,
|
|
22
21
|
localCreatePendingConnector,
|
|
23
22
|
localDeleteConnector,
|
|
@@ -45,7 +44,6 @@ import {
|
|
|
45
44
|
localResetAgentKey,
|
|
46
45
|
readLocalAgentFile,
|
|
47
46
|
} from '../agents/file-admin.js';
|
|
48
|
-
import { listAgentFiles, readAgentFile } from '../agents/files.js';
|
|
49
47
|
import { readModelConfig } from '../gateway/model-config.js';
|
|
50
48
|
import type { StationName } from '@metro-labs/core/station-names';
|
|
51
49
|
|
|
@@ -123,7 +121,7 @@ function connectorIdsOfLocalAgents(ids: string[]): Promise<Map<string, string[]>
|
|
|
123
121
|
const connectorApi: ConnectorApiDeps = {
|
|
124
122
|
listConnectors: async (subject, project) => {
|
|
125
123
|
const rows = await localListConnectors(subject, project);
|
|
126
|
-
syncPluginServers();
|
|
124
|
+
syncPluginServers(readLocalConnectors());
|
|
127
125
|
return rows;
|
|
128
126
|
},
|
|
129
127
|
createConnector: localCreateConnector,
|
|
@@ -138,19 +136,12 @@ const connectorApi: ConnectorApiDeps = {
|
|
|
138
136
|
|
|
139
137
|
const relayApi: RelayApiDeps = {
|
|
140
138
|
target: (connectorId, force) => localRelayTarget(connectorId, force),
|
|
141
|
-
identify:
|
|
139
|
+
identify: (req) => {
|
|
140
|
+
const who = authenticate(req);
|
|
141
|
+
return who?.kind === 'agent' ? { subject: 'agent-key', agentId: who.agentId } : null;
|
|
142
|
+
},
|
|
142
143
|
};
|
|
143
144
|
|
|
144
|
-
function agentNameOf(agentId: string): string | null {
|
|
145
|
-
return listAgentFiles()
|
|
146
|
-
.map((path) => readAgentFile(path))
|
|
147
|
-
.find((file) => file.id === agentId)?.name ?? null;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const localCli: LocalCliDeps = {
|
|
151
|
-
agentName: agentNameOf,
|
|
152
|
-
connectorEntries: () => localConnectorNamesByIds(allConnectorIds()),
|
|
153
|
-
};
|
|
154
145
|
|
|
155
146
|
function bundleApi(deps: LocalModeDeps): BundleApiDeps {
|
|
156
147
|
return {
|
|
@@ -210,7 +201,6 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
210
201
|
bundleApi: bundleApi(deps),
|
|
211
202
|
connectorApi,
|
|
212
203
|
relayApi,
|
|
213
|
-
localCli,
|
|
214
204
|
claudeApi: { authorize: (subject) => { assertLocalOwner(subject); } },
|
|
215
205
|
updateApi: { authorize: (subject) => { assertLocalOwner(subject); }, restart: deps.restart },
|
|
216
206
|
controlApi: { authorize: (subject) => { assertLocalOwner(subject); }, restart: deps.restart, stop: deps.stop },
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
type ConnectorApiDeps,
|
|
7
7
|
} from '../connectors/api.js';
|
|
8
8
|
import type { RelayApiDeps } from '../connectors/relay.js';
|
|
9
|
-
import { handleLocalCliRequest, type LocalCliDeps } from '../connectors/cli-api.js';
|
|
10
9
|
import { handleClaudeRequest, type ClaudeApiDeps } from '../claude/api.js';
|
|
11
10
|
import type { ModeInfo } from '@metro-labs/http/mode-api';
|
|
12
11
|
import { handleBundleRequest, type BundleApiDeps } from '../agents/bundle.js';
|
|
@@ -28,7 +27,6 @@ export interface SessionApis {
|
|
|
28
27
|
modelApi?: ModelApiDeps;
|
|
29
28
|
gateway?: GatewayDeps;
|
|
30
29
|
terminalApi?: TerminalApiDeps;
|
|
31
|
-
localCli?: LocalCliDeps;
|
|
32
30
|
claudeApi?: ClaudeApiDeps;
|
|
33
31
|
connectorApi?: ConnectorApiDeps;
|
|
34
32
|
relayApi?: RelayApiDeps;
|
|
@@ -45,7 +43,6 @@ export function handleSessionApis(
|
|
|
45
43
|
): boolean {
|
|
46
44
|
const routes: (() => boolean)[] = [
|
|
47
45
|
() => handleSessionApiRequest(req, res),
|
|
48
|
-
...when(apis.localCli, (d) => handleLocalCliRequest(req, res, d)),
|
|
49
46
|
...when(apis.connectorApi, (d) => handleConnectorApiRequest(req, res, d)),
|
|
50
47
|
...when(apis.bundleApi, (d) => handleBundleRequest(req, res, d)),
|
|
51
48
|
...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
|
package/runtime/runtime.json
CHANGED
package/dist/plugin.js
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { MARKETPLACE, runtimeStore } from './runtime-install.js';
|
|
6
|
-
const MARKETPLACE_REPO = 'bonustrack/metro';
|
|
7
|
-
const MARKETPLACE_NAME = 'metro';
|
|
8
|
-
const PLUGIN_SPEC = 'metro@metro';
|
|
9
|
-
export function marketplaceSource(store = runtimeStore()) {
|
|
10
|
-
const bundled = join(store, MARKETPLACE);
|
|
11
|
-
return existsSync(join(bundled, '.claude-plugin', 'marketplace.json')) ? bundled : MARKETPLACE_REPO;
|
|
12
|
-
}
|
|
13
|
-
const realish = (path) => {
|
|
14
|
-
try {
|
|
15
|
-
return realpathSync(path);
|
|
16
|
-
}
|
|
17
|
-
catch {
|
|
18
|
-
return path;
|
|
19
|
-
}
|
|
20
|
-
};
|
|
21
|
-
export function registeredMarketplacePath(listJson) {
|
|
22
|
-
let parsed;
|
|
23
|
-
try {
|
|
24
|
-
parsed = JSON.parse(listJson);
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
return undefined;
|
|
28
|
-
}
|
|
29
|
-
if (!Array.isArray(parsed))
|
|
30
|
-
return undefined;
|
|
31
|
-
const mine = parsed.find((m) => typeof m === 'object' && m !== null && m.name === MARKETPLACE_NAME);
|
|
32
|
-
if (mine === undefined)
|
|
33
|
-
return undefined;
|
|
34
|
-
const at = mine.installLocation ?? mine.path;
|
|
35
|
-
return typeof at === 'string' ? at : null;
|
|
36
|
-
}
|
|
37
|
-
export function installPathFrom(raw) {
|
|
38
|
-
let parsed;
|
|
39
|
-
try {
|
|
40
|
-
parsed = JSON.parse(raw);
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
45
|
-
if (typeof parsed !== 'object' || parsed === null)
|
|
46
|
-
return null;
|
|
47
|
-
const plugins = parsed.plugins;
|
|
48
|
-
if (typeof plugins !== 'object' || plugins === null)
|
|
49
|
-
return null;
|
|
50
|
-
const entries = plugins[PLUGIN_SPEC];
|
|
51
|
-
if (!Array.isArray(entries))
|
|
52
|
-
return null;
|
|
53
|
-
const first = entries[0];
|
|
54
|
-
return typeof first?.installPath === 'string' ? first.installPath : null;
|
|
55
|
-
}
|
|
56
|
-
export function pluginInstallPath() {
|
|
57
|
-
const registry = join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
|
|
58
|
-
let raw;
|
|
59
|
-
try {
|
|
60
|
-
raw = readFileSync(registry, 'utf8');
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
const path = installPathFrom(raw);
|
|
66
|
-
if (path === null)
|
|
67
|
-
return null;
|
|
68
|
-
return existsSync(join(path, 'bin', 'metro-plugin.mjs')) ? path : null;
|
|
69
|
-
}
|
|
70
|
-
function runClaude(args) {
|
|
71
|
-
const res = spawnSync('claude', args, { encoding: 'utf8' });
|
|
72
|
-
if (res.error !== undefined)
|
|
73
|
-
throw new Error('the `claude` command is not on PATH — install Claude Code first');
|
|
74
|
-
const output = `${res.stdout ?? ''}${res.stderr ?? ''}`;
|
|
75
|
-
return { ok: res.status === 0, output };
|
|
76
|
-
}
|
|
77
|
-
const tolerable = (output) => /already|exists|latest version/i.test(output);
|
|
78
|
-
export function syncPluginServers() {
|
|
79
|
-
const path = pluginInstallPath();
|
|
80
|
-
if (path === null)
|
|
81
|
-
return false;
|
|
82
|
-
const script = join(path, 'bin', 'metro-plugin.mjs');
|
|
83
|
-
const refreshed = spawnSync(process.execPath, [script, 'refresh'], {
|
|
84
|
-
stdio: ['ignore', 'inherit', 'inherit'],
|
|
85
|
-
});
|
|
86
|
-
if (refreshed.status !== 0)
|
|
87
|
-
return false;
|
|
88
|
-
runClaude(['plugin', 'update', PLUGIN_SPEC]);
|
|
89
|
-
return true;
|
|
90
|
-
}
|
|
91
|
-
function ensureMarketplace(source) {
|
|
92
|
-
const listed = runClaude(['plugin', 'marketplace', 'list', '--json']);
|
|
93
|
-
const at = registeredMarketplacePath(listed.output);
|
|
94
|
-
if (at !== undefined && at !== null && (at === source || realish(at) === realish(source)))
|
|
95
|
-
return { ok: true, output: '' };
|
|
96
|
-
if (at !== undefined)
|
|
97
|
-
runClaude(['plugin', 'marketplace', 'remove', MARKETPLACE_NAME]);
|
|
98
|
-
return runClaude(['plugin', 'marketplace', 'add', source]);
|
|
99
|
-
}
|
|
100
|
-
export async function installPlugin() {
|
|
101
|
-
const source = marketplaceSource();
|
|
102
|
-
const added = ensureMarketplace(source);
|
|
103
|
-
if (!added.ok && !tolerable(added.output)) {
|
|
104
|
-
process.stderr.write(added.output);
|
|
105
|
-
return 1;
|
|
106
|
-
}
|
|
107
|
-
const installed = runClaude(['plugin', 'install', PLUGIN_SPEC, '-y', '--scope', 'user']);
|
|
108
|
-
if (!installed.ok && !tolerable(installed.output)) {
|
|
109
|
-
process.stderr.write(installed.output);
|
|
110
|
-
return 1;
|
|
111
|
-
}
|
|
112
|
-
process.stderr.write(`Claude Code plugin installed from ${source}.\n`);
|
|
113
|
-
if (syncPluginServers()) {
|
|
114
|
-
process.stderr.write('Connector servers loaded — new Claude sessions have them; ' +
|
|
115
|
-
'run /reload-plugins --force in any session already open.\n');
|
|
116
|
-
return 0;
|
|
117
|
-
}
|
|
118
|
-
process.stderr.write('Start the daemon on this machine (metro serve), then run /metro:refresh inside Claude Code.\n');
|
|
119
|
-
return Promise.resolve(0);
|
|
120
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: refresh
|
|
3
|
-
description: Rewrite the plugin's MCP servers from the metro daemon's connector list by hand, for when the daemon could not keep the list current itself.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
The metro daemon writes this plugin's MCP server list itself whenever a connector is added, renamed or removed, so this is the fallback for when it could not: the daemon was not running at the time, or a plugin update reset the file.
|
|
7
|
-
|
|
8
|
-
1. Run with Bash:
|
|
9
|
-
|
|
10
|
-
```
|
|
11
|
-
node "${CLAUDE_PLUGIN_ROOT}/bin/metro-plugin.mjs" refresh
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
2. On success, relay the script's output verbatim and tell the user to run `/reload-plugins --force`, which re-reads the plugin from disk; a plain `/reload-plugins` reuses a cached manifest and keeps the old list. You cannot run that command for them.
|
|
15
|
-
3. If the script says there is no daemon, tell the user to start one with `metro serve` and stop. If it says there is no agent yet, tell them to create or restore one in the web UI the daemon links to.
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
-
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
-
import { cors, sendJson, type AgentIdentity } from '@metro-labs/http/api-http';
|
|
4
|
-
import { loopbackBase } from '../files/attach-serve.js';
|
|
5
|
-
import { relayServersJson, type RelayServerEntry } from './json.js';
|
|
6
|
-
import { agentIdForKey } from '../agents/keys.js';
|
|
7
|
-
|
|
8
|
-
const PATH = '/api/cli/mcp';
|
|
9
|
-
|
|
10
|
-
export interface LocalCliDeps {
|
|
11
|
-
agentName: (agentId: string) => string | null;
|
|
12
|
-
connectorEntries: (agentId: string) => Promise<RelayServerEntry[]>;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function keyOf(req: IncomingMessage): string {
|
|
16
|
-
const header = req.headers.authorization ?? '';
|
|
17
|
-
if (header.toLowerCase().startsWith('bearer ')) return header.slice(7).trim();
|
|
18
|
-
const query = new URLSearchParams((req.url ?? '').split('?')[1] ?? '');
|
|
19
|
-
return query.get('token')?.trim() ?? '';
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function keyIdentity(req: IncomingMessage): AgentIdentity | null {
|
|
23
|
-
const key = keyOf(req);
|
|
24
|
-
const agentId = key === '' ? undefined : agentIdForKey(key);
|
|
25
|
-
return agentId === undefined ? null : { subject: 'agent-key', agentId };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function handleLocalCliRequest(
|
|
29
|
-
req: IncomingMessage,
|
|
30
|
-
res: ServerResponse,
|
|
31
|
-
deps: LocalCliDeps,
|
|
32
|
-
): boolean {
|
|
33
|
-
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
34
|
-
if (path !== PATH && !path.startsWith('/api/cli/')) return false;
|
|
35
|
-
if (req.method === 'OPTIONS') {
|
|
36
|
-
res.writeHead(204, cors(req)).end();
|
|
37
|
-
return true;
|
|
38
|
-
}
|
|
39
|
-
if (path !== PATH) {
|
|
40
|
-
sendJson(req, res, 404, { error: 'not on a local daemon' });
|
|
41
|
-
return true;
|
|
42
|
-
}
|
|
43
|
-
if (req.method !== 'GET') {
|
|
44
|
-
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
45
|
-
return true;
|
|
46
|
-
}
|
|
47
|
-
const who = keyIdentity(req);
|
|
48
|
-
if (who === null) {
|
|
49
|
-
sendJson(req, res, 401, { error: 'unauthorized' });
|
|
50
|
-
return true;
|
|
51
|
-
}
|
|
52
|
-
deps
|
|
53
|
-
.connectorEntries(who.agentId)
|
|
54
|
-
.then((entries) => {
|
|
55
|
-
const agent = deps.agentName(who.agentId) ?? '';
|
|
56
|
-
sendJson(req, res, 200, { json: relayServersJson(entries, loopbackBase(), keyOf(req)), agent });
|
|
57
|
-
})
|
|
58
|
-
.catch((err: unknown) => {
|
|
59
|
-
log.warn({ err: errMsg(err) }, 'local cli: request failed');
|
|
60
|
-
if (!res.headersSent) sendJson(req, res, 500, { error: 'cli failed' });
|
|
61
|
-
});
|
|
62
|
-
return true;
|
|
63
|
-
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
export interface RelayServerEntry {
|
|
2
|
-
id: string;
|
|
3
|
-
name: string;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
export const NAME_PREFIX = 'metro.box ';
|
|
7
|
-
|
|
8
|
-
export function relayServersJson(
|
|
9
|
-
entries: RelayServerEntry[],
|
|
10
|
-
base: string,
|
|
11
|
-
cliToken: string,
|
|
12
|
-
): string {
|
|
13
|
-
const mcpServers: Record<string, unknown> = {};
|
|
14
|
-
for (const entry of entries)
|
|
15
|
-
mcpServers[`${NAME_PREFIX}${entry.name}`] = {
|
|
16
|
-
type: 'http',
|
|
17
|
-
url: `${base}/relay/${entry.id}`,
|
|
18
|
-
headers: { Authorization: `Bearer ${cliToken}` },
|
|
19
|
-
};
|
|
20
|
-
return JSON.stringify({ mcpServers }, null, 2);
|
|
21
|
-
}
|