@stage-labs/metro 0.1.0-beta.84 → 0.1.0-beta.85
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 +23 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/aggregate.ts +199 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/upstream.ts +172 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/connector-tools.ts +31 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/index.ts +4 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session-registry.ts +4 -0
- 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 +7 -0
- package/runtime/runtime.json +1 -1
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.85",
|
|
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": {
|
|
@@ -19,7 +19,10 @@ import {
|
|
|
19
19
|
trainEventToMetroEvent,
|
|
20
20
|
} from '../routes/http.js';
|
|
21
21
|
import { localAgentKey } from '../stations/materialize.js';
|
|
22
|
-
import { fileSource } from '../agents/files.js';
|
|
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
26
|
import { applyLocalOwner } from './local-owner.js';
|
|
24
27
|
import { localOwner } from '../agents/file-admin.js';
|
|
25
28
|
import { ensureStationDeps } from '../stations/runtime-deps.js';
|
|
@@ -29,6 +32,7 @@ import {
|
|
|
29
32
|
agentLiveness,
|
|
30
33
|
closeAgentSession,
|
|
31
34
|
createMetroMcp,
|
|
35
|
+
announceToolSchemaToAll,
|
|
32
36
|
} from '../mcp/index.js';
|
|
33
37
|
import { metroCall } from '../mcp/ctx.js';
|
|
34
38
|
import { gatherAccountsForAgents } from '../mcp/accounts.js';
|
|
@@ -114,6 +118,22 @@ function sessionApis(): SessionApis {
|
|
|
114
118
|
});
|
|
115
119
|
}
|
|
116
120
|
|
|
121
|
+
let connectors: ConnectorAggregate | null = null;
|
|
122
|
+
|
|
123
|
+
function startConnectors(): void {
|
|
124
|
+
const aggregate = new ConnectorAggregate(agentsDir(), () => {
|
|
125
|
+
invalidateToolSchema();
|
|
126
|
+
announceToolSchemaToAll();
|
|
127
|
+
});
|
|
128
|
+
connectors = aggregate;
|
|
129
|
+
setConnectorToolProvider({
|
|
130
|
+
list: () => aggregate.list(),
|
|
131
|
+
owns: (name) => aggregate.owns(name),
|
|
132
|
+
call: (name, args) => aggregate.call(name, args),
|
|
133
|
+
});
|
|
134
|
+
aggregate.start();
|
|
135
|
+
}
|
|
136
|
+
|
|
117
137
|
async function main(): Promise<void> {
|
|
118
138
|
applyLocalOwner();
|
|
119
139
|
await materializeFrom(fileSource, { allowEmpty: true });
|
|
@@ -126,6 +146,7 @@ async function main(): Promise<void> {
|
|
|
126
146
|
metroCall,
|
|
127
147
|
);
|
|
128
148
|
metroMcp.startInbound();
|
|
149
|
+
startConnectors();
|
|
129
150
|
startUploadReaper();
|
|
130
151
|
announceLocalEndpoint();
|
|
131
152
|
tunnel?.start();
|
|
@@ -145,6 +166,7 @@ async function shutdown(): Promise<void> {
|
|
|
145
166
|
if (shuttingDown) return;
|
|
146
167
|
shuttingDown = true;
|
|
147
168
|
log.info('dispatcher shutting down');
|
|
169
|
+
connectors?.stop();
|
|
148
170
|
tunnel?.stop();
|
|
149
171
|
if (webhookServer) {
|
|
150
172
|
const server = webhookServer;
|
|
@@ -0,0 +1,199 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
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);
|
|
@@ -138,6 +138,10 @@ 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
|
+
|
|
141
145
|
export async function closeAgentSession(agentId: string): Promise<boolean> {
|
|
142
146
|
const scopeKey = sessionScopeKey({ kind: 'agent', agentId });
|
|
143
147
|
return (await activeRegistry?.closeScope(scopeKey)) ?? false;
|
|
@@ -61,6 +61,10 @@ 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
|
+
|
|
64
68
|
forScope(scopeKey: string): McpSession | undefined {
|
|
65
69
|
return this.byScope.get(scopeKey);
|
|
66
70
|
}
|
|
@@ -165,7 +165,7 @@ export class McpSession {
|
|
|
165
165
|
return this.issuedSchema !== toolSchemaSignature();
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
|
|
168
|
+
announceToolSchema(): void {
|
|
169
169
|
if (!this.streamAttached) return;
|
|
170
170
|
this.deliverSchemaNotice(
|
|
171
171
|
() => this.server.sendToolListChanged(),
|
|
@@ -31,6 +31,7 @@ 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';
|
|
34
35
|
|
|
35
36
|
const STATION_TOOLS = new Map<
|
|
36
37
|
string,
|
|
@@ -62,11 +63,16 @@ const toolList = (): { tools: unknown[] } => ({
|
|
|
62
63
|
})),
|
|
63
64
|
),
|
|
64
65
|
LIST_ACCOUNTS_TOOL,
|
|
66
|
+
...connectorToolList(),
|
|
65
67
|
],
|
|
66
68
|
});
|
|
67
69
|
|
|
68
70
|
let schemaSignature: string | undefined;
|
|
69
71
|
|
|
72
|
+
export function invalidateToolSchema(): void {
|
|
73
|
+
schemaSignature = undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
70
76
|
export const toolSchemaSignature = (): string => {
|
|
71
77
|
schemaSignature ??= createHash('sha256')
|
|
72
78
|
.update(JSON.stringify(toolList()))
|
|
@@ -149,6 +155,7 @@ async function runTool(
|
|
|
149
155
|
const name = req.params.name;
|
|
150
156
|
const a = req.params.arguments ?? {};
|
|
151
157
|
|
|
158
|
+
if (isConnectorTool(name)) return callConnectorTool(name, a);
|
|
152
159
|
const identity = currentIdentity();
|
|
153
160
|
if (name !== 'list_accounts' && scopeDenied(identity, name, a))
|
|
154
161
|
return errResult('metro: this account is outside your authorized scope');
|
package/runtime/runtime.json
CHANGED