@stage-labs/metro 0.1.0-beta.87 → 0.1.0-beta.88

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.87",
3
+ "version": "0.1.0-beta.88",
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": {
@@ -20,9 +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 { ConnectorAggregate } from '../connectors/aggregate.js';
24
- import { setConnectorToolProvider } from '../mcp/connector-tools.js';
25
- import { invalidateToolSchema } from '../mcp/tool-dispatch.js';
23
+ import { ConnectorWatch } from '../connectors/watch.js';
24
+ import { syncPluginServers } from '../connectors/plugin-sync.js';
26
25
  import { applyLocalOwner } from './local-owner.js';
27
26
  import { localOwner } from '../agents/file-admin.js';
28
27
  import { ensureStationDeps } from '../stations/runtime-deps.js';
@@ -32,7 +31,6 @@ import {
32
31
  agentLiveness,
33
32
  closeAgentSession,
34
33
  createMetroMcp,
35
- announceToolSchemaToAll,
36
34
  } from '../mcp/index.js';
37
35
  import { metroCall } from '../mcp/ctx.js';
38
36
  import { gatherAccountsForAgents } from '../mcp/accounts.js';
@@ -121,20 +119,14 @@ function sessionApis(): SessionApis {
121
119
  });
122
120
  }
123
121
 
124
- let connectors: ConnectorAggregate | null = null;
122
+ let connectors: ConnectorWatch | null = null;
125
123
 
126
124
  function startConnectors(): void {
127
- const aggregate = new ConnectorAggregate(agentsDir(), () => {
128
- invalidateToolSchema();
129
- announceToolSchemaToAll();
125
+ const watch = new ConnectorWatch(agentsDir(), () => {
126
+ syncPluginServers();
130
127
  });
131
- connectors = aggregate;
132
- setConnectorToolProvider({
133
- list: () => aggregate.list(),
134
- owns: (name) => aggregate.owns(name),
135
- call: (name, args) => aggregate.call(name, args),
136
- });
137
- aggregate.start();
128
+ connectors = watch;
129
+ watch.start();
138
130
  }
139
131
 
140
132
  async function main(): Promise<void> {
@@ -0,0 +1,97 @@
1
+ import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { errMsg, log } from '@metro-labs/core/log';
4
+ import { claudeDir } from '../claude/files.js';
5
+ import { webhookPort } from '../net/tunnel.js';
6
+ import { readLocalConnectors, type LocalConnectorRow } from './store.js';
7
+
8
+ const MARKER = join('bin', 'metro-plugin.mjs');
9
+ const FILE = '.mcp.json';
10
+ const HELPER = 'node "${CLAUDE_PLUGIN_ROOT}/bin/metro-plugin.mjs" headers';
11
+ const SLUG_MAX = 40;
12
+
13
+ export interface PluginServer {
14
+ type: 'http';
15
+ url: string;
16
+ headersHelper: string;
17
+ }
18
+
19
+ export function serverKey(name: string): string {
20
+ const slug = name
21
+ .toLowerCase()
22
+ .replace(/[^a-z0-9]+/g, '-')
23
+ .replace(/^-+|-+$/g, '')
24
+ .slice(0, SLUG_MAX)
25
+ .replace(/-+$/, '');
26
+ return slug === '' ? 'connector' : slug;
27
+ }
28
+
29
+ export function pluginServers(rows: LocalConnectorRow[], base: string): Record<string, PluginServer> {
30
+ const taken = new Map<string, number>();
31
+ const out: Record<string, PluginServer> = {};
32
+ for (const row of rows) {
33
+ const wanted = serverKey(row.name);
34
+ const seen = taken.get(wanted) ?? 0;
35
+ taken.set(wanted, seen + 1);
36
+ const key = seen === 0 ? wanted : `${wanted}-${row.id.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 4)}`;
37
+ out[key] = { type: 'http', url: `${base}/relay/${row.id}`, headersHelper: HELPER };
38
+ }
39
+ return out;
40
+ }
41
+
42
+ export function installedPluginFiles(dir = claudeDir()): string[] {
43
+ const root = join(dir, 'plugins', 'marketplaces');
44
+ if (!existsSync(root)) return [];
45
+ const out: string[] = [];
46
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
47
+ if (!entry.isDirectory()) continue;
48
+ const plugin = join(root, entry.name, 'plugin');
49
+ if (existsSync(join(plugin, MARKER))) out.push(join(plugin, FILE));
50
+ }
51
+ return out;
52
+ }
53
+
54
+ const readOrNull = (path: string): string | null => {
55
+ try {
56
+ return readFileSync(path, 'utf8');
57
+ } catch {
58
+ return null;
59
+ }
60
+ };
61
+
62
+ function writeIfChanged(path: string, text: string): boolean {
63
+ if (readOrNull(path) === text) return false;
64
+ const tmp = `${path}.metro-${String(process.pid)}`;
65
+ writeFileSync(tmp, text, { mode: 0o644 });
66
+ renameSync(tmp, path);
67
+ return true;
68
+ }
69
+
70
+ export interface PluginSyncOptions {
71
+ dir?: string;
72
+ base?: string;
73
+ agents?: string;
74
+ }
75
+
76
+ export function syncPluginServers(opts: PluginSyncOptions = {}): number {
77
+ const files = installedPluginFiles(opts.dir ?? claudeDir());
78
+ if (files.length === 0) return 0;
79
+ const base = opts.base ?? `http://127.0.0.1:${String(webhookPort())}`;
80
+ const rows = opts.agents === undefined ? readLocalConnectors() : readLocalConnectors(opts.agents);
81
+ const servers = pluginServers(rows, base);
82
+ const text = `${JSON.stringify(servers, null, 2)}\n`;
83
+ let written = 0;
84
+ for (const file of files) {
85
+ try {
86
+ if (writeIfChanged(file, text)) written += 1;
87
+ } catch (err) {
88
+ log.warn({ file, err: errMsg(err) }, 'plugin: could not write the connector servers');
89
+ }
90
+ }
91
+ if (written > 0)
92
+ log.info(
93
+ { files: written, servers: Object.keys(servers).length },
94
+ 'plugin: connector servers written; /reload-plugins picks them up in a running session',
95
+ );
96
+ return written;
97
+ }
@@ -0,0 +1,54 @@
1
+ import { watch, type FSWatcher } from 'node:fs';
2
+ import { errMsg, log } from '@metro-labs/core/log';
3
+
4
+ const DEBOUNCE_MS = 300;
5
+ const FILE = 'connectors.json';
6
+
7
+ export class ConnectorWatch {
8
+ private watcher: FSWatcher | null = null;
9
+ private timer: ReturnType<typeof setTimeout> | null = null;
10
+
11
+ constructor(
12
+ private readonly dir: string,
13
+ private readonly onChange: () => void,
14
+ ) {}
15
+
16
+ start(): void {
17
+ try {
18
+ this.watcher = watch(this.dir, (_event, file) => {
19
+ if (file === null || file === FILE) this.schedule();
20
+ });
21
+ this.watcher.on('error', (err: unknown) => {
22
+ log.warn({ err: errMsg(err) }, 'connectors: watcher failed; the plugin list refreshes at the next start');
23
+ });
24
+ this.watcher.unref();
25
+ } catch (err) {
26
+ log.warn({ err: errMsg(err) }, 'connectors: could not watch the agents dir');
27
+ }
28
+ this.fire();
29
+ }
30
+
31
+ stop(): void {
32
+ this.watcher?.close();
33
+ this.watcher = null;
34
+ if (this.timer !== null) clearTimeout(this.timer);
35
+ this.timer = null;
36
+ }
37
+
38
+ private schedule(): void {
39
+ if (this.timer !== null) clearTimeout(this.timer);
40
+ this.timer = setTimeout(() => {
41
+ this.timer = null;
42
+ this.fire();
43
+ }, DEBOUNCE_MS);
44
+ this.timer.unref();
45
+ }
46
+
47
+ private fire(): void {
48
+ try {
49
+ this.onChange();
50
+ } catch (err) {
51
+ log.warn({ err: errMsg(err) }, 'connectors: change handler failed');
52
+ }
53
+ }
54
+ }
@@ -138,10 +138,6 @@ export function agentLiveness(): Map<string, AgentLiveness> {
138
138
  return activeRegistry?.liveness() ?? new Map<string, AgentLiveness>();
139
139
  }
140
140
 
141
- export function announceToolSchemaToAll(): void {
142
- activeRegistry?.announceToolSchema();
143
- }
144
-
145
141
  export async function closeAgentSession(agentId: string): Promise<boolean> {
146
142
  const scopeKey = sessionScopeKey({ kind: 'agent', agentId });
147
143
  return (await activeRegistry?.closeScope(scopeKey)) ?? false;
@@ -61,10 +61,6 @@ export class SessionRegistry {
61
61
  return out;
62
62
  }
63
63
 
64
- announceToolSchema(): void {
65
- for (const session of this.byId.values()) session.announceToolSchema();
66
- }
67
-
68
64
  forScope(scopeKey: string): McpSession | undefined {
69
65
  return this.byScope.get(scopeKey);
70
66
  }
@@ -165,7 +165,7 @@ export class McpSession {
165
165
  return this.issuedSchema !== toolSchemaSignature();
166
166
  }
167
167
 
168
- announceToolSchema(): void {
168
+ private announceToolSchema(): void {
169
169
  if (!this.streamAttached) return;
170
170
  this.deliverSchemaNotice(
171
171
  () => this.server.sendToolListChanged(),
@@ -31,7 +31,6 @@ import {
31
31
  type RequestIdentity,
32
32
  } from './request-identity.js';
33
33
  import { str } from '@metro-labs/core/str';
34
- import { callConnectorTool, connectorToolList, isConnectorTool } from './connector-tools.js';
35
34
 
36
35
  const STATION_TOOLS = new Map<
37
36
  string,
@@ -63,16 +62,11 @@ const toolList = (): { tools: unknown[] } => ({
63
62
  })),
64
63
  ),
65
64
  LIST_ACCOUNTS_TOOL,
66
- ...connectorToolList(),
67
65
  ],
68
66
  });
69
67
 
70
68
  let schemaSignature: string | undefined;
71
69
 
72
- export function invalidateToolSchema(): void {
73
- schemaSignature = undefined;
74
- }
75
-
76
70
  export const toolSchemaSignature = (): string => {
77
71
  schemaSignature ??= createHash('sha256')
78
72
  .update(JSON.stringify(toolList()))
@@ -155,7 +149,6 @@ async function runTool(
155
149
  const name = req.params.name;
156
150
  const a = req.params.arguments ?? {};
157
151
 
158
- if (isConnectorTool(name)) return callConnectorTool(name, a);
159
152
  const identity = currentIdentity();
160
153
  if (name !== 'list_accounts' && scopeDenied(identity, name, a))
161
154
  return errResult('metro: this account is outside your authorized scope');
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.87"
2
+ "version": "0.1.0-beta.88"
3
3
  }
@@ -1,199 +0,0 @@
1
- import { watch, type FSWatcher } from 'node:fs';
2
- import { isRecord } from '@metro-labs/core/is-record';
3
- import { errMsg, log } from '@metro-labs/core/log';
4
- import type { ToolResult } from '@metro-labs/core/stations/types';
5
- import { signInState } from './config.js';
6
- import type { RelayTarget } from './relay-target.js';
7
- import { localRelayTarget, readLocalConnectors, type LocalConnectorRow } from './store.js';
8
- import { UpstreamClient, type UpstreamTool } from './upstream.js';
9
-
10
- const SEP = '__';
11
- const SLUG_MAX = 24;
12
- const NAME_MAX = 64;
13
- const WATCH_DEBOUNCE_MS = 300;
14
- const CONNECTORS_FILE = 'connectors.json';
15
-
16
- export interface ConnectorToolView {
17
- name: string;
18
- description: string;
19
- inputSchema: unknown;
20
- annotations?: unknown;
21
- }
22
-
23
- interface Entry {
24
- row: LocalConnectorRow;
25
- stamp: string;
26
- slug: string;
27
- client: UpstreamClient;
28
- tools: UpstreamTool[];
29
- }
30
-
31
- type TargetOf = (connectorId: string, force: boolean, dir: string) => Promise<RelayTarget>;
32
-
33
- export function slugOf(name: string): string {
34
- const slug = name
35
- .toLowerCase()
36
- .replace(/[^a-z0-9]+/g, '_')
37
- .replace(/^_+|_+$/g, '')
38
- .slice(0, SLUG_MAX)
39
- .replace(/_+$/, '');
40
- return slug === '' ? 'connector' : slug;
41
- }
42
-
43
- const stampOf = (row: LocalConnectorRow): string =>
44
- JSON.stringify([row.name, row.url, row.config.auth.kind, row.config.verified.at, row.config.oauth]);
45
-
46
- const toolName = (slug: string, tool: string): string => `${slug}${SEP}${tool}`.slice(0, NAME_MAX);
47
-
48
- const textBlock = (text: string): { type: 'text'; text: string } => ({ type: 'text', text });
49
-
50
- function blockOf(block: unknown): { type: 'text'; text: string } {
51
- if (isRecord(block) && block.type === 'text' && typeof block.text === 'string') return textBlock(block.text);
52
- const kind = isRecord(block) && typeof block.type === 'string' ? block.type : 'unknown';
53
- return textBlock(`[${kind} block omitted by metro]`);
54
- }
55
-
56
- export function toToolResult(raw: unknown): ToolResult {
57
- if (isRecord(raw) && Array.isArray(raw.content)) {
58
- const content = raw.content.map(blockOf);
59
- return raw.isError === true ? { content, isError: true } : { content };
60
- }
61
- return { content: [textBlock(JSON.stringify(raw ?? null))] };
62
- }
63
-
64
- function dedupeSlugs(entries: Map<string, Entry>): void {
65
- const bySlug = new Map<string, Entry[]>();
66
- for (const entry of entries.values()) bySlug.set(entry.slug, [...(bySlug.get(entry.slug) ?? []), entry]);
67
- for (const group of bySlug.values()) {
68
- if (group.length < 2) continue;
69
- for (const entry of group) entry.slug = `${entry.slug}_${entry.row.id.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 4)}`;
70
- }
71
- }
72
-
73
- export class ConnectorAggregate {
74
- private entries = new Map<string, Entry>();
75
- private watcher: FSWatcher | null = null;
76
- private timer: ReturnType<typeof setTimeout> | null = null;
77
- private loading: Promise<void> | null = null;
78
-
79
- constructor(
80
- private readonly dir: string,
81
- private readonly onChange: () => void,
82
- private readonly targetOf: TargetOf = localRelayTarget,
83
- ) {}
84
-
85
- start(): void {
86
- try {
87
- this.watcher = watch(this.dir, (_event, file) => {
88
- if (file === null || file === CONNECTORS_FILE) this.schedule();
89
- });
90
- this.watcher.on('error', (err: unknown) => {
91
- log.warn({ err: errMsg(err) }, 'connectors: watcher failed; tools refresh on the next daemon start');
92
- });
93
- this.watcher.unref();
94
- } catch (err) {
95
- log.warn({ err: errMsg(err) }, 'connectors: could not watch the agents dir');
96
- }
97
- this.schedule(0);
98
- }
99
-
100
- stop(): void {
101
- this.watcher?.close();
102
- this.watcher = null;
103
- if (this.timer !== null) clearTimeout(this.timer);
104
- this.timer = null;
105
- }
106
-
107
- private schedule(ms = WATCH_DEBOUNCE_MS): void {
108
- if (this.timer !== null) clearTimeout(this.timer);
109
- this.timer = setTimeout(() => {
110
- this.timer = null;
111
- this.reload().catch((err: unknown) => {
112
- log.warn({ err: errMsg(err) }, 'connectors: reload failed');
113
- });
114
- }, ms);
115
- this.timer.unref();
116
- }
117
-
118
- reload(): Promise<void> {
119
- this.loading ??= this.load().finally(() => {
120
- this.loading = null;
121
- });
122
- return this.loading;
123
- }
124
-
125
- private async load(): Promise<void> {
126
- const rows = readLocalConnectors(this.dir).filter((row) => signInState(row.config) !== 'disconnected');
127
- const next = new Map<string, Entry>();
128
- const jobs: Promise<void>[] = [];
129
- for (const row of rows) {
130
- const stamp = stampOf(row);
131
- const kept = this.entries.get(row.id);
132
- if (kept?.stamp === stamp && kept !== undefined) {
133
- next.set(row.id, kept);
134
- continue;
135
- }
136
- const entry: Entry = { row, stamp, slug: slugOf(row.name), client: new UpstreamClient((force) => this.targetOf(row.id, force, this.dir)), tools: [] };
137
- next.set(row.id, entry);
138
- jobs.push(this.fill(entry));
139
- }
140
- await Promise.all(jobs);
141
- dedupeSlugs(next);
142
- const before = this.signature();
143
- this.entries = next;
144
- if (this.signature() !== before) this.onChange();
145
- }
146
-
147
- private async fill(entry: Entry): Promise<void> {
148
- try {
149
- entry.tools = await entry.client.listTools();
150
- log.info({ connector: entry.row.name, tools: entry.tools.length }, 'connectors: tools listed');
151
- } catch (err) {
152
- log.warn({ connector: entry.row.name, err: errMsg(err) }, 'connectors: tools not listed; the connector has no tools until it answers');
153
- }
154
- }
155
-
156
- signature(): string {
157
- return JSON.stringify([...this.entries.values()].map((e) => [e.row.id, e.slug, e.tools.map((t) => t.name)]).sort());
158
- }
159
-
160
- list(): ConnectorToolView[] {
161
- const out: ConnectorToolView[] = [];
162
- for (const entry of [...this.entries.values()].sort((a, b) => a.slug.localeCompare(b.slug)))
163
- for (const tool of entry.tools)
164
- out.push({
165
- name: toolName(entry.slug, tool.name),
166
- description: tool.description === '' ? `${entry.row.name}: ${tool.name}` : `${tool.description} (${entry.row.name})`,
167
- inputSchema: tool.inputSchema,
168
- ...(tool.annotations === undefined ? {} : { annotations: tool.annotations }),
169
- });
170
- return out;
171
- }
172
-
173
- private resolve(name: string): { entry: Entry; tool: UpstreamTool } | null {
174
- const at = name.indexOf(SEP);
175
- if (at <= 0) return null;
176
- const slug = name.slice(0, at);
177
- for (const entry of this.entries.values()) {
178
- if (entry.slug !== slug) continue;
179
- const tool = entry.tools.find((t) => toolName(slug, t.name) === name);
180
- return tool === undefined ? null : { entry, tool };
181
- }
182
- return null;
183
- }
184
-
185
- owns(name: string): boolean {
186
- return this.resolve(name) !== null;
187
- }
188
-
189
- async call(name: string, args: Record<string, unknown>): Promise<ToolResult> {
190
- const found = this.resolve(name);
191
- if (found === null) return { content: [textBlock(`metro: no connector serves ${name}`)], isError: true };
192
- try {
193
- return toToolResult(await found.entry.client.callTool(found.tool.name, args));
194
- } catch (err) {
195
- log.warn({ connector: found.entry.row.name, tool: found.tool.name, err: errMsg(err) }, 'connectors: tool call failed');
196
- return { content: [textBlock(`metro: connector ${found.entry.row.name} ${errMsg(err)}`)], isError: true };
197
- }
198
- }
199
- }
@@ -1,172 +0,0 @@
1
- import { isRecord } from '@metro-labs/core/is-record';
2
- import { errMsg } from '@metro-labs/core/log';
3
- import type { RelayTarget } from './relay-target.js';
4
-
5
- const ACCEPT = 'application/json, text/event-stream';
6
- const PROTOCOL = '2025-11-25';
7
- const MAX_PAGES = 10;
8
- const LIST_MS = 15_000;
9
- const CALL_MS = 120_000;
10
-
11
- export interface UpstreamTool {
12
- name: string;
13
- description: string;
14
- inputSchema: unknown;
15
- annotations: unknown;
16
- }
17
-
18
- export type TargetOf = (force: boolean) => Promise<RelayTarget>;
19
-
20
- export class UpstreamRefused extends Error {}
21
- export class UpstreamFailed extends Error {}
22
-
23
- interface Live {
24
- session: string | null;
25
- protocol: string;
26
- }
27
-
28
- interface Answer {
29
- status: number;
30
- text: string;
31
- contentType: string;
32
- session: string | null;
33
- }
34
-
35
- function dataLines(text: string): string[] {
36
- return text
37
- .replace(/\r\n/g, '\n')
38
- .split('\n')
39
- .filter((line) => line.startsWith('data:'))
40
- .map((line) => line.slice(5).trim());
41
- }
42
-
43
- function messageFor(answer: Answer, id: number): Record<string, unknown> | null {
44
- const candidates = answer.contentType.includes('text/event-stream') ? dataLines(answer.text) : [answer.text];
45
- for (const candidate of candidates) {
46
- try {
47
- const parsed: unknown = JSON.parse(candidate);
48
- if (isRecord(parsed) && parsed.id === id) return parsed;
49
- } catch {
50
- continue;
51
- }
52
- }
53
- return null;
54
- }
55
-
56
- function resultOf(answer: Answer, id: number): unknown {
57
- const message = messageFor(answer, id);
58
- if (message === null) throw new UpstreamFailed('answered without a result for the request');
59
- if (isRecord(message.error)) {
60
- const text = typeof message.error.message === 'string' ? message.error.message : JSON.stringify(message.error);
61
- throw new UpstreamFailed(text);
62
- }
63
- return message.result;
64
- }
65
-
66
- const initializeBody = (id: number): unknown => ({
67
- jsonrpc: '2.0',
68
- id,
69
- method: 'initialize',
70
- params: { protocolVersion: PROTOCOL, capabilities: {}, clientInfo: { name: 'metro', version: '0.1.0' } },
71
- });
72
-
73
- function toolOf(raw: unknown): UpstreamTool | null {
74
- if (!isRecord(raw) || typeof raw.name !== 'string' || raw.name === '') return null;
75
- return {
76
- name: raw.name,
77
- description: typeof raw.description === 'string' ? raw.description : '',
78
- inputSchema: isRecord(raw.inputSchema) ? raw.inputSchema : { type: 'object' },
79
- annotations: isRecord(raw.annotations) ? raw.annotations : undefined,
80
- };
81
- }
82
-
83
- export class UpstreamClient {
84
- private live: Live | null = null;
85
- private seq = 0;
86
-
87
- constructor(private readonly target: TargetOf) {}
88
-
89
- forget(): void {
90
- this.live = null;
91
- }
92
-
93
- async listTools(): Promise<UpstreamTool[]> {
94
- const tools: UpstreamTool[] = [];
95
- let cursor = '';
96
- for (let page = 0; page < MAX_PAGES; page += 1) {
97
- const result = await this.rpc('tools/list', cursor === '' ? {} : { cursor }, LIST_MS);
98
- if (!isRecord(result)) break;
99
- if (Array.isArray(result.tools)) for (const raw of result.tools) {
100
- const tool = toolOf(raw);
101
- if (tool !== null) tools.push(tool);
102
- }
103
- const next = result.nextCursor;
104
- if (typeof next !== 'string' || next === '' || next === cursor) break;
105
- cursor = next;
106
- }
107
- return tools;
108
- }
109
-
110
- callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
111
- return this.rpc('tools/call', { name, arguments: args }, CALL_MS);
112
- }
113
-
114
- private async post(body: unknown, headers: Record<string, string>, force: boolean, ms: number): Promise<Answer> {
115
- const target = await this.target(force);
116
- if (target.kind === 'missing') throw new UpstreamRefused('this connector no longer exists on the daemon');
117
- if (target.kind === 'signin') throw new UpstreamRefused('this connector needs signing in again on its page');
118
- let res: Response;
119
- try {
120
- res = await fetch(target.url, {
121
- method: 'POST',
122
- redirect: 'manual',
123
- signal: AbortSignal.timeout(ms),
124
- headers: { 'content-type': 'application/json', accept: ACCEPT, ...target.headers, ...headers },
125
- body: JSON.stringify(body),
126
- });
127
- } catch (err) {
128
- throw new UpstreamFailed(`could not be reached (${errMsg(err)})`);
129
- }
130
- const text = await res.text().catch(() => '');
131
- return { status: res.status, text, contentType: res.headers.get('content-type') ?? '', session: res.headers.get('mcp-session-id') };
132
- }
133
-
134
- private async withCredentialLadder(body: unknown, headers: Record<string, string>, ms: number): Promise<Answer> {
135
- const first = await this.post(body, headers, false, ms);
136
- if (first.status !== 401 && first.status !== 403) return first;
137
- const again = await this.post(body, headers, true, ms);
138
- if (again.status === 401 || again.status === 403) throw new UpstreamRefused('rejected the credential; sign in again on its page');
139
- return again;
140
- }
141
-
142
- private async ensureSession(): Promise<Live> {
143
- if (this.live !== null) return this.live;
144
- const id = ++this.seq;
145
- const answer = await this.withCredentialLadder(initializeBody(id), {}, LIST_MS);
146
- if (answer.status >= 300) throw new UpstreamFailed(`answered ${String(answer.status)} to initialize`);
147
- const result = resultOf(answer, id);
148
- const protocol = isRecord(result) && typeof result.protocolVersion === 'string' ? result.protocolVersion : PROTOCOL;
149
- this.live = { session: answer.session, protocol };
150
- await this.post({ jsonrpc: '2.0', method: 'notifications/initialized' }, this.sessionHeaders(this.live), false, LIST_MS).catch(() => undefined);
151
- return this.live;
152
- }
153
-
154
- private sessionHeaders(live: Live): Record<string, string> {
155
- const headers: Record<string, string> = { 'mcp-protocol-version': live.protocol };
156
- if (live.session !== null && live.session !== '') headers['mcp-session-id'] = live.session;
157
- return headers;
158
- }
159
-
160
- private async rpc(method: string, params: unknown, ms: number): Promise<unknown> {
161
- const live = await this.ensureSession();
162
- const id = ++this.seq;
163
- const body = { jsonrpc: '2.0', id, method, params };
164
- let answer = await this.withCredentialLadder(body, this.sessionHeaders(live), ms);
165
- if (answer.status === 404 && live.session !== null) {
166
- this.live = null;
167
- answer = await this.withCredentialLadder(body, this.sessionHeaders(await this.ensureSession()), ms);
168
- }
169
- if (answer.status >= 300) throw new UpstreamFailed(`answered ${String(answer.status)}${answer.text === '' ? '' : `: ${answer.text.slice(0, 200)}`}`);
170
- return resultOf(answer, id);
171
- }
172
- }
@@ -1,31 +0,0 @@
1
- import type { ToolResult } from '@metro-labs/core/stations/types';
2
- import { errResult } from './ctx.js';
3
-
4
- export interface ConnectorToolEntry {
5
- name: string;
6
- description: string;
7
- inputSchema: unknown;
8
- annotations?: unknown;
9
- }
10
-
11
- export interface ConnectorToolProvider {
12
- list: () => ConnectorToolEntry[];
13
- owns: (name: string) => boolean;
14
- call: (name: string, args: Record<string, unknown>) => Promise<ToolResult>;
15
- }
16
-
17
- const NONE: ConnectorToolProvider = {
18
- list: () => [],
19
- owns: () => false,
20
- call: (name) => Promise.resolve(errResult(`metro: no connector serves ${name}`)),
21
- };
22
-
23
- let provider: ConnectorToolProvider = NONE;
24
-
25
- export function setConnectorToolProvider(next: ConnectorToolProvider | null): void {
26
- provider = next ?? NONE;
27
- }
28
-
29
- export const connectorToolList = (): ConnectorToolEntry[] => provider.list();
30
- export const isConnectorTool = (name: string): boolean => provider.owns(name);
31
- export const callConnectorTool = (name: string, args: Record<string, unknown>): Promise<ToolResult> => provider.call(name, args);