@stage-labs/metro 0.1.0-beta.217 → 0.1.0-beta.219
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claude.js +5 -1
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +7 -3
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/tool-search.ts +126 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-catalog.ts +5 -0
- package/runtime/runtime.json +1 -1
package/dist/claude.js
CHANGED
|
@@ -40,6 +40,7 @@ export function gatewayEnv(base, agentKey, port) {
|
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
42
|
export const channelEnv = (env) => set(env, 'MCP_PROTOCOL_NEGOTIATION') ? env : { ...env, MCP_PROTOCOL_NEGOTIATION: 'legacy' };
|
|
43
|
+
export const toolSearchEnv = (env) => set(env, 'ENABLE_TOOL_SEARCH') ? env : { ...env, ENABLE_TOOL_SEARCH: 'true' };
|
|
43
44
|
const OWN_CREDENTIALS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'];
|
|
44
45
|
export function credentialEnv(env, agentKey, signedIn) {
|
|
45
46
|
if (signedIn || OWN_CREDENTIALS.some((name) => set(env, name)))
|
|
@@ -123,7 +124,10 @@ function mcpConfigFor(key, port) {
|
|
|
123
124
|
}
|
|
124
125
|
function gatewayLaunchEnv(key, port) {
|
|
125
126
|
process.stderr.write(`metro claude: inference goes through the daemon at http://127.0.0.1:${String(port)}/gateway (the Model page decides where)\n`);
|
|
126
|
-
const
|
|
127
|
+
const pointed = routeModelEnv(gatewayEnv(process.env, key, port), currentRoute());
|
|
128
|
+
const routed = toolSearchEnv(pointed);
|
|
129
|
+
if (routed !== pointed)
|
|
130
|
+
process.stderr.write('metro claude: MCP tool search is on, so connector tools load when Claude needs them instead of all at start\n');
|
|
127
131
|
const env = credentialEnv(routed, key, claudeSignedIn());
|
|
128
132
|
if (env !== routed)
|
|
129
133
|
process.stderr.write("metro claude: Claude Code has no login of its own here, so metro's key stands in as its credential; the Model page must route to Bedrock, OpenRouter or Codex\n");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.219",
|
|
4
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",
|
|
@@ -22,6 +22,7 @@ import { OPENROUTER_BASE } from './openrouter.js';
|
|
|
22
22
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
23
23
|
import { forgetServed, noteServed } from './served.js';
|
|
24
24
|
import { forgetUsage, noteUsageHeaders, UsageScanner } from './usage.js';
|
|
25
|
+
import { fitToolSearch } from './tool-search.js';
|
|
25
26
|
import type { CodexTokens } from './codex-auth.js';
|
|
26
27
|
|
|
27
28
|
const GATEWAY_PREFIX = '/gateway';
|
|
@@ -204,12 +205,15 @@ function shapedFor(req: IncomingMessage, sent: Record<string, unknown>): Record<
|
|
|
204
205
|
return effort === null ? sent : withEffort(sent, effort);
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
const bytesOf = (raw: Buffer, parsed: Record<string, unknown>, sent: Record<string, unknown>): Buffer => (sent === parsed ? raw : Buffer.from(JSON.stringify(sent)));
|
|
209
|
+
|
|
207
210
|
async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): Promise<void> {
|
|
208
211
|
const cfg = deps.config();
|
|
209
212
|
const raw = await readBody(req);
|
|
210
|
-
const
|
|
213
|
+
const parsed = parseJson(raw);
|
|
214
|
+
const sent = fitToolSearch(req, parsed);
|
|
215
|
+
const route = resolveRoute(requestedModel(sent), cfg) ?? passthrough(sent);
|
|
211
216
|
const body = shapedFor(req, sent);
|
|
212
|
-
const route = resolveRoute(requestedModel(body), cfg) ?? passthrough(body);
|
|
213
217
|
const conn = route.connection;
|
|
214
218
|
log.info({ route: routeLabel(route), connection: conn.label, path }, 'gateway: routing');
|
|
215
219
|
if (path === MESSAGES) noteServed({ connection: conn.id, provider: conn.provider, model: route.model, at: new Date().toISOString() });
|
|
@@ -229,7 +233,7 @@ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string,
|
|
|
229
233
|
await toSubscription(req, res, path, body, route, deps);
|
|
230
234
|
return;
|
|
231
235
|
}
|
|
232
|
-
await toAnthropic(req, res, raw, sent, body, route, deps);
|
|
236
|
+
await toAnthropic(req, res, bytesOf(raw, parsed, sent), sent, body, route, deps);
|
|
233
237
|
}
|
|
234
238
|
|
|
235
239
|
function failed(res: ServerResponse, err: unknown): void {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
+
import { stringOf } from '@metro-labs/http/api-http';
|
|
4
|
+
|
|
5
|
+
type Body = Record<string, unknown>;
|
|
6
|
+
|
|
7
|
+
const TOOL_SEARCH_BETA = /^(advanced-tool-use|tool-search-tool|mid-conversation-tool-changes)-/;
|
|
8
|
+
const PLACEHOLDER = 'DeferredToolPlaceholder';
|
|
9
|
+
const NOTHING_FOUND = 'No matching deferred tools found';
|
|
10
|
+
|
|
11
|
+
const blocksOf = (message: unknown): unknown[] => (isRecord(message) && Array.isArray(message.content) ? message.content : []);
|
|
12
|
+
|
|
13
|
+
const isResult = (block: unknown): block is Body & { content: unknown[] } => isRecord(block) && block.type === 'tool_result' && Array.isArray(block.content);
|
|
14
|
+
|
|
15
|
+
const inlineDefinition = (block: unknown): Body | null =>
|
|
16
|
+
isRecord(block) && block.type === 'tool_addition' && isRecord(block.tool) && block.tool.type === 'tool_definition' && isRecord(block.tool.definition) ? block.tool.definition : null;
|
|
17
|
+
|
|
18
|
+
function loadedName(block: unknown): string | null {
|
|
19
|
+
if (!isRecord(block)) return null;
|
|
20
|
+
if (block.type === 'tool_reference') return stringOf(block.tool_name);
|
|
21
|
+
if (block.type !== 'tool_addition' || !isRecord(block.tool)) return null;
|
|
22
|
+
return stringOf(inlineDefinition(block)?.name ?? block.tool.name);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const removedName = (block: unknown): string | null => (isRecord(block) && block.type === 'tool_removal' && isRecord(block.tool) ? stringOf(block.tool.name) : null);
|
|
26
|
+
|
|
27
|
+
const changes = (block: unknown): boolean => loadedName(block) !== null || removedName(block) !== null;
|
|
28
|
+
|
|
29
|
+
const changing = (message: unknown): boolean => blocksOf(message).some((block) => changes(block) || (isResult(block) && block.content.some(changes)));
|
|
30
|
+
|
|
31
|
+
const functionLine = (tool: Body): string =>
|
|
32
|
+
`<function>${JSON.stringify({ description: stringOf(tool.description), name: tool.name, parameters: isRecord(tool.input_schema) ? tool.input_schema : {} })}</function>`;
|
|
33
|
+
|
|
34
|
+
function functionsText(blocks: unknown[], catalog: Map<string, Body>): string {
|
|
35
|
+
const lines = blocks.flatMap((block) => {
|
|
36
|
+
const tool = catalog.get(loadedName(block) ?? '');
|
|
37
|
+
return tool === undefined ? [] : [functionLine(tool)];
|
|
38
|
+
});
|
|
39
|
+
return lines.length === 0 ? NOTHING_FOUND : ['<functions>', ...lines, '</functions>'].join('\n');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const cacheOf = (blocks: unknown[]): Body => {
|
|
43
|
+
const marked = blocks.find((block) => isRecord(block) && block.cache_control !== undefined);
|
|
44
|
+
return isRecord(marked) ? { cache_control: marked.cache_control } : {};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function expanded(blocks: unknown[], catalog: Map<string, Body>): unknown[] {
|
|
48
|
+
const found = blocks.filter((block) => loadedName(block) !== null);
|
|
49
|
+
return blocks.flatMap((block) => {
|
|
50
|
+
if (block === found[0]) return [{ type: 'text', text: functionsText(found, catalog), ...cacheOf(found) }];
|
|
51
|
+
if (loadedName(block) !== null) return [];
|
|
52
|
+
const removed = removedName(block);
|
|
53
|
+
return removed === null ? [block] : [{ type: 'text', text: `Tool ${removed} is no longer available.`, ...cacheOf([block]) }];
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const expandedBlock = (block: unknown, catalog: Map<string, Body>): unknown =>
|
|
58
|
+
isResult(block) && block.content.some(changes) ? { ...block, content: expanded(block.content, catalog) } : block;
|
|
59
|
+
|
|
60
|
+
function expandedMessage(message: unknown, catalog: Map<string, Body>): unknown {
|
|
61
|
+
if (!changing(message) || !isRecord(message)) return message;
|
|
62
|
+
return { ...message, content: expanded(blocksOf(message).map((block) => expandedBlock(block, catalog)), catalog) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface Found {
|
|
66
|
+
names: Set<string>;
|
|
67
|
+
inline: Map<string, Body>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function foundIn(messages: unknown[]): Found {
|
|
71
|
+
const found: Found = { names: new Set(), inline: new Map() };
|
|
72
|
+
const blocks = messages.flatMap(blocksOf).flatMap((block) => (isResult(block) ? block.content : [block]));
|
|
73
|
+
for (const block of blocks) {
|
|
74
|
+
const name = loadedName(block);
|
|
75
|
+
const definition = inlineDefinition(block);
|
|
76
|
+
if (name !== null) found.names.add(name);
|
|
77
|
+
if (definition !== null) found.inline.set(stringOf(definition.name), definition);
|
|
78
|
+
const removed = removedName(block);
|
|
79
|
+
if (removed === null) continue;
|
|
80
|
+
found.names.delete(removed);
|
|
81
|
+
found.inline.delete(removed);
|
|
82
|
+
}
|
|
83
|
+
return found;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const deferred = (tool: unknown): boolean => isRecord(tool) && tool.defer_loading === true;
|
|
87
|
+
|
|
88
|
+
const loaded = (tool: unknown): unknown =>
|
|
89
|
+
isRecord(tool) && 'defer_loading' in tool ? Object.fromEntries(Object.entries(tool).filter(([key]) => key !== 'defer_loading')) : tool;
|
|
90
|
+
|
|
91
|
+
const deferring = (tools: unknown): boolean => Array.isArray(tools) && tools.some((tool) => isRecord(tool) && 'defer_loading' in tool);
|
|
92
|
+
|
|
93
|
+
const nameOf = (tool: unknown): string => (isRecord(tool) ? stringOf(tool.name) : '');
|
|
94
|
+
|
|
95
|
+
export function resolveToolSearch(body: Body): Body {
|
|
96
|
+
const messages: unknown[] = Array.isArray(body.messages) ? body.messages : [];
|
|
97
|
+
if (!deferring(body.tools) && !messages.some(changing)) return body;
|
|
98
|
+
const declared: unknown[] = Array.isArray(body.tools) ? body.tools : [];
|
|
99
|
+
const found = foundIn(messages);
|
|
100
|
+
const shown = declared.filter((tool) => nameOf(tool) !== PLACEHOLDER && (!deferred(tool) || found.names.has(nameOf(tool)))).map(loaded);
|
|
101
|
+
const known = new Set(shown.map(nameOf));
|
|
102
|
+
const inline = [...found.inline.values()].filter((tool) => !known.has(nameOf(tool))).map(loaded);
|
|
103
|
+
const tools = [...shown, ...inline];
|
|
104
|
+
const catalog = new Map([...declared, ...inline].filter(isRecord).map((tool) => [nameOf(tool), tool]));
|
|
105
|
+
return {
|
|
106
|
+
...body,
|
|
107
|
+
...(Array.isArray(body.tools) || inline.length > 0 ? { tools } : {}),
|
|
108
|
+
...(Array.isArray(body.messages) ? { messages: messages.map((message) => expandedMessage(message, catalog)) } : {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function dropToolSearchBeta(req: IncomingMessage): void {
|
|
113
|
+
const raw = req.headers['anthropic-beta'];
|
|
114
|
+
if (typeof raw !== 'string') return;
|
|
115
|
+
const kept = raw
|
|
116
|
+
.split(',')
|
|
117
|
+
.map((beta) => beta.trim())
|
|
118
|
+
.filter((beta) => beta !== '' && !TOOL_SEARCH_BETA.test(beta));
|
|
119
|
+
if (kept.length > 0) req.headers['anthropic-beta'] = kept.join(',');
|
|
120
|
+
else delete req.headers['anthropic-beta'];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function fitToolSearch(req: IncomingMessage, body: Body): Body {
|
|
124
|
+
dropToolSearchBeta(req);
|
|
125
|
+
return resolveToolSearch(body);
|
|
126
|
+
}
|
|
@@ -37,8 +37,12 @@ export interface PublishedTool {
|
|
|
37
37
|
description: string;
|
|
38
38
|
inputSchema: Record<string, unknown>;
|
|
39
39
|
annotations: ToolAnnotations;
|
|
40
|
+
_meta?: Record<string, boolean>;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
const ALWAYS_LOADED = new Set(['react', 'typing', 'send', 'reply']);
|
|
44
|
+
const ALWAYS_LOAD = { 'anthropic/alwaysLoad': true };
|
|
45
|
+
|
|
42
46
|
const annotationsOf = (def: ToolDef): ToolAnnotations =>
|
|
43
47
|
toolGroupOf(def.name) === 'read'
|
|
44
48
|
? { readOnlyHint: true }
|
|
@@ -50,6 +54,7 @@ export const listedTools = (): PublishedTool[] =>
|
|
|
50
54
|
description: def.description,
|
|
51
55
|
inputSchema: def.inputSchema,
|
|
52
56
|
annotations: annotationsOf(def),
|
|
57
|
+
...(ALWAYS_LOADED.has(def.name) ? { _meta: ALWAYS_LOAD } : {}),
|
|
53
58
|
}));
|
|
54
59
|
|
|
55
60
|
const STATION_OF_TOOL = new Map(STATIONS.flatMap((s) => s.tools.map((t) => [t.name, s.name] as const)));
|
package/runtime/runtime.json
CHANGED