@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 +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +7 -15
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +97 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/watch.ts +54 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +0 -4
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-registry.ts +0 -4
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +0 -7
- package/runtime/runtime.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/aggregate.ts +0 -199
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/upstream.ts +0 -172
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/connector-tools.ts +0 -31
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.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 {
|
|
24
|
-
import {
|
|
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:
|
|
122
|
+
let connectors: ConnectorWatch | null = null;
|
|
125
123
|
|
|
126
124
|
function startConnectors(): void {
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
announceToolSchemaToAll();
|
|
125
|
+
const watch = new ConnectorWatch(agentsDir(), () => {
|
|
126
|
+
syncPluginServers();
|
|
130
127
|
});
|
|
131
|
-
connectors =
|
|
132
|
-
|
|
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');
|
package/runtime/runtime.json
CHANGED
|
@@ -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);
|