@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
package/dist/api.js
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
import { localAgents, localDaemonUp,
|
|
1
|
+
import { localAgents, localDaemonUp, pickLocalAgent } from './local.js';
|
|
2
2
|
import { localUrl } from './runtime.js';
|
|
3
3
|
const NO_DAEMON = `no metro daemon on ${localUrl()} — start one with: metro serve`;
|
|
4
|
-
export async function mcpServers(wanted) {
|
|
5
|
-
if (!(await localDaemonUp()))
|
|
6
|
-
throw new Error(NO_DAEMON);
|
|
7
|
-
return localMcpServers(pickLocalAgent(localAgents(), wanted));
|
|
8
|
-
}
|
|
9
4
|
export async function whoisAuthorized(wanted) {
|
|
10
5
|
if (!(await localDaemonUp()))
|
|
11
6
|
throw new Error(NO_DAEMON);
|
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { whoisAuthorized } from './api.js';
|
|
3
3
|
import { stopAll } from './control.js';
|
|
4
4
|
import { tailEvents } from './tail.js';
|
|
5
5
|
import { launchClaude } from './claude.js';
|
|
6
|
-
import { installPlugin } from './plugin.js';
|
|
7
6
|
import { update } from './update.js';
|
|
8
7
|
import { serve } from './serve.js';
|
|
9
8
|
import { service, serviceStopHint } from './service.js';
|
|
@@ -26,9 +25,6 @@ const USAGE = `metro — run your agent on this machine
|
|
|
26
25
|
metro tail <agent-id>
|
|
27
26
|
follow this machine's inbound events, one JSON line each
|
|
28
27
|
metro whoami print the agent this machine runs
|
|
29
|
-
metro mcp print the mcpServers block of the agent's connectors, served through the
|
|
30
|
-
daemon's own relay
|
|
31
|
-
metro plugin set up the Claude Code plugin (connector servers + /metro:refresh)
|
|
32
28
|
metro claude [args...]
|
|
33
29
|
open Claude Code with the metro channel; every argument is passed through
|
|
34
30
|
the same, with inference on Amazon Bedrock through a local proxy so the
|
|
@@ -36,10 +32,6 @@ const USAGE = `metro — run your agent on this machine
|
|
|
36
32
|
metro update update to the newest published version (--check only reports)
|
|
37
33
|
metro version print this CLI's version
|
|
38
34
|
|
|
39
|
-
Start Claude Code with every connector, writing nothing to disk:
|
|
40
|
-
|
|
41
|
-
claude --mcp-config <(metro mcp)
|
|
42
|
-
|
|
43
35
|
METRO_WEBHOOK_PORT the daemon's port (default 8420)
|
|
44
36
|
METRO_AGENTS_DIR where the agents live (default ~/.metro/agents)
|
|
45
37
|
METRO_AGENT_KEY the key metro tail presents, instead of the agent file's
|
|
@@ -72,11 +64,6 @@ const COMMANDS = {
|
|
|
72
64
|
service: () => service(process.argv.slice(3)),
|
|
73
65
|
stop: stopDaemon,
|
|
74
66
|
tail: () => tailEvents(process.argv.slice(3)),
|
|
75
|
-
mcp: async () => {
|
|
76
|
-
process.stdout.write(`${await mcpServers(process.argv[3])}\n`);
|
|
77
|
-
return 0;
|
|
78
|
-
},
|
|
79
|
-
plugin: installPlugin,
|
|
80
67
|
claude: () => launchClaude(process.argv.slice(3)),
|
|
81
68
|
update: () => update(process.argv.slice(3)),
|
|
82
69
|
version: async () => {
|
package/dist/local.js
CHANGED
|
@@ -3,7 +3,6 @@ import { homedir } from 'node:os';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { localUrl } from './runtime.js';
|
|
5
5
|
const PROBE_MS = 3_000;
|
|
6
|
-
const READ_MS = 20_000;
|
|
7
6
|
export function agentsDir() {
|
|
8
7
|
const explicit = process.env.METRO_AGENTS_DIR?.trim();
|
|
9
8
|
return explicit !== undefined && explicit !== '' ? explicit : join(homedir(), '.metro', 'agents');
|
|
@@ -76,15 +75,3 @@ export async function localDaemonUp(base = localUrl()) {
|
|
|
76
75
|
return false;
|
|
77
76
|
}
|
|
78
77
|
}
|
|
79
|
-
export async function localMcpServers(agent, base = localUrl()) {
|
|
80
|
-
const res = await fetch(`${base}/api/cli/mcp`, {
|
|
81
|
-
headers: { authorization: `Bearer ${agent.key}` },
|
|
82
|
-
signal: AbortSignal.timeout(READ_MS),
|
|
83
|
-
});
|
|
84
|
-
if (!res.ok)
|
|
85
|
-
throw new Error(`the local daemon answered ${String(res.status)} for the connectors`);
|
|
86
|
-
const body = (await res.json());
|
|
87
|
-
if (typeof body.json !== 'string')
|
|
88
|
-
throw new Error('the local daemon returned an unexpected response');
|
|
89
|
-
return body.json;
|
|
90
|
-
}
|
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.142",
|
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
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, the standing rules loaded at each session start
|
|
3
|
+
"version": "0.1.0-beta.142",
|
|
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",
|
|
7
7
|
"url": "https://metro.box"
|
|
@@ -1,127 +1,40 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, readdirSync, readFileSync
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import {
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { join } from 'node:path';
|
|
6
5
|
|
|
7
|
-
const
|
|
8
|
-
const mcpPath = join(pluginRoot, '.mcp.json');
|
|
9
|
-
const NAME_PREFIX = 'metro.box ';
|
|
6
|
+
const agentsDir = () => process.env.METRO_AGENTS_DIR || join(homedir(), '.metro', 'agents');
|
|
10
7
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
function agentsDir() {
|
|
14
|
-
const explicit = process.env.METRO_AGENTS_DIR?.trim();
|
|
15
|
-
return explicit ? explicit : join(homedir(), '.metro', 'agents');
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function agentFiles(dir) {
|
|
19
|
-
const fixed = join(dir, 'agent.json');
|
|
20
|
-
if (existsSync(fixed)) return [fixed];
|
|
21
|
-
if (!existsSync(dir)) return [];
|
|
22
|
-
return readdirSync(dir, { withFileTypes: true })
|
|
23
|
-
.filter((entry) => entry.isDirectory())
|
|
24
|
-
.map((entry) => join(dir, entry.name, 'agent.json'))
|
|
25
|
-
.filter((path) => existsSync(path));
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function localAgents() {
|
|
29
|
-
const out = [];
|
|
30
|
-
for (const path of agentFiles(agentsDir())) {
|
|
31
|
-
try {
|
|
32
|
-
const file = JSON.parse(readFileSync(path, 'utf8'));
|
|
33
|
-
if (typeof file.id === 'string' && typeof file.key === 'string')
|
|
34
|
-
out.push({ id: file.id, name: typeof file.name === 'string' ? file.name : 'agent', key: file.key });
|
|
35
|
-
} catch {
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function pickAgent() {
|
|
43
|
-
const agents = localAgents();
|
|
44
|
-
const wanted = process.env.METRO_AGENT?.trim();
|
|
45
|
-
if (wanted) {
|
|
46
|
-
const found = agents.find((a) => a.name === wanted || a.id === wanted);
|
|
47
|
-
if (!found) throw new Error(`no local agent named '${wanted}' in ${agentsDir()}`);
|
|
48
|
-
return found;
|
|
49
|
-
}
|
|
50
|
-
if (agents.length === 1) return agents[0];
|
|
51
|
-
throw new Error(
|
|
52
|
-
agents.length === 0
|
|
53
|
-
? 'no agent on this machine yet — start metro serve, it creates one at boot'
|
|
54
|
-
: `several agents on this machine — set METRO_AGENT to one of: ${agents.map((a) => a.name).join(', ')}`,
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
async function api(path, key) {
|
|
59
|
-
let res;
|
|
8
|
+
function readAgent(path) {
|
|
60
9
|
try {
|
|
61
|
-
|
|
10
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
11
|
+
return typeof parsed?.key === 'string' ? parsed : null;
|
|
62
12
|
} catch {
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
const body = await res.json().catch(() => null);
|
|
66
|
-
if (!res.ok) {
|
|
67
|
-
const detail = typeof body?.error === 'string' ? body.error : `the daemon answered ${res.status}`;
|
|
68
|
-
const err = new Error(detail);
|
|
69
|
-
err.status = res.status;
|
|
70
|
-
throw err;
|
|
71
|
-
}
|
|
72
|
-
return body;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function serverEntries(rawJson) {
|
|
76
|
-
const servers = JSON.parse(rawJson)?.mcpServers;
|
|
77
|
-
if (typeof servers !== 'object' || servers === null)
|
|
78
|
-
throw new Error('the daemon returned an unexpected mcpServers shape');
|
|
79
|
-
const out = {};
|
|
80
|
-
for (const [key, value] of Object.entries(servers)) {
|
|
81
|
-
const name = key.startsWith(NAME_PREFIX) ? key.slice(NAME_PREFIX.length) : key;
|
|
82
|
-
out[name] = {
|
|
83
|
-
type: value.type ?? 'http',
|
|
84
|
-
url: value.url,
|
|
85
|
-
headersHelper: 'node "${CLAUDE_PLUGIN_ROOT}/bin/metro-plugin.mjs" headers',
|
|
86
|
-
};
|
|
13
|
+
return null;
|
|
87
14
|
}
|
|
88
|
-
return out;
|
|
89
15
|
}
|
|
90
16
|
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
'Run /reload-plugins to apply in this session.\n',
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function headers() {
|
|
105
|
-
let agent;
|
|
106
|
-
try {
|
|
107
|
-
agent = pickAgent();
|
|
108
|
-
} catch (err) {
|
|
109
|
-
process.stderr.write(`metro: ${err.message}\n`);
|
|
110
|
-
process.stdout.write('{}\n');
|
|
111
|
-
return;
|
|
17
|
+
function pickAgent() {
|
|
18
|
+
const dir = agentsDir();
|
|
19
|
+
const fixed = readAgent(join(dir, 'agent.json'));
|
|
20
|
+
if (fixed !== null) return fixed;
|
|
21
|
+
if (!existsSync(dir)) throw new Error(`no agent on this machine yet (${dir})`);
|
|
22
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
23
|
+
if (!entry.isDirectory()) continue;
|
|
24
|
+
const found = readAgent(join(dir, entry.name, 'agent.json'));
|
|
25
|
+
if (found !== null) return found;
|
|
112
26
|
}
|
|
113
|
-
|
|
27
|
+
throw new Error(`no agent on this machine yet (${dir})`);
|
|
114
28
|
}
|
|
115
29
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (run === undefined) {
|
|
120
|
-
process.stderr.write('usage: metro-plugin.mjs <refresh|headers>\n');
|
|
30
|
+
if (process.argv[2] !== 'headers') {
|
|
31
|
+
process.stderr.write('usage: metro-plugin.mjs headers\n');
|
|
121
32
|
process.exit(1);
|
|
122
33
|
}
|
|
123
34
|
|
|
124
|
-
|
|
35
|
+
try {
|
|
36
|
+
process.stdout.write(`${JSON.stringify({ Authorization: `Bearer ${pickAgent().key}` })}\n`);
|
|
37
|
+
} catch (err) {
|
|
125
38
|
process.stderr.write(`metro: ${err.message}\n`);
|
|
126
|
-
process.
|
|
127
|
-
}
|
|
39
|
+
process.stdout.write('{}\n');
|
|
40
|
+
}
|
|
@@ -20,8 +20,8 @@ import {
|
|
|
20
20
|
} from '../routes/http.js';
|
|
21
21
|
import { localAgentKey } from '../stations/materialize.js';
|
|
22
22
|
import { agentsDir, fileSource } from '../agents/files.js';
|
|
23
|
-
import { ConnectorWatch } from '../connectors/watch.js';
|
|
24
23
|
import { syncPluginServers } from '../connectors/plugin-sync.js';
|
|
24
|
+
import { readLocalConnectors } from '../connectors/store.js';
|
|
25
25
|
import { ensureMetroPlugin } from '../claude/plugin-install.js';
|
|
26
26
|
import { sessionRunning, stopSession, unwatchSession, watchSession } from '../claude/session.js';
|
|
27
27
|
import { tryClaudeSetup } from '../claude/setup.js';
|
|
@@ -129,15 +129,6 @@ function sessionApis(): SessionApis {
|
|
|
129
129
|
});
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
let connectors: ConnectorWatch | null = null;
|
|
133
|
-
|
|
134
|
-
function startConnectors(): void {
|
|
135
|
-
const watch = new ConnectorWatch(agentsDir(), () => {
|
|
136
|
-
syncPluginServers();
|
|
137
|
-
});
|
|
138
|
-
connectors = watch;
|
|
139
|
-
watch.start();
|
|
140
|
-
}
|
|
141
132
|
|
|
142
133
|
async function main(): Promise<void> {
|
|
143
134
|
applyLocalOwner();
|
|
@@ -154,7 +145,7 @@ installBearerSessions(agentsDir(), localOwner);
|
|
|
154
145
|
metroCall,
|
|
155
146
|
);
|
|
156
147
|
metroMcp.startInbound();
|
|
157
|
-
|
|
148
|
+
syncPluginServers(readLocalConnectors());
|
|
158
149
|
startUploadReaper();
|
|
159
150
|
announceLocalEndpoint();
|
|
160
151
|
tunnel?.start();
|
|
@@ -166,7 +157,7 @@ installBearerSessions(agentsDir(), localOwner);
|
|
|
166
157
|
ensureMetroPlugin()
|
|
167
158
|
.then((outcome) => {
|
|
168
159
|
log.info({ outcome }, 'plugin: metro plugin for Claude Code');
|
|
169
|
-
if (outcome !== 'skipped') syncPluginServers();
|
|
160
|
+
if (outcome !== 'skipped') syncPluginServers(readLocalConnectors());
|
|
170
161
|
})
|
|
171
162
|
.catch((err: unknown) => {
|
|
172
163
|
log.warn({ err: errMsg(err) }, 'plugin: could not ensure the Claude Code plugin');
|
|
@@ -185,7 +176,6 @@ async function shutdown(): Promise<void> {
|
|
|
185
176
|
shuttingDown = true;
|
|
186
177
|
log.info('dispatcher shutting down');
|
|
187
178
|
unwatchSession();
|
|
188
|
-
connectors?.stop();
|
|
189
179
|
tunnel?.stop();
|
|
190
180
|
if (webhookServer) {
|
|
191
181
|
const server = webhookServer;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { log } from '@metro-labs/core/log';
|
|
3
|
+
import { healthOf } from './health.js';
|
|
3
4
|
import {
|
|
4
5
|
apiFailure,
|
|
5
6
|
apiSession,
|
|
@@ -81,11 +82,7 @@ function target(path: string): Target {
|
|
|
81
82
|
return id === null ? { kind: 'unknown' } : subTarget(id, segments.slice(1));
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
function connectorPayload(
|
|
85
|
-
row: Connector,
|
|
86
|
-
detail = false,
|
|
87
|
-
): Record<string, unknown> {
|
|
88
|
-
const { catalog, ...summary } = row.verified;
|
|
85
|
+
function connectorPayload(row: Connector): Record<string, unknown> {
|
|
89
86
|
return {
|
|
90
87
|
id: row.id,
|
|
91
88
|
name: row.name,
|
|
@@ -95,7 +92,8 @@ function connectorPayload(
|
|
|
95
92
|
header: row.header,
|
|
96
93
|
clientId: row.client?.clientId ?? null,
|
|
97
94
|
signIn: row.signIn,
|
|
98
|
-
verified:
|
|
95
|
+
verified: row.verified,
|
|
96
|
+
health: healthOf(row.id),
|
|
99
97
|
};
|
|
100
98
|
}
|
|
101
99
|
|
|
@@ -208,7 +206,7 @@ async function handleConnector(
|
|
|
208
206
|
return;
|
|
209
207
|
}
|
|
210
208
|
const row = await deps.getConnector(session.subject, id);
|
|
211
|
-
sendJson(req, res, 200, connectorPayload(row
|
|
209
|
+
sendJson(req, res, 200, connectorPayload(row));
|
|
212
210
|
}
|
|
213
211
|
|
|
214
212
|
async function handleRename(
|
|
@@ -4,7 +4,6 @@ import type {
|
|
|
4
4
|
VerifiedRecord,
|
|
5
5
|
VerifiedServer,
|
|
6
6
|
} from './verify.js';
|
|
7
|
-
import { readStoredTools } from './tools.js';
|
|
8
7
|
import type { OAuthClient } from './oauth-client.js';
|
|
9
8
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
10
9
|
|
|
@@ -133,16 +132,7 @@ function readAuth(raw: unknown): ConnectorAuth {
|
|
|
133
132
|
|
|
134
133
|
function readVerified(raw: unknown): VerifiedRecord {
|
|
135
134
|
const record = isRecord(raw) ? raw : {};
|
|
136
|
-
|
|
137
|
-
return {
|
|
138
|
-
at: text(record.at),
|
|
139
|
-
server: text(record.server),
|
|
140
|
-
version: text(record.version),
|
|
141
|
-
protocol: text(record.protocol),
|
|
142
|
-
icon: text(record.icon),
|
|
143
|
-
tools: typeof record.tools === 'number' ? record.tools : 0,
|
|
144
|
-
catalog,
|
|
145
|
-
};
|
|
135
|
+
return { at: text(record.at), server: text(record.server) };
|
|
146
136
|
}
|
|
147
137
|
|
|
148
138
|
export function readConfig(raw: unknown): ConnectorConfig {
|
|
@@ -163,13 +153,5 @@ export function signInState(config: ConnectorConfig): ConnectorSignIn {
|
|
|
163
153
|
}
|
|
164
154
|
|
|
165
155
|
export function stamp(server: VerifiedServer): VerifiedRecord {
|
|
166
|
-
return {
|
|
167
|
-
at: new Date().toISOString(),
|
|
168
|
-
server: server.server,
|
|
169
|
-
version: server.version,
|
|
170
|
-
protocol: server.protocol,
|
|
171
|
-
icon: server.icon,
|
|
172
|
-
tools: server.tools,
|
|
173
|
-
catalog: server.catalog,
|
|
174
|
-
};
|
|
156
|
+
return { at: new Date().toISOString(), server: server.server };
|
|
175
157
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface ConnectorHealth {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
reason: string | null;
|
|
4
|
+
at: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const seen = new Map<string, ConnectorHealth>();
|
|
8
|
+
|
|
9
|
+
export function noteHealth(id: string, ok: boolean, reason: string | null = null): void {
|
|
10
|
+
seen.set(id, { ok, reason: ok ? null : reason, at: new Date().toISOString() });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const healthOf = (id: string): ConnectorHealth | null => seen.get(id) ?? null;
|
|
14
|
+
|
|
15
|
+
export function forgetHealth(id?: string): void {
|
|
16
|
+
if (id === undefined) seen.clear();
|
|
17
|
+
else seen.delete(id);
|
|
18
|
+
}
|
|
@@ -66,12 +66,4 @@ export function connectorFromRow(row: ConnectorLike): Connector {
|
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
export const UNVERIFIED = {
|
|
70
|
-
at: '',
|
|
71
|
-
server: '',
|
|
72
|
-
version: '',
|
|
73
|
-
protocol: '',
|
|
74
|
-
icon: '',
|
|
75
|
-
tools: 0,
|
|
76
|
-
catalog: [],
|
|
77
|
-
};
|
|
69
|
+
export const UNVERIFIED = { at: '', server: '' };
|
|
@@ -3,13 +3,17 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
4
|
import { claudeDir } from '../claude/files.js';
|
|
5
5
|
import { loopbackBase } from '../files/attach-serve.js';
|
|
6
|
-
import { readLocalConnectors, type LocalConnectorRow } from './store.js';
|
|
7
6
|
|
|
8
7
|
const MARKER = join('bin', 'metro-plugin.mjs');
|
|
9
8
|
const FILE = '.mcp.json';
|
|
10
9
|
const HELPER = 'node "${CLAUDE_PLUGIN_ROOT}/bin/metro-plugin.mjs" headers';
|
|
11
10
|
const SLUG_MAX = 40;
|
|
12
11
|
|
|
12
|
+
export interface PluginRow {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
13
17
|
export interface PluginServer {
|
|
14
18
|
type: 'http';
|
|
15
19
|
url: string;
|
|
@@ -26,7 +30,7 @@ export function serverKey(name: string): string {
|
|
|
26
30
|
return slug === '' ? 'connector' : slug;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
|
-
export function pluginServers(rows:
|
|
33
|
+
export function pluginServers(rows: PluginRow[], base: string): Record<string, PluginServer> {
|
|
30
34
|
const taken = new Map<string, number>();
|
|
31
35
|
const out: Record<string, PluginServer> = {};
|
|
32
36
|
for (const row of rows) {
|
|
@@ -88,7 +92,6 @@ function writeIfChanged(path: string, text: string): boolean {
|
|
|
88
92
|
export interface PluginSyncOptions {
|
|
89
93
|
dir?: string;
|
|
90
94
|
base?: string;
|
|
91
|
-
agents?: string;
|
|
92
95
|
staged?: string | null;
|
|
93
96
|
}
|
|
94
97
|
|
|
@@ -97,13 +100,10 @@ function filesFor(opts: PluginSyncOptions): string[] {
|
|
|
97
100
|
return installedPluginFiles(opts.dir ?? claudeDir(), staged);
|
|
98
101
|
}
|
|
99
102
|
|
|
100
|
-
|
|
101
|
-
agents === undefined ? readLocalConnectors() : readLocalConnectors(agents);
|
|
102
|
-
|
|
103
|
-
export function syncPluginServers(opts: PluginSyncOptions = {}): number {
|
|
103
|
+
export function syncPluginServers(rows: PluginRow[], opts: PluginSyncOptions = {}): number {
|
|
104
104
|
const files = filesFor(opts);
|
|
105
105
|
if (files.length === 0) return 0;
|
|
106
|
-
const servers = pluginServers(
|
|
106
|
+
const servers = pluginServers(rows, opts.base ?? loopbackBase());
|
|
107
107
|
const text = `${JSON.stringify(servers, null, 2)}\n`;
|
|
108
108
|
let written = 0;
|
|
109
109
|
for (const file of files) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { whyUnreachable } from './reach.js';
|
|
3
|
+
import { noteHealth } from './health.js';
|
|
3
4
|
import type { RelayTarget } from './relay-target.js';
|
|
4
5
|
import type { AgentIdentity } from '@metro-labs/http/api-http';
|
|
5
6
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
@@ -240,14 +241,18 @@ async function relayExchange(
|
|
|
240
241
|
return;
|
|
241
242
|
}
|
|
242
243
|
if (out.kind === 'signin') {
|
|
244
|
+
noteHealth(connectorId, false, 'the sign-in has expired, connect it again');
|
|
243
245
|
signinAnswer(res, connectorId);
|
|
244
246
|
return;
|
|
245
247
|
}
|
|
246
248
|
if (out.upstream.status >= 300 && out.upstream.status < 400) {
|
|
247
249
|
await out.upstream.body?.cancel();
|
|
250
|
+
noteHealth(connectorId, false, 'the connector redirected; metro does not follow');
|
|
248
251
|
answer(res, 502, { error: 'the connector redirected; metro does not follow' });
|
|
249
252
|
return;
|
|
250
253
|
}
|
|
254
|
+
const { status } = out.upstream;
|
|
255
|
+
noteHealth(connectorId, status < 400, status < 400 ? null : `the connector answered ${String(status)}`);
|
|
251
256
|
await pipe(res, out.upstream);
|
|
252
257
|
}
|
|
253
258
|
|
|
@@ -268,6 +273,7 @@ function dispatch(
|
|
|
268
273
|
answer(res, err.status, { error: err.message });
|
|
269
274
|
} else {
|
|
270
275
|
log.warn({ err: errMsg(err), connector: connectorId }, 'relay: failed');
|
|
276
|
+
noteHealth(connectorId, false, `metro could not reach the connector: ${whyUnreachable(err)}`);
|
|
271
277
|
answer(res, 502, { error: `metro could not reach the connector: ${whyUnreachable(err)}` });
|
|
272
278
|
if (!res.writableEnded) res.end();
|
|
273
279
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
+
import { syncPluginServers } from './plugin-sync.js';
|
|
2
3
|
import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
|
|
3
4
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
5
|
import {
|
|
@@ -76,6 +77,7 @@ export function readLocalConnectors(dir = agentsDir()): LocalConnectorRow[] {
|
|
|
76
77
|
|
|
77
78
|
function writeRows(dir: string, rows: LocalConnectorRow[]): void {
|
|
78
79
|
writeSecure(filePath(dir), `${JSON.stringify({ version: 1, connectors: rows }, null, 2)}\n`);
|
|
80
|
+
if (dir === agentsDir()) syncPluginServers(rows);
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
const missing = (): ConnectorError => new ConnectorError('no such connector', 404);
|
|
@@ -237,13 +239,6 @@ export async function localDeleteConnector(subject: string, id: string, dir = ag
|
|
|
237
239
|
return Promise.resolve({ id: row.id, name: row.name });
|
|
238
240
|
}
|
|
239
241
|
|
|
240
|
-
const byIds = (ids: string[], dir: string): LocalConnectorRow[] =>
|
|
241
|
-
readLocalConnectors(dir).filter((r) => ids.includes(r.id));
|
|
242
|
-
|
|
243
|
-
export async function localConnectorNamesByIds(ids: string[], dir = agentsDir()): Promise<{ id: string; name: string }[]> {
|
|
244
|
-
return Promise.resolve(byIds(ids, dir).map((r) => ({ id: r.id, name: r.name })));
|
|
245
|
-
}
|
|
246
|
-
|
|
247
242
|
const inflight = new Map<string, Promise<OAuthAuth>>();
|
|
248
243
|
|
|
249
244
|
function refreshOnce(row: LocalConnectorRow, auth: OAuthAuth, dir: string): Promise<OAuthAuth> {
|
|
@@ -4,8 +4,6 @@ export class ConnectorVerifyError extends ApiError {}
|
|
|
4
4
|
|
|
5
5
|
export class ConnectorUnauthorized extends ConnectorVerifyError {}
|
|
6
6
|
|
|
7
|
-
export class ConnectorNotMcp extends ConnectorVerifyError {}
|
|
8
|
-
|
|
9
7
|
export const POLICY_MESSAGE =
|
|
10
8
|
'Metro connects from its own server, so it cannot reach a URL on your machine. localhost and private addresses are not usable as connectors.';
|
|
11
9
|
|
|
@@ -59,12 +57,3 @@ export function connectorUrlText(url: URL): string {
|
|
|
59
57
|
if (url.pathname !== '/' || url.search !== '') return text;
|
|
60
58
|
return text.endsWith('/') ? text.slice(0, -1) : text;
|
|
61
59
|
}
|
|
62
|
-
|
|
63
|
-
export function safeIconSrc(raw: unknown): string {
|
|
64
|
-
if (typeof raw !== 'string' || raw === '') return '';
|
|
65
|
-
try {
|
|
66
|
-
return new URL(raw).protocol === 'https:' ? raw.slice(0, 500) : '';
|
|
67
|
-
} catch {
|
|
68
|
-
return '';
|
|
69
|
-
}
|
|
70
|
-
}
|