@stage-labs/metro 0.1.0-beta.141 → 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.
Files changed (25) hide show
  1. package/dist/api.js +1 -6
  2. package/dist/cli.js +1 -14
  3. package/dist/local.js +0 -13
  4. package/package.json +1 -1
  5. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +2 -2
  6. package/runtime/marketplace/plugin/bin/metro-plugin.mjs +24 -111
  7. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +3 -13
  8. package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +11 -7
  9. package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +2 -20
  10. package/runtime/node_modules/@metro-labs/daemon/src/connectors/health.ts +18 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/connectors/model.ts +1 -9
  12. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +8 -8
  13. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +6 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +11 -7
  15. package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +84 -74
  16. package/runtime/node_modules/@metro-labs/daemon/src/connectors/url.ts +0 -11
  17. package/runtime/node_modules/@metro-labs/daemon/src/connectors/verify.ts +42 -273
  18. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +8 -16
  19. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -3
  20. package/runtime/runtime.json +1 -1
  21. package/dist/plugin.js +0 -120
  22. package/runtime/marketplace/plugin/skills/refresh/SKILL.md +0 -15
  23. package/runtime/node_modules/@metro-labs/daemon/src/connectors/cli-api.ts +0 -63
  24. package/runtime/node_modules/@metro-labs/daemon/src/connectors/json.ts +0 -21
  25. 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, localMcpServers, pickLocalAgent } from './local.js';
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 { mcpServers, whoisAuthorized } from './api.js';
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.141",
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,7 +1,7 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.141",
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, and a refresh skill for when the daemon could not keep the server list current. No vendor credential ever sits in a config file.",
3
+ "version": "0.1.0-beta.143",
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, writeFileSync } from 'node:fs';
2
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
- import { dirname, join } from 'node:path';
5
- import { fileURLToPath } from 'node:url';
4
+ import { join } from 'node:path';
6
5
 
7
- const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url)));
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
- const localUrl = () => `http://127.0.0.1:${Number(process.env.METRO_WEBHOOK_PORT) || 8420}`;
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
- res = await fetch(`${localUrl()}${path}`, { headers: { authorization: `Bearer ${key}` } });
10
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
11
+ return typeof parsed?.key === 'string' ? parsed : null;
62
12
  } catch {
63
- throw new Error(`no metro daemon on ${localUrl()} — start one with: metro serve`);
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
- async function refresh() {
92
- const agent = pickAgent();
93
- const body = await api('/api/cli/mcp', agent.key);
94
- const servers = serverEntries(body.json);
95
- writeFileSync(mcpPath, `${JSON.stringify(servers, null, 2)}\n`);
96
- const names = Object.keys(servers);
97
- process.stdout.write(
98
- `agent '${agent.name}': ${names.length} server(s) written to the plugin\n` +
99
- (names.length ? ` ${names.join('\n ')}\n` : '') +
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
- process.stdout.write(`${JSON.stringify({ Authorization: `Bearer ${agent.key}` })}\n`);
27
+ throw new Error(`no agent on this machine yet (${dir})`);
114
28
  }
115
29
 
116
- const command = process.argv[2];
117
- const run = { refresh, headers: () => Promise.resolve(headers()) }[command];
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
- run().catch((err) => {
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.exit(err.status === 401 ? 2 : 1);
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
- startConnectors();
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,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { log } from '@metro-labs/core/log';
3
+ import { healthOf } from './health.js';
4
+ import type { RemoteTool } from './tools.js';
3
5
  import {
4
6
  apiFailure,
5
7
  apiSession,
@@ -39,6 +41,7 @@ export interface ConnectorApiDeps extends OAuthRouteDeps {
39
41
  input: ConnectorInput,
40
42
  ) => Promise<Connector>;
41
43
  verifyConnector: (subject: string, id: string) => Promise<ConnectorCheck>;
44
+ connectorTools: (subject: string, id: string) => Promise<RemoteTool[]>;
42
45
  disconnectConnector: (subject: string, id: string) => Promise<Connector>;
43
46
  renameConnector: (
44
47
  subject: string,
@@ -53,6 +56,7 @@ type Routable =
53
56
  | { kind: 'callback' }
54
57
  | { kind: 'connector'; id: string }
55
58
  | { kind: 'verify'; id: string }
59
+ | { kind: 'tools'; id: string }
56
60
  | { kind: 'connect'; id: string }
57
61
  | { kind: 'disconnect'; id: string }
58
62
  | { kind: 'rename'; id: string };
@@ -64,6 +68,7 @@ function subTarget(id: string, rest: string[]): Target {
64
68
  if (rest.length > 1) return { kind: 'unknown' };
65
69
  const head = rest[0];
66
70
  if (head === 'verify') return { kind: 'verify', id };
71
+ if (head === 'tools') return { kind: 'tools', id };
67
72
  if (head === 'connect') return { kind: 'connect', id };
68
73
  if (head === 'disconnect') return { kind: 'disconnect', id };
69
74
  if (head === 'rename') return { kind: 'rename', id };
@@ -81,11 +86,7 @@ function target(path: string): Target {
81
86
  return id === null ? { kind: 'unknown' } : subTarget(id, segments.slice(1));
82
87
  }
83
88
 
84
- function connectorPayload(
85
- row: Connector,
86
- detail = false,
87
- ): Record<string, unknown> {
88
- const { catalog, ...summary } = row.verified;
89
+ function connectorPayload(row: Connector): Record<string, unknown> {
89
90
  return {
90
91
  id: row.id,
91
92
  name: row.name,
@@ -95,7 +96,8 @@ function connectorPayload(
95
96
  header: row.header,
96
97
  clientId: row.client?.clientId ?? null,
97
98
  signIn: row.signIn,
98
- verified: detail ? { ...summary, catalog } : summary,
99
+ verified: row.verified,
100
+ health: healthOf(row.id),
99
101
  };
100
102
  }
101
103
 
@@ -208,7 +210,7 @@ async function handleConnector(
208
210
  return;
209
211
  }
210
212
  const row = await deps.getConnector(session.subject, id);
211
- sendJson(req, res, 200, connectorPayload(row, true));
213
+ sendJson(req, res, 200, connectorPayload(row));
212
214
  }
213
215
 
214
216
  async function handleRename(
@@ -239,6 +241,7 @@ async function route(
239
241
  if (tgt.kind === 'callback') return;
240
242
  if (tgt.kind === 'verify')
241
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) });
242
245
  else if (tgt.kind === 'connect')
243
246
  await handleConnect(req, res, deps, session, tgt.id);
244
247
  else if (tgt.kind === 'disconnect')
@@ -259,6 +262,7 @@ const ALLOWED: Record<Routable['kind'], string[]> = {
259
262
  callback: ['GET'],
260
263
  connector: ['GET', 'DELETE'],
261
264
  verify: ['POST'],
265
+ tools: ['GET'],
262
266
  connect: ['POST'],
263
267
  disconnect: ['POST'],
264
268
  rename: ['POST'],
@@ -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
- const catalog = readStoredTools(record.catalog);
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: LocalConnectorRow[], base: string): Record<string, PluginServer> {
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
- const rowsFor = (agents?: string): LocalConnectorRow[] =>
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(rowsFor(opts.agents), opts.base ?? loopbackBase());
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,6 @@
1
1
  import { join } from 'node:path';
2
+ import { syncPluginServers } from './plugin-sync.js';
3
+ import { listRemoteTools, type RemoteTool } from './tools.js';
2
4
  import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
3
5
  import { errMsg, log } from '@metro-labs/core/log';
4
6
  import {
@@ -76,6 +78,7 @@ export function readLocalConnectors(dir = agentsDir()): LocalConnectorRow[] {
76
78
 
77
79
  function writeRows(dir: string, rows: LocalConnectorRow[]): void {
78
80
  writeSecure(filePath(dir), `${JSON.stringify({ version: 1, connectors: rows }, null, 2)}\n`);
81
+ if (dir === agentsDir()) syncPluginServers(rows);
79
82
  }
80
83
 
81
84
  const missing = (): ConnectorError => new ConnectorError('no such connector', 404);
@@ -224,6 +227,14 @@ export async function localVerifyConnector(subject: string, id: string, dir = ag
224
227
  }
225
228
  }
226
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
+
227
238
  export async function localRenameConnector(subject: string, id: string, raw: string, dir = agentsDir()): Promise<Connector> {
228
239
  const name = connectorName(raw);
229
240
  const row = rowOrThrow(subject, id, dir);
@@ -237,13 +248,6 @@ export async function localDeleteConnector(subject: string, id: string, dir = ag
237
248
  return Promise.resolve({ id: row.id, name: row.name });
238
249
  }
239
250
 
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
251
  const inflight = new Map<string, Promise<OAuthAuth>>();
248
252
 
249
253
  function refreshOnce(row: LocalConnectorRow, auth: OAuthAuth, dir: string): Promise<OAuthAuth> {