Ship the batch: undo, parallel subagents, lazy MCP, hot-reloaded skills; docs and CI/CD
Loop and ergonomics batch across Now/Next and Maintenance:
- /undo and /redo via pre-prompt file snapshots (snapshot.ts)
- task takes a tasks[] array and runs investigations concurrently (subagent.ts)
- lazy MCP tools: mcp_list/mcp_inspect/mcp_call meta-tools, eager opt-in (mcp.ts, config.ts)
- skill tool reads its list live so a mid-session install is callable next turn (skills.ts)
- tool-name lists (tool-kinds.ts) derived from a mutating() marker; gates previously ungated writes
- prune/session recovery path summarized, and step-back doom-loop primitive (step-back.ts)
- @file completion re-walks on a slow cooldown; estimateTokens and pricing labeled as estimates
Docs: README, CHANGELOG, docs/{mcp,architecture,development} updated to match.
CI/CD: bun install-store cache and concurrency gates on both workflows; release.yml now
composes file-based release notes via scripts/make-release-notes.ts and verifies every binary.
This commit is contained in:
+124
-10
@@ -1,27 +1,47 @@
|
||||
import { createMCPClient, type MCPClient } from '@ai-sdk/mcp';
|
||||
import { Experimental_StdioMCPTransport } from '@ai-sdk/mcp/mcp-stdio';
|
||||
import type { ToolSet } from 'ai';
|
||||
import { tool, type ToolSet } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type McpServerConfig =
|
||||
| { command: string; args?: string[]; env?: Record<string, string>; cwd?: string }
|
||||
| { url: string; type?: 'http' | 'sse'; headers?: Record<string, string> };
|
||||
|
||||
/**
|
||||
* How a server's tools reach the model.
|
||||
*
|
||||
* `eager` registers every tool with its schema up front — cheap for a two-tool
|
||||
* server, a tax for one that exposes twenty. `lazy` registers only the three
|
||||
* meta-tools below and fetches a server's tools on demand via `mcp_call`, so a
|
||||
* configured server costs almost nothing in the request until a tool is actually
|
||||
* invoked.
|
||||
*/
|
||||
export type McpMode = 'eager' | 'lazy';
|
||||
|
||||
export type McpHandle = {
|
||||
tools: ToolSet;
|
||||
errors: { server: string; message: string }[];
|
||||
/** Server names, for the prompt's MCP line. Empty when the mode is eager. */
|
||||
servers: string[];
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
const isRemote = (c: McpServerConfig): c is Extract<McpServerConfig, { url: string }> => 'url' in c;
|
||||
|
||||
/**
|
||||
* Connects every configured server and namespaces its tools as `mcp__<server>__<tool>`
|
||||
* so two servers exposing `search` cannot silently shadow each other.
|
||||
* A server that fails to start is reported, never fatal.
|
||||
* Connects every configured server and exposes its tools.
|
||||
*
|
||||
* In `eager` mode the tools land in the returned set as `mcp__<server>__<tool>`, so
|
||||
* two servers exposing `search` cannot silently shadow each other. In `lazy` mode the
|
||||
* set holds only the three meta-tools and `servers` names the configured servers; a
|
||||
* server that fails to start is reported, never fatal, in either mode.
|
||||
*/
|
||||
export async function connectMcp(servers: Record<string, McpServerConfig>): Promise<McpHandle> {
|
||||
export async function connectMcp(
|
||||
servers: Record<string, McpServerConfig>,
|
||||
mode: McpMode = 'lazy',
|
||||
): Promise<McpHandle> {
|
||||
const clients: MCPClient[] = [];
|
||||
const tools: ToolSet = {};
|
||||
const byName = new Map<string, MCPClient>();
|
||||
const errors: McpHandle['errors'] = [];
|
||||
|
||||
await Promise.all(
|
||||
@@ -38,9 +58,7 @@ export async function connectMcp(servers: Record<string, McpServerConfig>): Prom
|
||||
}),
|
||||
});
|
||||
clients.push(client);
|
||||
for (const [toolName, tool] of Object.entries(await client.tools())) {
|
||||
tools[`mcp__${name}__${toolName}`] = tool;
|
||||
}
|
||||
byName.set(name, client);
|
||||
} catch (e) {
|
||||
errors.push({ server: name, message: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
@@ -48,10 +66,106 @@ export async function connectMcp(servers: Record<string, McpServerConfig>): Prom
|
||||
);
|
||||
|
||||
return {
|
||||
tools,
|
||||
tools: mode === 'eager' ? await eagerTools(byName) : lazyTools(byName),
|
||||
errors,
|
||||
servers: mode === 'eager' ? [] : [...byName.keys()],
|
||||
close: async () => {
|
||||
await Promise.all(clients.map((c) => c.close().catch(() => {})));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Eager: every server tool gets an AI tool registered with its schema. */
|
||||
async function eagerTools(byName: Map<string, MCPClient>): Promise<ToolSet> {
|
||||
const tools: ToolSet = {};
|
||||
await Promise.all(
|
||||
[...byName.entries()].map(async ([name, client]) => {
|
||||
for (const [toolName, t] of Object.entries(await client.tools())) {
|
||||
tools[`mcp__${name}__${toolName}`] = t;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache of each client's AI tools, so `mcp_call` does not re-list on every call.
|
||||
* The WeakMap drops entries when a client (and its session) is closed and collected.
|
||||
*/
|
||||
const clientToolsCache = new WeakMap<MCPClient, ToolSet>();
|
||||
|
||||
/** Fetches a server's AI tools, or returns the cached set from a prior call. */
|
||||
async function cachedClientTools(client: MCPClient): Promise<ToolSet> {
|
||||
const cached = clientToolsCache.get(client);
|
||||
if (cached) return cached;
|
||||
const tools = await client.tools();
|
||||
clientToolsCache.set(client, tools);
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy: three meta-tools instead of every server schema.
|
||||
*
|
||||
* `mcp_list` names a server's tools from their definitions (cheap, no schema).
|
||||
* `mcp_inspect` reads one tool's schema so the model knows its inputs.
|
||||
* `mcp_call` executes one tool on its server, making the server's tools available
|
||||
* only from the moment they are actually invoked.
|
||||
*/
|
||||
function lazyTools(byName: Map<string, MCPClient>): ToolSet {
|
||||
const connectionError = (name: string) =>
|
||||
byName.has(name) ? undefined : `unknown server "${name}". Configured: ${[...byName.keys()].join(', ') || 'none'}`;
|
||||
|
||||
return {
|
||||
mcp_list: tool({
|
||||
description: `List the tools exposed by an MCP server. Servers: ${[...byName.keys()].join(', ') || 'none'}.`,
|
||||
inputSchema: z.object({ server: z.string().describe('Server name from your instructions') }),
|
||||
execute: async ({ server }) => {
|
||||
const err = connectionError(server);
|
||||
if (err) throw new Error(err);
|
||||
const client = byName.get(server)!;
|
||||
const defs = await client.listTools();
|
||||
return defs.tools.length === 0
|
||||
? `server "${server}" exposes no tools`
|
||||
: defs.tools.map((d) => `- ${d.name}: ${d.description ?? 'no description'}`).join('\n');
|
||||
},
|
||||
}),
|
||||
mcp_inspect: tool({
|
||||
description: `Inspect one tool's input schema on an MCP server. Servers: ${[...byName.keys()].join(', ') || 'none'}.`,
|
||||
inputSchema: z.object({
|
||||
server: z.string().describe('Server name from your instructions'),
|
||||
toolName: z.string().describe('Tool name, as listed by mcp_list'),
|
||||
}),
|
||||
execute: async ({ server, toolName }) => {
|
||||
const err = connectionError(server);
|
||||
if (err) throw new Error(err);
|
||||
const client = byName.get(server)!;
|
||||
const defs = await client.listTools();
|
||||
const def = defs.tools.find((d) => d.name === toolName);
|
||||
if (!def) throw new Error(`No tool "${toolName}" on "${server}". List first with mcp_list.`);
|
||||
return def.inputSchema ? JSON.stringify(def.inputSchema, null, 2) : `tool "${toolName}" declares no input schema`;
|
||||
},
|
||||
}),
|
||||
mcp_call: tool({
|
||||
description:
|
||||
`Call one tool on an MCP server. Inspect its schema with mcp_inspect first. ` +
|
||||
`Servers: ${[...byName.keys()].join(', ') || 'none'}.`,
|
||||
inputSchema: z.object({
|
||||
server: z.string().describe('Server name from your instructions'),
|
||||
toolName: z.string().describe('Tool name, as listed by mcp_list'),
|
||||
args: z.record(z.string(), z.unknown()).describe('Arguments the tool expects, from mcp_inspect'),
|
||||
}),
|
||||
execute: async ({ server, toolName, args }) => {
|
||||
const err = connectionError(server);
|
||||
if (err) throw new Error(err);
|
||||
const client = byName.get(server)!;
|
||||
const serverTools = await cachedClientTools(client);
|
||||
const aiTool = serverTools[toolName];
|
||||
if (!aiTool)
|
||||
throw new Error(
|
||||
`No tool "${toolName}" on "${server}". List first with mcp_list (server exposes: ${Object.keys(serverTools).join(', ') || 'none'}).`,
|
||||
);
|
||||
return aiTool.execute!(args, { toolCallId: 'mcp_call', messages: [] } as never);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user