@stage-labs/metro 0.1.0-beta.217 → 0.1.0-beta.218

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
2
  import { markOnboardingDone, seedChannels } from './onboarding.js';
3
3
  import { writeMcpConfig } from './mcp-config.js';
4
- import { currentRoute, permissionMode, routeModelEnv, systemPrompt } from './route.js';
4
+ import { anthropicRoute, currentRoute, permissionMode, routeModelEnv, systemPrompt } from './route.js';
5
5
  import { settingsConflicts, settingsFiles } from './claude-settings.js';
6
6
  import { localAgent } from './local.js';
7
7
  import { PROVIDER_FLAGS } from './provider-flags.js';
@@ -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, anthropic) => !anthropic || 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 routed = routeModelEnv(gatewayEnv(process.env, key, port), currentRoute());
127
+ const pointed = routeModelEnv(gatewayEnv(process.env, key, port), currentRoute());
128
+ const routed = toolSearchEnv(pointed, anthropicRoute());
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/dist/route.js CHANGED
@@ -16,17 +16,27 @@ function routeIn(cfg) {
16
16
  const model = text(conn.model);
17
17
  return PROVIDERS.has(provider) && model !== '' ? `${provider}:${model}` : null;
18
18
  }
19
- export function currentRoute(dir = agentsDir()) {
19
+ function modelConfig(dir) {
20
20
  const path = join(dir, 'model.json');
21
21
  if (!existsSync(path))
22
22
  return null;
23
23
  try {
24
- return routeIn(JSON.parse(readFileSync(path, 'utf8')));
24
+ return JSON.parse(readFileSync(path, 'utf8'));
25
25
  }
26
26
  catch {
27
27
  return null;
28
28
  }
29
29
  }
30
+ export const currentRoute = (dir = agentsDir()) => routeIn(modelConfig(dir));
31
+ const isRecord = (value) => typeof value === 'object' && value !== null;
32
+ export function anthropicRoute(dir = agentsDir()) {
33
+ const cfg = modelConfig(dir);
34
+ if (!isRecord(cfg) || !Array.isArray(cfg.connections))
35
+ return true;
36
+ const connections = cfg.connections.filter(isRecord);
37
+ const routed = connections.find((c) => c.id === cfg.route) ?? connections[0];
38
+ return routed === undefined || routed.provider === 'anthropic';
39
+ }
30
40
  export function routeModelEnv(env, route) {
31
41
  if (route === null || (env.ANTHROPIC_MODEL ?? '').trim() !== '')
32
42
  return env;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.217",
3
+ "version": "0.1.0-beta.218",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.217",
3
+ "version": "0.1.0-beta.218",
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';
@@ -208,8 +209,9 @@ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string,
208
209
  const cfg = deps.config();
209
210
  const raw = await readBody(req);
210
211
  const sent = parseJson(raw);
211
- const body = shapedFor(req, sent);
212
- const route = resolveRoute(requestedModel(body), cfg) ?? passthrough(body);
212
+ const shaped = shapedFor(req, sent);
213
+ const route = resolveRoute(requestedModel(shaped), cfg) ?? passthrough(shaped);
214
+ const body = fitToolSearch(req, shaped, route);
213
215
  const conn = route.connection;
214
216
  log.info({ route: routeLabel(route), connection: conn.label, path }, 'gateway: routing');
215
217
  if (path === MESSAGES) noteServed({ connection: conn.id, provider: conn.provider, model: route.model, at: new Date().toISOString() });
@@ -0,0 +1,62 @@
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
+ import type { Route } from './model-config.js';
5
+
6
+ type Body = Record<string, unknown>;
7
+
8
+ const TOOL_SEARCH_BETA = /^(advanced-tool-use|tool-search-tool)-/;
9
+ const BEFORE_TOOL_REFERENCE = /^claude-3|^claude-(opus|sonnet)-4(-[01])?(-\d{8})?$/;
10
+
11
+ export const takesToolSearch = (route: Route): boolean =>
12
+ route.connection.provider === 'anthropic' && !BEFORE_TOOL_REFERENCE.test(route.model);
13
+
14
+ const isReference = (block: unknown): boolean => isRecord(block) && block.type === 'tool_reference';
15
+
16
+ const holdsReference = (block: unknown): boolean =>
17
+ isReference(block) || (isRecord(block) && block.type === 'tool_result' && Array.isArray(block.content) && block.content.some(isReference));
18
+
19
+ const referencing = (message: unknown): boolean => isRecord(message) && Array.isArray(message.content) && message.content.some(holdsReference);
20
+
21
+ const deferred = (tool: unknown): tool is Body => isRecord(tool) && tool.defer_loading !== undefined;
22
+
23
+ function flatBlock(block: unknown): unknown {
24
+ if (!isRecord(block)) return block;
25
+ if (block.type === 'tool_reference') return { type: 'text', text: `Tool ${stringOf(block.tool_name)} is loaded: call it directly.` };
26
+ if (block.type === 'tool_result' && Array.isArray(block.content)) return { ...block, content: block.content.map(flatBlock) };
27
+ return block;
28
+ }
29
+
30
+ const flatMessage = (message: unknown): unknown =>
31
+ isRecord(message) && Array.isArray(message.content) ? { ...message, content: message.content.map(flatBlock) } : message;
32
+
33
+ const loadedTool = (tool: unknown): unknown =>
34
+ deferred(tool) ? Object.fromEntries(Object.entries(tool).filter(([key]) => key !== 'defer_loading')) : tool;
35
+
36
+ export function withoutToolSearch(body: Body): Body {
37
+ const tools = Array.isArray(body.tools) ? body.tools : [];
38
+ const messages = Array.isArray(body.messages) ? body.messages : [];
39
+ if (!tools.some(deferred) && !messages.some(referencing)) return body;
40
+ return {
41
+ ...body,
42
+ ...(Array.isArray(body.tools) ? { tools: tools.map(loadedTool) } : {}),
43
+ ...(Array.isArray(body.messages) ? { messages: messages.map(flatMessage) } : {}),
44
+ };
45
+ }
46
+
47
+ function dropToolSearchBeta(req: IncomingMessage): void {
48
+ const raw = req.headers['anthropic-beta'];
49
+ if (typeof raw !== 'string') return;
50
+ const kept = raw
51
+ .split(',')
52
+ .map((beta) => beta.trim())
53
+ .filter((beta) => beta !== '' && !TOOL_SEARCH_BETA.test(beta));
54
+ if (kept.length > 0) req.headers['anthropic-beta'] = kept.join(',');
55
+ else delete req.headers['anthropic-beta'];
56
+ }
57
+
58
+ export function fitToolSearch(req: IncomingMessage, body: Body, route: Route): Body {
59
+ if (takesToolSearch(route)) return body;
60
+ dropToolSearchBeta(req);
61
+ return withoutToolSearch(body);
62
+ }
@@ -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)));
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.217"
2
+ "version": "0.1.0-beta.218"
3
3
  }