From 7215dddbb9696a5743975d18a8d6e65b8b326839 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 12:06:37 -0700 Subject: [PATCH 01/39] feat(mcp): serve native data tools over stdio without an LLM Add `altimate-code mcp serve`, a stdio MCP server that lets any MCP client (e.g. Claude Code) call altimate-code's data tools directly while the client's own model does the reasoning. The server never calls a model. - Exposes `warehouse_list`, `schema_inspect`, `sql_execute`, `sql_check` and `sql_materialize`, reusing the existing implementations with short tool descriptions. - Read-only by default: the `sql_execute_write` permission is denied unless `--allow-write` is passed; reads are auto-allowed. - Binds a local session at start-up so results do not carry the workspace-routing notice; forwards MCP cancellation to tool calls; `sql_materialize` keeps a session-local scratch warehouse. - Spill hints tell MCP clients to open the spill path with any file reader instead of naming the agent-only `read` tool. - `--connections ` or normal connection discovery; stdout carries only MCP protocol messages; no telemetry start-up. Co-Authored-By: Claude Opus 5.5 --- docs/docs/configure/mcp-servers.md | 31 +++ .../src/altimate/mcp-server/adapter.ts | 123 +++++++++++ .../src/altimate/mcp-server/server.ts | 99 +++++++++ .../src/altimate/tools/sql-execute.ts | 1 + .../src/altimate/tools/sql-result-format.ts | 6 +- .../src/altimate/workspace/precedence.ts | 10 + packages/opencode/src/cli/cmd/mcp.ts | 17 ++ packages/opencode/src/index.ts | 3 + .../test/altimate/mcp-server/server.test.ts | 209 ++++++++++++++++++ .../test/altimate/mcp-server/stdio.test.ts | 84 +++++++ 10 files changed, 582 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/altimate/mcp-server/adapter.ts create mode 100644 packages/opencode/src/altimate/mcp-server/server.ts create mode 100644 packages/opencode/test/altimate/mcp-server/server.test.ts create mode 100644 packages/opencode/test/altimate/mcp-server/stdio.test.ts diff --git a/docs/docs/configure/mcp-servers.md b/docs/docs/configure/mcp-servers.md index c2b860e7b1..b0ce1b4367 100644 --- a/docs/docs/configure/mcp-servers.md +++ b/docs/docs/configure/mcp-servers.md @@ -6,6 +6,37 @@ description: "Configure Model Context Protocol (MCP) servers in Altimate Code to altimate supports the Model Context Protocol (MCP) for connecting to external tool servers. +## Use Altimate Code tools from another MCP client + +Run `altimate-code mcp serve` to expose data tools over stdio. Your MCP client's model handles reasoning; this server runs tools directly without calling a model. + +Add this entry to your MCP client's configuration (for example, Claude Code's `.mcp.json`): + +```json +{ + "mcpServers": { + "altimate-code": { + "command": "altimate-code", + "args": ["mcp", "serve", "--connections", "/absolute/path/to/connections.json"] + } + } +} +``` + +The server exposes exactly `warehouse_list`, `schema_inspect`, `sql_execute`, `sql_check`, and `sql_materialize`. A connections file is a JSON object keyed by connection name: + +```json +{ + "local": { "type": "sqlite", "path": "./analytics.sqlite" } +} +``` + +Relative database paths resolve beside this file. Omit `--connections` to use normal connection discovery: global and project `.altimate-code/connections.json` files, followed by `ALTIMATE_CODE_CONN_*` environment overrides. Set your client's working directory to the project when using project discovery. + +Source writes are refused by default. Add `--allow-write` to enable SQL write permissions; existing hard safety blocks still apply. `sql_materialize` always requires a read-only source query and writes only into the server session's temporary `scratch` warehouse. Scratch tables disappear when the server exits. Large results may include a local spill file path that your client can open with any file reader. + +The server writes only MCP protocol messages to stdout and sends logs to stderr. It does not initialize telemetry and honors `ALTIMATE_TELEMETRY_DISABLED`. + ## Local MCP Servers Run an MCP server as a local subprocess: diff --git a/packages/opencode/src/altimate/mcp-server/adapter.ts b/packages/opencode/src/altimate/mcp-server/adapter.ts new file mode 100644 index 0000000000..22a606b5c6 --- /dev/null +++ b/packages/opencode/src/altimate/mcp-server/adapter.ts @@ -0,0 +1,123 @@ +import { Effect, Layer } from "effect" +import { mkdir, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { randomUUID } from "node:crypto" +import { Agent } from "@/agent/agent" +import { Tool } from "@/tool/tool" +import { Truncate } from "@/tool/truncate" +import * as TruncateCore from "@/tool/truncate-core" +import { WarehouseListTool } from "../tools/warehouse-list" +import { SchemaInspectTool } from "../tools/schema-inspect" +import { SqlExecuteTool } from "../tools/sql-execute" +import { SqlCheckTool } from "../tools/sql-check" +import { SqlMaterializeTool } from "../tools/sql-materialize" +import * as Precedence from "../workspace/precedence" +import { closeScratch } from "../native/connections/scratch" +import type { SessionID, MessageID } from "@/session/schema" + +export const descriptions: Record = { + warehouse_list: "List configured warehouse connections and their database types.", + schema_inspect: "Inspect tables and columns in a connected warehouse, with optional sample values.", + sql_execute: + "Execute SQL against a connected warehouse and return rows. Source writes require the server's --allow-write option.", + sql_check: "Run a read-only SQL data quality check against a connected warehouse.", + sql_materialize: "Copy a read-only source query into a session-local scratch table for further analysis.", +} + +// These services intentionally have no agent runtime, configuration loading, or provider dependencies. +const analyst = { name: "analyst", mode: "primary", permission: [], options: {} } as Agent.Info +const truncation: Truncate.Interface = { + cleanup: () => Effect.void, + limits: () => Effect.succeed({ maxLines: TruncateCore.MAX_LINES, maxBytes: TruncateCore.MAX_BYTES }), + write: (text) => + Effect.promise(async () => { + const directory = path.join(tmpdir(), "altimate-code-mcp-results") + await mkdir(directory, { recursive: true, mode: 0o700 }) + const file = path.join(directory, `${randomUUID()}.txt`) + await writeFile(file, text, { mode: 0o600 }) + return file + }), + output: (text, options = {}) => + Effect.gen(function* () { + const maxLines = options.maxLines ?? TruncateCore.MAX_LINES + const maxBytes = options.maxBytes ?? TruncateCore.MAX_BYTES + const lines = text.split("\n") + const bytes = Buffer.byteLength(text) + if (TruncateCore.fits(lines, bytes, maxLines, maxBytes)) return { content: text, truncated: false as const } + const direction = options.direction ?? TruncateCore.DEFAULT_DIRECTION + const preview = TruncateCore.preview(lines, bytes, { + maxLines, + maxBytes, + direction, + headRatio: options.headRatio ?? TruncateCore.DEFAULT_HEAD_RATIO, + }) + const file = yield* truncation.write(text) + return { + content: TruncateCore.assemble( + preview, + `Full output saved to: ${file}\nOpen this path with any file reader to inspect the remaining output.`, + direction, + ), + truncated: true as const, + outputPath: file, + } + }), +} + +const services = Layer.mergeAll( + Layer.succeed( + Agent.Service, + Agent.Service.of({ + get: () => Effect.succeed(analyst), + list: () => Effect.succeed([analyst]), + defaultInfo: () => Effect.succeed(analyst), + defaultAgent: () => Effect.succeed(analyst.name), + generate: () => Effect.die(new Error("MCP data tools never generate agents or call a model")), + }), + ), + Layer.succeed(Truncate.Service, truncation), +) + +export async function createAdapter(options: { allowWrite?: boolean } = {}) { + const sessionID = `mcp-${randomUUID()}` as SessionID + Precedence.bindLocalSession(sessionID) + const tools = await Effect.runPromise( + Effect.gen(function* () { + const result = new Map() + for (const source of [WarehouseListTool, SchemaInspectTool, SqlExecuteTool, SqlCheckTool, SqlMaterializeTool]) { + const info = yield* source + result.set(info.id, yield* Tool.init(info, { agent: analyst })) + } + return result + }).pipe(Effect.provide(services)), + ) + + return { + tools, + async execute(name: string, args: Record, signal: AbortSignal) { + signal.throwIfAborted() + const tool = tools.get(name) + if (!tool) throw new Error(`Unknown tool: ${name}`) + const ctx: Tool.Context = { + sessionID, + messageID: `mcp-${randomUUID()}` as MessageID, + agent: "analyst", + abort: signal, + extra: { mcpClient: true }, + messages: [], + metadata: () => Effect.void, + // Fail closed for new permission kinds. sql_execute is the validated source-read permission. + ask: ({ permission }) => + permission === "sql_execute" || permission === "schema_inspect" || options.allowWrite + ? Effect.void + : Effect.die(new Error(`Permission denied: ${permission}. Restart with --allow-write to enable writes.`)), + } + return Effect.runPromise(tool.execute(args, ctx)) + }, + async close() { + await closeScratch(sessionID) + Precedence.releaseLocalSession(sessionID) + }, + } +} diff --git a/packages/opencode/src/altimate/mcp-server/server.ts b/packages/opencode/src/altimate/mcp-server/server.ts new file mode 100644 index 0000000000..1fd85dfc5b --- /dev/null +++ b/packages/opencode/src/altimate/mcp-server/server.ts @@ -0,0 +1,99 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" +import { CallToolRequestSchema, ListToolsRequestSchema, type Tool as McpTool } from "@modelcontextprotocol/sdk/types.js" +import { readFile } from "node:fs/promises" +import path from "node:path" +import { isLocalFilePath, type ConnectionConfig } from "@altimateai/drivers" +import * as Registry from "../native/connections/registry" +import { createAdapter, descriptions } from "./adapter" +import { InstallationVersion } from "@opencode-ai/core/installation/version" + +export interface ServeOptions { + connections?: string + allowWrite?: boolean +} + +export async function createServer(options: ServeOptions = {}) { + if (options.connections) { + const file = path.resolve(options.connections) + const configs: unknown = JSON.parse(await readFile(file, "utf8")) + if (!configs || typeof configs !== "object" || Array.isArray(configs)) + throw new Error("Connections file must contain an object keyed by connection name") + const resolved: Record = {} + for (const [name, value] of Object.entries(configs)) { + if (!value || typeof value !== "object" || typeof value.type !== "string") + throw new Error(`Invalid connection: ${name}`) + const config = { ...value } as ConnectionConfig + if ( + ["duckdb", "sqlite"].includes(config.type.toLowerCase()) && + typeof config.path === "string" && + isLocalFilePath(config.path) && + !path.isAbsolute(config.path) && + !path.win32.isAbsolute(config.path) + ) { + config.path = path.resolve(path.dirname(file), config.path) + } + resolved[name] = config + } + Registry.setConfigs(resolved) + } + const adapter = await createAdapter(options) + const server = new Server({ name: "altimate-code", version: InstallationVersion }, { capabilities: { tools: {} } }) + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [...adapter.tools].map(([name, tool]) => ({ + name, + description: descriptions[name], + inputSchema: tool.jsonSchema as McpTool["inputSchema"], + })), + })) + server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + try { + const result = await adapter.execute(request.params.name, request.params.arguments ?? {}, extra.signal) + const isError = result.status === "error" || (result.metadata.success !== true && Boolean(result.metadata.error)) + return { content: [{ type: "text" as const, text: result.output }], isError } + } catch (error) { + return { + content: [{ type: "text" as const, text: error instanceof Error ? error.message : String(error) }], + isError: true, + } + } + }) + server.onclose = async () => { + await adapter.close().catch(console.error) + } + return server +} + +export async function serveStdio(options: ServeOptions = {}): Promise { + // Protocol writes use the transport's stdout directly; application logging must use stderr. + console.log = console.error.bind(console) + console.info = console.error.bind(console) + console.debug = console.error.bind(console) + const server = await createServer(options) + server.onerror = (error) => console.error(error) + const closed = Promise.withResolvers() + const onclose = server.onclose + server.onclose = async () => { + try { + await onclose?.() + await Registry.closeAll() + closed.resolve() + } catch (error) { + closed.reject(error) + } + } + const close = () => { + void server.close().catch(closed.reject) + } + process.stdin.once("end", close) + process.once("SIGINT", close) + process.once("SIGTERM", close) + try { + await server.connect(new StdioServerTransport()) + await closed.promise + } finally { + process.stdin.off("end", close) + process.off("SIGINT", close) + process.off("SIGTERM", close) + } +} diff --git a/packages/opencode/src/altimate/tools/sql-execute.ts b/packages/opencode/src/altimate/tools/sql-execute.ts index 2af69625b3..b78d49e037 100644 --- a/packages/opencode/src/altimate/tools/sql-execute.ts +++ b/packages/opencode/src/altimate/tools/sql-execute.ts @@ -360,6 +360,7 @@ export const SqlExecuteTool = Tool.define( const suggestion = ctx.agent === "analyst" ? null : PostConnectSuggestions.getProgressiveSuggestion("sql_execute") const lint = await lintSqlResult(sql, args.warehouse, ctx.sessionID) const formatted = await formatSqlResult(result, { + mcpClient: ctx.extra?.mcpClient === true, limit, fullTextColumns: args.full_text_columns, // The statement text lets the formatter add the empty/near-empty diff --git a/packages/opencode/src/altimate/tools/sql-result-format.ts b/packages/opencode/src/altimate/tools/sql-result-format.ts index 89ca714961..0f9b544caa 100644 --- a/packages/opencode/src/altimate/tools/sql-result-format.ts +++ b/packages/opencode/src/altimate/tools/sql-result-format.ts @@ -6,6 +6,7 @@ export const SQL_MAX_PREVIEW_BYTES = 16 * 1024 export const SQL_MAX_SPILL_BYTES = 32 * 1024 * 1024 interface FormatOptions { + mcpClient?: boolean limit: number maxBytes?: number maxLines?: number @@ -520,7 +521,10 @@ export async function formatSqlResult(result: SqlExecuteResult, options: FormatO const chunks: string[] = [] serializeRows(result, (chunk) => chunks.push(chunk)) metadata.result_path = await options.write(chunks.join("")) - spill = `All ${result.rows.length} returned rows saved to ${metadata.result_path}. JSONL text: columns/values per row; $bigint, $date (ISO), $bytes (base64) tags; strings unchanged. Use read with char_offset/max_chars to recover any clipped text.` + const reader = options.mcpClient + ? "Open this path with any file reader to recover clipped text." + : "Use read with char_offset/max_chars to recover any clipped text." + spill = `All ${result.rows.length} returned rows saved to ${metadata.result_path}. JSONL text: columns/values per row; $bigint, $date (ISO), $bytes (base64) tags; strings unchanged. ${reader}` } catch { spill = `Spill failed (write/serialization error or ${SQL_MAX_SPILL_BYTES / 1024 / 1024} MiB size limit); returned rows were not saved.` } diff --git a/packages/opencode/src/altimate/workspace/precedence.ts b/packages/opencode/src/altimate/workspace/precedence.ts index ff070b578e..ff706be23b 100644 --- a/packages/opencode/src/altimate/workspace/precedence.ts +++ b/packages/opencode/src/altimate/workspace/precedence.ts @@ -190,6 +190,16 @@ const EMPTY = (reason: Precedence["disabledReason"], workspaceName = "", workspa /** Per-session precedence, refreshed once per turn by the tool resolver and read * (never recomputed) by tool bodies mid-turn. */ const bySession = new Map() + +/** A standalone native-tool server deliberately uses local drivers, without attaching an engine. */ +export function bindLocalSession(sessionID: string): void { + remember(sessionID, EMPTY("escape-hatch")) +} + +export function releaseLocalSession(sessionID: string): void { + bySession.delete(sessionID) + incarnations.delete(sessionID) +} /** One token per session incarnation: set when a session is first remembered or * recreated after eviction, dropped with it. A delivery records against the * incarnation it was published for, so ordinary refreshes (which replace the diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 2fd3c56eaa..ae293db694 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -108,6 +108,9 @@ export const McpCommand = cmd({ yargs .command(McpAddCommand) .command(McpListCommand) + // altimate_change start — expose native data tools over stdio without an agent + .command(McpServeCommand) + // altimate_change end // altimate_change start — upstream_fix (#790): `status` is the name people reach for when a // server will not connect, and it was the one name that did not exist. It shares the list // handler rather than duplicating a view that already probes live and already prints the @@ -124,6 +127,20 @@ export const McpCommand = cmd({ async handler() {}, }) +// altimate_change start — standalone native-tool MCP server +export const McpServeCommand = cmd({ + command: "serve", + describe: "serve data tools over MCP stdio (no model required)", + builder: (yargs) => yargs + .option("connections", { type: "string", describe: "Connections JSON file" }) + .option("allow-write", { type: "boolean", default: false, describe: "Allow SQL write permissions" }), + async handler(args) { + const { serveStdio } = await import("../../altimate/mcp-server/server") + await serveStdio({ connections: args.connections, allowWrite: args.allowWrite }) + }, +}) +// altimate_change end + // altimate_change start — upstream_fix (#878/#701): config-level diagnostics, shared by every // exit of `mcp list` / `mcp status` so a config with nothing listable still reports them. function reportConfigDiagnostics() { diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index a9adcc16bb..3312876467 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -135,6 +135,9 @@ let cli = yargs(args) }) // altimate_change end .middleware(async (opts) => { + // altimate_change start — MCP stdio needs no banner, telemetry startup or agent initialization + if (opts._[0] === "mcp" && opts._[1] === "serve") return + // altimate_change end if (opts.printLogs) process.env.OPENCODE_PRINT_LOGS = "1" if (opts.logLevel) process.env.OPENCODE_LOG_LEVEL = opts.logLevel if (opts.pure) { diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts new file mode 100644 index 0000000000..c2e3bc573d --- /dev/null +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -0,0 +1,209 @@ +import { afterEach, expect, spyOn, test } from "bun:test" +import { Database } from "bun:sqlite" +import { mkdtemp, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" +import { createServer } from "../../../src/altimate/mcp-server/server" +import * as Registry from "../../../src/altimate/native/connections/registry" +import type { Connector } from "@altimateai/drivers/types" +import { closeScratch } from "../../../src/altimate/native/connections/scratch" + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup() + await closeScratch() + await Registry.closeAll() + Registry.reset() +}) + +async function fixture(allowWrite = false) { + const dir = await mkdtemp(path.join(os.tmpdir(), "mcp-tools-test-")) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + const dbPath = path.join(dir, "source.sqlite") + const db = new Database(dbPath) + db.run("CREATE TABLE items (id INTEGER, name TEXT)") + db.run("INSERT INTO items VALUES (1, 'apple'), (2, 'pear')") + db.close() + const config = path.join(dir, "connections.json") + await Bun.write(config, JSON.stringify({ source: { type: "sqlite", path: "source.sqlite" } })) + const server = await createServer({ allowWrite, connections: config }) + const client = new Client({ name: "test", version: "1" }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + await client.connect(clientTransport) + cleanups.push(async () => { + await client.close() + await server.close() + await Registry.closeAll() + }) + const call = (name: string, args: Record) => client.callTool({ name, arguments: args }) + return { client, call } +} + +function text(result: Awaited>) { + return JSON.stringify(result.content) +} + +test("lists exactly five tools and executes warehouse, schema, SQL and checks without routing notices", async () => { + const { client, call } = await fixture() + const listed = await client.listTools() + expect(listed.tools.map((t) => t.name).sort()).toEqual([ + "schema_inspect", + "sql_check", + "sql_execute", + "sql_materialize", + "warehouse_list", + ]) + expect(listed.tools.every((t) => t.inputSchema.type === "object")).toBe(true) + for (const [name, args, expected] of [ + ["warehouse_list", {}, "source"], + ["schema_inspect", { warehouse: "source", table: "items", include_samples: false }, "name"], + ["sql_execute", { warehouse: "source", query: "SELECT * FROM items" }, "apple"], + ["sql_check", { warehouse: "source", check: "nulls", sql: "SELECT * FROM items", columns: ["id"] }, "null"], + ] as const) { + const result = await call(name, args) + expect(result.isError).toBe(false) + expect(text(result)).toContain(expected) + expect(text(result)).not.toContain("Not routed through the bound workspace") + } +}) + +test("source writes are denied by default and cannot modify the source", async () => { + const { call } = await fixture() + const denied = await call("sql_execute", { warehouse: "source", query: "INSERT INTO items VALUES (3, 'plum')" }) + expect(denied.isError).toBe(true) + expect(text(denied)).toContain("--allow-write") + const rows = await call("sql_execute", { warehouse: "source", query: "SELECT COUNT(*) AS total FROM items" }) + expect(rows.isError).toBe(false) + expect(text(rows)).toContain("2") +}) + +test("allow-write permits SQL mutations", async () => { + const { call } = await fixture(true) + expect( + (await call("sql_execute", { warehouse: "source", query: "INSERT INTO items VALUES (3, 'plum')" })).isError, + ).toBe(false) + expect( + text(await call("sql_execute", { warehouse: "source", query: "SELECT name FROM items WHERE id=3" })), + ).toContain("plum") +}) + +test("materializes read-only sources into scratch, refuses source mutations", async () => { + const { call } = await fixture() + const result = await call("sql_materialize", { + source_warehouse: "source", + sql: "SELECT * FROM items", + target_table: "copied", + }) + expect(result.isError).toBe(false) + expect(text(result)).not.toContain("Not routed through the bound workspace") + const rows = await call("sql_execute", { warehouse: "scratch", query: "SELECT * FROM copied" }) + expect(rows.isError).toBe(false) + expect(text(rows)).toContain("apple") + const denied = await call("sql_materialize", { + source_warehouse: "source", + sql: "DELETE FROM items", + target_table: "bad", + }) + expect(denied.isError).toBe(true) +}) + +test("MCP cancellation reaches the active tool abort signal", async () => { + const { client } = await fixture() + const started = Promise.withResolvers() + const stopped = Promise.withResolvers() + const original = Registry.get + const execute: Connector["execute"] = (_sql, _limit, _binds, options) => { + started.resolve(options!.signal!) + return new Promise((_resolve, reject) => + options!.signal!.addEventListener( + "abort", + () => { + stopped.resolve() + reject(new Error("Cancelled")) + }, + { once: true }, + ), + ) + } + const dispatch = spyOn(Registry, "get").mockImplementation(async (name) => + name === "source" ? ({ execute, supportsResultByteLimit: true } as Connector) : original(name), + ) + try { + const abort = new AbortController() + const request = client.callTool( + { name: "sql_execute", arguments: { warehouse: "source", query: "SELECT * FROM items" } }, + undefined, + { signal: abort.signal }, + ) + const rejected = request.then( + () => { + throw new Error("Expected cancellation rejection") + }, + (error) => error, + ) + const signal = await started.promise + abort.abort(new Error("Client cancelled")) + expect(await rejected).toBeInstanceOf(Error) + await stopped.promise + expect(signal.aborted).toBe(true) + } finally { + dispatch.mockRestore() + } +}) + +test("spill output gives MCP-compatible file-reader instructions", async () => { + const { call } = await fixture() + const result = await call("sql_execute", { + warehouse: "source", + query: "SELECT name FROM items", + full_text_columns: ["name"], + }) + expect(result.isError).toBe(false) + expect(text(result)).toContain("any file reader") + expect(text(result)).not.toContain("char_offset") + expect(text(result)).not.toContain("Task tool") +}) + +test("generic truncation saves output with client-neutral hints", async () => { + const { call } = await fixture() + const listing = spyOn(Registry, "list").mockReturnValue({ + warehouses: Array.from({ length: 2500 }, (_, i) => ({ name: `warehouse_${i}`, type: "sqlite" })), + }) + try { + const result = await call("warehouse_list", {}) + expect(result.isError).toBe(false) + expect(text(result)).toContain("Full output saved to:") + expect(text(result)).toContain("any file reader") + expect(text(result)).not.toContain("Task tool") + expect(text(result)).not.toContain("offset/limit") + } finally { + listing.mockRestore() + } +}) + +test("server startup and tool listing make no network calls", async () => { + const fetch = spyOn(globalThis, "fetch").mockImplementation( + Object.assign( + () => { + throw new Error("Unexpected network call") + }, + { preconnect: globalThis.fetch.preconnect }, + ), + ) + try { + const { client } = await fixture() + expect((await client.listTools()).tools).toHaveLength(5) + expect(fetch).not.toHaveBeenCalled() + } finally { + fetch.mockRestore() + } +}) + +test("malformed arguments and unknown tools return MCP errors", async () => { + const { call } = await fixture() + expect((await call("unknown", {})).isError).toBe(true) + expect((await call("sql_execute", { warehouse: "source", query: 42 })).isError).toBe(true) +}) diff --git a/packages/opencode/test/altimate/mcp-server/stdio.test.ts b/packages/opencode/test/altimate/mcp-server/stdio.test.ts new file mode 100644 index 0000000000..8eb678e92d --- /dev/null +++ b/packages/opencode/test/altimate/mcp-server/stdio.test.ts @@ -0,0 +1,84 @@ +import { expect, test } from "bun:test" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" +import { mkdtemp, rm } from "node:fs/promises" +import path from "node:path" +import os from "node:os" +import { Database } from "bun:sqlite" + +for (const allowWrite of [false, true]) { + test(`CLI stdio handshake, read and write policy (allow-write=${allowWrite})`, async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "mcp-stdio-test-")) + const db = new Database(path.join(dir, "db.sqlite")) + db.run("CREATE TABLE items (id INTEGER)") + db.close() + const connections = path.join(dir, "connections.json") + await Bun.write(connections, JSON.stringify({ source: { type: "sqlite", path: "db.sqlite" } })) + // Any network attempt, including module initialization, fails this test. stdout must contain only MCP frames. + const guard = path.join(dir, "no-network.ts") + await Bun.write( + guard, + 'globalThis.fetch = (() => { process.stderr.write("UNEXPECTED_NETWORK_CALL\\n"); throw new Error("No network allowed") }) as typeof fetch\n', + ) + const transport = new StdioClientTransport({ + command: process.execPath, + args: [ + "run", + "--conditions=browser", + "--preload", + guard, + "src/index.ts", + "mcp", + "serve", + "--connections", + connections, + ...(allowWrite ? ["--allow-write"] : []), + ], + cwd: path.resolve(import.meta.dir, "../../.."), + stderr: "pipe", + env: { ...process.env, ALTIMATE_TELEMETRY_DISABLED: "1" } as Record, + }) + let stderr = "" + transport.stderr?.on("data", (data) => { + stderr += data.toString() + }) + const client = new Client({ name: "stdio-test", version: "1" }) + try { + await client.connect(transport) + expect((await client.listTools()).tools).toHaveLength(5) + const write = await client.callTool({ + name: "sql_execute", + arguments: { warehouse: "source", query: "INSERT INTO items VALUES (7)" }, + }) + expect(write.isError).toBe(!allowWrite) + const read = await client.callTool({ + name: "sql_execute", + arguments: { warehouse: "source", query: "SELECT COUNT(*) AS n FROM items" }, + }) + expect(read.isError).toBe(false) + expect(JSON.stringify(read.content)).toContain(allowWrite ? "1" : "0") + } finally { + await client.close() + await rm(dir, { recursive: true, force: true }) + } + expect(stderr).not.toContain("UNEXPECTED_NETWORK_CALL") + }, 15_000) +} + +test("CLI exits cleanly when the client closes stdin", async () => { + const process = Bun.spawn([Bun.which("bun")!, "run", "--conditions=browser", "src/index.ts", "mcp", "serve"], { + cwd: path.resolve(import.meta.dir, "../../.."), + env: { ...Bun.env, ALTIMATE_TELEMETRY_DISABLED: "1" }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }) + process.stdin.end() + try { + expect(await process.exited).toBe(0) + expect(await new Response(process.stdout).text()).toBe("") + expect(await new Response(process.stderr).text()).toBe("") + } finally { + process.kill() + } +}, 15_000) From d88dd208359146583f5413730e404b8126ba9e9e Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 12:59:26 -0700 Subject: [PATCH 02/39] fix(mcp): enforce source read-only queries and clean up server output - Without `--allow-write`, every source query from the MCP tools requests database-enforced read-only where the engine supports it (Postgres read-only transaction, SQLite `query_only`, DuckDB read-only open). Engines without database-level read-only are still served, but `warehouse_list`, a stderr notice at start-up and the docs say plainly that stored functions with side effects are not blocked and a read-only database user is recommended. - Spill output goes to a private per-process directory that is removed when the server closes, through the shared truncation service (user output limits and failure wording preserved). - MCP-facing text names only the exposed tools: no `lineage_check` tip from `schema_inspect`, and client-neutral `full_text_columns` guidance. - `--connections` uses the registry's path resolution and rejects the reserved `scratch` name at start-up; docs state the file replaces discovery. `--print-logs`/`--log-level` now apply to `mcp serve`. - Tests: in-process MCP server tests need real drivers, so they run in a dedicated CI step (`ALTIMATE_MCP_E2E=1`); the allow-write read-back and middleware-skip regressions now fail when the behaviour regresses. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 8 + docs/docs/configure/mcp-servers.md | 8 +- .../src/altimate/mcp-server/adapter.ts | 136 +++++----- .../altimate/mcp-server/output-settings.ts | 48 ++++ .../src/altimate/mcp-server/server.ts | 44 ++-- .../altimate/native/connections/read-only.ts | 7 + .../altimate/native/connections/register.ts | 4 +- .../altimate/native/connections/registry.ts | 21 ++ .../src/altimate/native/query-execution.ts | 2 + .../src/altimate/tools/schema-inspect.ts | 2 +- .../src/altimate/tools/schema-values.ts | 4 +- .../src/altimate/tools/sql-execute.ts | 4 +- .../src/altimate/tools/sql-materialize.ts | 4 +- .../src/altimate/tools/warehouse-list.ts | 6 + packages/opencode/src/index.ts | 4 +- packages/opencode/src/tool/truncate.ts | 199 ++++++++------- .../test/altimate/mcp-server/server.test.ts | 240 +++++++++++++++++- .../test/altimate/mcp-server/stdio.test.ts | 43 +++- 18 files changed, 577 insertions(+), 207 deletions(-) create mode 100644 packages/opencode/src/altimate/mcp-server/output-settings.ts create mode 100644 packages/opencode/src/altimate/native/connections/read-only.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 154e240ef6..827218ea84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -512,6 +512,14 @@ jobs: env: ALTIMATE_DUCKDB_E2E: "1" + # Same reason as above: the in-process MCP server tests use real SQLite/DuckDB drivers, which the + # whole-package run replaces with module mocks, so they run in their own process. + - name: Run MCP server tests (real drivers, no mocks) + run: bun test --timeout 90000 test/altimate/mcp-server/server.test.ts + working-directory: packages/opencode + env: + ALTIMATE_MCP_E2E: "1" + - name: Run Docker driver E2E (MySQL, SQL Server, Redshift) run: bun test test/altimate/drivers-docker-e2e.test.ts working-directory: packages/opencode diff --git a/docs/docs/configure/mcp-servers.md b/docs/docs/configure/mcp-servers.md index b0ce1b4367..f618507371 100644 --- a/docs/docs/configure/mcp-servers.md +++ b/docs/docs/configure/mcp-servers.md @@ -31,9 +31,13 @@ The server exposes exactly `warehouse_list`, `schema_inspect`, `sql_execute`, `s } ``` -Relative database paths resolve beside this file. Omit `--connections` to use normal connection discovery: global and project `.altimate-code/connections.json` files, followed by `ALTIMATE_CODE_CONN_*` environment overrides. Set your client's working directory to the project when using project discovery. +Relative database paths resolve beside this file. With `--connections`, this file is the only source of connections: global and project `connections.json` files and `ALTIMATE_CODE_CONN_*` overrides are ignored. Omit `--connections` to use normal connection discovery: global and project `.altimate-code/connections.json` files, followed by `ALTIMATE_CODE_CONN_*` environment overrides. Set your client's working directory to the project when using project discovery. -Source writes are refused by default. Add `--allow-write` to enable SQL write permissions; existing hard safety blocks still apply. `sql_materialize` always requires a read-only source query and writes only into the server session's temporary `scratch` warehouse. Scratch tables disappear when the server exits. Large results may include a local spill file path that your client can open with any file reader. + + +Source writes are refused by default. Without `--allow-write`, source queries request database-enforced read-only execution on PostgreSQL, SQLite and DuckDB. For other connection types, read-only is enforced by SQL classification only; stored functions with side effects are not blocked — use a read-only database user. The server prints this caveat on stderr at startup and includes it in `warehouse_list` for each affected connection. Add `--allow-write` to enable SQL write permissions; existing hard safety blocks still apply. `sql_materialize` always requires a read-only source query and writes only into the server session's temporary `scratch` warehouse. Scratch tables disappear when the server exits. Large results may include a local spill file path that your client can open with any file reader. Spill files use a private directory and are deleted when the server exits. Local `tool_output.max_lines` and `tool_output.max_bytes` settings control output limits; remote agent configuration is not loaded. + + The server writes only MCP protocol messages to stdout and sends logs to stderr. It does not initialize telemetry and honors `ALTIMATE_TELEMETRY_DISABLED`. diff --git a/packages/opencode/src/altimate/mcp-server/adapter.ts b/packages/opencode/src/altimate/mcp-server/adapter.ts index 22a606b5c6..0e1eef103c 100644 --- a/packages/opencode/src/altimate/mcp-server/adapter.ts +++ b/packages/opencode/src/altimate/mcp-server/adapter.ts @@ -1,12 +1,14 @@ +import { FSUtil } from "@opencode-ai/core/fs-util" +import { NodePath } from "@effect/platform-node" import { Effect, Layer } from "effect" -import { mkdir, writeFile } from "node:fs/promises" +import { mkdtemp, chmod, rm } from "node:fs/promises" import { tmpdir } from "node:os" import path from "node:path" import { randomUUID } from "node:crypto" import { Agent } from "@/agent/agent" import { Tool } from "@/tool/tool" import { Truncate } from "@/tool/truncate" -import * as TruncateCore from "@/tool/truncate-core" +import { outputLimits } from "./output-settings" import { WarehouseListTool } from "../tools/warehouse-list" import { SchemaInspectTool } from "../tools/schema-inspect" import { SqlExecuteTool } from "../tools/sql-execute" @@ -25,77 +27,67 @@ export const descriptions: Record = { sql_materialize: "Copy a read-only source query into a session-local scratch table for further analysis.", } -// These services intentionally have no agent runtime, configuration loading, or provider dependencies. +// No agent runtime or provider initialization; only local output settings are loaded. const analyst = { name: "analyst", mode: "primary", permission: [], options: {} } as Agent.Info -const truncation: Truncate.Interface = { - cleanup: () => Effect.void, - limits: () => Effect.succeed({ maxLines: TruncateCore.MAX_LINES, maxBytes: TruncateCore.MAX_BYTES }), - write: (text) => - Effect.promise(async () => { - const directory = path.join(tmpdir(), "altimate-code-mcp-results") - await mkdir(directory, { recursive: true, mode: 0o700 }) - const file = path.join(directory, `${randomUUID()}.txt`) - await writeFile(file, text, { mode: 0o600 }) - return file - }), - output: (text, options = {}) => - Effect.gen(function* () { - const maxLines = options.maxLines ?? TruncateCore.MAX_LINES - const maxBytes = options.maxBytes ?? TruncateCore.MAX_BYTES - const lines = text.split("\n") - const bytes = Buffer.byteLength(text) - if (TruncateCore.fits(lines, bytes, maxLines, maxBytes)) return { content: text, truncated: false as const } - const direction = options.direction ?? TruncateCore.DEFAULT_DIRECTION - const preview = TruncateCore.preview(lines, bytes, { - maxLines, - maxBytes, - direction, - headRatio: options.headRatio ?? TruncateCore.DEFAULT_HEAD_RATIO, - }) - const file = yield* truncation.write(text) - return { - content: TruncateCore.assemble( - preview, - `Full output saved to: ${file}\nOpen this path with any file reader to inspect the remaining output.`, - direction, - ), - truncated: true as const, - outputPath: file, - } - }), +function services(directory: string, limits: Awaited>) { + return Layer.mergeAll( + Layer.succeed( + Agent.Service, + Agent.Service.of({ + get: () => Effect.succeed(analyst), + list: () => Effect.succeed([analyst]), + defaultInfo: () => Effect.succeed(analyst), + defaultAgent: () => Effect.succeed(analyst.name), + generate: () => Effect.die(new Error("MCP data tools never generate agents or call a model")), + }), + ), + Truncate.makeLayer({ + directory, + limits: () => Effect.succeed(limits), + hint: (file, outcome) => + `The tool call ${outcome === "error" ? "failed" : "succeeded"} but the output was truncated. Full output saved to: ${file}\nOpen this path with any file reader to inspect the remaining output.`, + }).pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(NodePath.layer)), + ) } -const services = Layer.mergeAll( - Layer.succeed( - Agent.Service, - Agent.Service.of({ - get: () => Effect.succeed(analyst), - list: () => Effect.succeed([analyst]), - defaultInfo: () => Effect.succeed(analyst), - defaultAgent: () => Effect.succeed(analyst.name), - generate: () => Effect.die(new Error("MCP data tools never generate agents or call a model")), - }), - ), - Layer.succeed(Truncate.Service, truncation), -) - export async function createAdapter(options: { allowWrite?: boolean } = {}) { + const limits = await outputLimits() + const directory = await mkdtemp(path.join(tmpdir(), "altimate-code-mcp-results-")) + await chmod(directory, 0o700) const sessionID = `mcp-${randomUUID()}` as SessionID Precedence.bindLocalSession(sessionID) const tools = await Effect.runPromise( - Effect.gen(function* () { - const result = new Map() - for (const source of [WarehouseListTool, SchemaInspectTool, SqlExecuteTool, SqlCheckTool, SqlMaterializeTool]) { - const info = yield* source - result.set(info.id, yield* Tool.init(info, { agent: analyst })) - } - return result - }).pipe(Effect.provide(services)), - ) + Effect.scoped( + Effect.gen(function* () { + const result = new Map() + for (const source of [WarehouseListTool, SchemaInspectTool, SqlExecuteTool, SqlCheckTool, SqlMaterializeTool]) { + const info = yield* source + const tool = yield* Tool.init(info, { agent: analyst }) + if (tool.jsonSchema) { + tool.jsonSchema = JSON.parse( + JSON.stringify(tool.jsonSchema).replaceAll( + "use read with char_offset/max_chars to recover clipped text", + "open result_path with any file reader to recover clipped text", + ), + ) + } + result.set(info.id, tool) + } + return result + }).pipe(Effect.provide(services(directory, limits))), + ), + ).catch(async (error) => { + Precedence.releaseLocalSession(sessionID) + await rm(directory, { recursive: true, force: true }) + throw error + }) + const shutdown = new AbortController() + const active = new Set>() return { tools, async execute(name: string, args: Record, signal: AbortSignal) { + signal = AbortSignal.any([signal, shutdown.signal]) signal.throwIfAborted() const tool = tools.get(name) if (!tool) throw new Error(`Unknown tool: ${name}`) @@ -104,7 +96,7 @@ export async function createAdapter(options: { allowWrite?: boolean } = {}) { messageID: `mcp-${randomUUID()}` as MessageID, agent: "analyst", abort: signal, - extra: { mcpClient: true }, + extra: { mcpClient: true, forceReadOnly: !options.allowWrite }, messages: [], metadata: () => Effect.void, // Fail closed for new permission kinds. sql_execute is the validated source-read permission. @@ -113,11 +105,23 @@ export async function createAdapter(options: { allowWrite?: boolean } = {}) { ? Effect.void : Effect.die(new Error(`Permission denied: ${permission}. Restart with --allow-write to enable writes.`)), } - return Effect.runPromise(tool.execute(args, ctx)) + const pending = Effect.runPromise(tool.execute(args, ctx)) + active.add(pending) + try { + return await pending + } finally { + active.delete(pending) + } }, async close() { - await closeScratch(sessionID) - Precedence.releaseLocalSession(sessionID) + shutdown.abort(new Error("MCP server closed")) + await Promise.allSettled([...active]) + try { + await closeScratch(sessionID) + } finally { + Precedence.releaseLocalSession(sessionID) + await rm(directory, { recursive: true, force: true }) + } }, } } diff --git a/packages/opencode/src/altimate/mcp-server/output-settings.ts b/packages/opencode/src/altimate/mcp-server/output-settings.ts new file mode 100644 index 0000000000..62dfa7b193 --- /dev/null +++ b/packages/opencode/src/altimate/mcp-server/output-settings.ts @@ -0,0 +1,48 @@ +import { readFile } from "node:fs/promises" +import path from "node:path" +import { parse } from "jsonc-parser" +import { Global } from "@/global" +import { MAX_LINES, MAX_BYTES } from "@/tool/truncate-core" + +// Read only output settings: loading the full agent config can install dependencies +// or fetch remote configuration, neither of which belongs in this standalone server. +export async function outputLimits() { + const limits = { maxLines: MAX_LINES, maxBytes: MAX_BYTES } + const apply = (text: string) => { + const output = parse(text)?.tool_output + if (Number.isInteger(output?.max_lines) && output.max_lines > 0) limits.maxLines = output.max_lines + if (Number.isInteger(output?.max_bytes) && output.max_bytes > 0) limits.maxBytes = output.max_bytes + } + const load = async (file: string) => { + const text = await readFile(file, "utf8").catch((error) => { + if (error.code === "ENOENT") return undefined + throw error + }) + if (text) apply(text) + } + for (const name of ["config.json", "opencode.json", "opencode.jsonc", "altimate-code.json", "altimate-code.jsonc"]) { + await load(path.join(Global.Path.config, name)) + } + if (process.env.OPENCODE_CONFIG) await load(process.env.OPENCODE_CONFIG) + if (process.env.OPENCODE_DISABLE_PROJECT_CONFIG !== "1" && process.env.OPENCODE_DISABLE_PROJECT_CONFIG !== "true") { + const ancestors = [] + for (let directory = process.cwd(); ; directory = path.dirname(directory)) { + ancestors.unshift(directory) + if (directory === path.dirname(directory)) break + } + for (const directory of ancestors) { + for (const base of [directory, path.join(directory, ".opencode"), path.join(directory, ".altimate-code")]) { + for (const name of ["opencode.jsonc", "opencode.json", "altimate-code.jsonc", "altimate-code.json"]) { + await load(path.join(base, name)) + } + } + } + } + if (process.env.OPENCODE_CONFIG_DIR) { + for (const name of ["opencode.jsonc", "opencode.json", "altimate-code.jsonc", "altimate-code.json"]) { + await load(path.join(process.env.OPENCODE_CONFIG_DIR, name)) + } + } + if (process.env.OPENCODE_CONFIG_CONTENT) apply(process.env.OPENCODE_CONFIG_CONTENT) + return limits +} diff --git a/packages/opencode/src/altimate/mcp-server/server.ts b/packages/opencode/src/altimate/mcp-server/server.ts index 1fd85dfc5b..2ee45a2e47 100644 --- a/packages/opencode/src/altimate/mcp-server/server.ts +++ b/packages/opencode/src/altimate/mcp-server/server.ts @@ -1,11 +1,9 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js" import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { CallToolRequestSchema, ListToolsRequestSchema, type Tool as McpTool } from "@modelcontextprotocol/sdk/types.js" -import { readFile } from "node:fs/promises" -import path from "node:path" -import { isLocalFilePath, type ConnectionConfig } from "@altimateai/drivers" import * as Registry from "../native/connections/registry" import { createAdapter, descriptions } from "./adapter" +import { readOnlyCaveat } from "../native/connections/read-only" import { InstallationVersion } from "@opencode-ai/core/installation/version" export interface ServeOptions { @@ -14,28 +12,12 @@ export interface ServeOptions { } export async function createServer(options: ServeOptions = {}) { - if (options.connections) { - const file = path.resolve(options.connections) - const configs: unknown = JSON.parse(await readFile(file, "utf8")) - if (!configs || typeof configs !== "object" || Array.isArray(configs)) - throw new Error("Connections file must contain an object keyed by connection name") - const resolved: Record = {} - for (const [name, value] of Object.entries(configs)) { - if (!value || typeof value !== "object" || typeof value.type !== "string") - throw new Error(`Invalid connection: ${name}`) - const config = { ...value } as ConnectionConfig - if ( - ["duckdb", "sqlite"].includes(config.type.toLowerCase()) && - typeof config.path === "string" && - isLocalFilePath(config.path) && - !path.isAbsolute(config.path) && - !path.win32.isAbsolute(config.path) - ) { - config.path = path.resolve(path.dirname(file), config.path) - } - resolved[name] = config + if (options.connections) await Registry.loadFile(options.connections) + if (!options.allowWrite) { + for (const connection of Registry.list().warehouses) { + const caveat = readOnlyCaveat(connection) + if (caveat) console.error(caveat) } - Registry.setConfigs(resolved) } const adapter = await createAdapter(options) const server = new Server({ name: "altimate-code", version: InstallationVersion }, { capabilities: { tools: {} } }) @@ -58,8 +40,18 @@ export async function createServer(options: ServeOptions = {}) { } } }) + let cleanup: Promise | undefined + const dispose = () => (cleanup ??= adapter.close()) server.onclose = async () => { - await adapter.close().catch(console.error) + await dispose().catch(console.error) + } + const close = server.close.bind(server) + server.close = async () => { + try { + await close() + } finally { + await dispose() + } } return server } @@ -95,5 +87,7 @@ export async function serveStdio(options: ServeOptions = {}): Promise { process.stdin.off("end", close) process.off("SIGINT", close) process.off("SIGTERM", close) + await server.close() + await Registry.closeAll() } } diff --git a/packages/opencode/src/altimate/native/connections/read-only.ts b/packages/opencode/src/altimate/native/connections/read-only.ts new file mode 100644 index 0000000000..bf64f9c842 --- /dev/null +++ b/packages/opencode/src/altimate/native/connections/read-only.ts @@ -0,0 +1,7 @@ +/** Drivers that enforce ExecuteOptions.readOnly at the database boundary. */ +const ENFORCED = new Set(["postgres", "postgresql", "sqlite", "duckdb"]) + +export function readOnlyCaveat(connection: { name: string; type: string }): string | undefined { + if (ENFORCED.has(connection.type.toLowerCase())) return undefined + return `${connection.name} (${connection.type}): read-only is enforced by SQL classification only; stored functions with side effects are not blocked — use a read-only database user` +} diff --git a/packages/opencode/src/altimate/native/connections/register.ts b/packages/opencode/src/altimate/native/connections/register.ts index a763ef0036..5761b38342 100644 --- a/packages/opencode/src/altimate/native/connections/register.ts +++ b/packages/opencode/src/altimate/native/connections/register.ts @@ -525,7 +525,7 @@ register("sql.execute", async (params: SqlExecuteParams, context): Promise { diff --git a/packages/opencode/src/altimate/native/connections/registry.ts b/packages/opencode/src/altimate/native/connections/registry.ts index b3f11b7895..c71dab5d7e 100644 --- a/packages/opencode/src/altimate/native/connections/registry.ts +++ b/packages/opencode/src/altimate/native/connections/registry.ts @@ -219,6 +219,27 @@ export function load(): void { loaded = true } +/** Load only an explicit connections file, resolving stores relative to that file. */ +export async function loadFile(file: string): Promise { + await closeAll() + const absolute = path.resolve(file) + const parsed: unknown = JSON.parse(await fs.promises.readFile(absolute, "utf8")) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Connections file must contain an object keyed by connection name") + } + for (const [name, value] of Object.entries(parsed)) { + if (!value || typeof value !== "object" || typeof value.type !== "string") { + throw new Error(`Invalid connection: ${name}`) + } + } + configs = new Map( + Object.entries(resolveStorePaths(parsed as Record, path.dirname(absolute))), + ) + if (configs.has(SCRATCH_NAME)) Log.Default.warn(SCRATCH_NAME_CONFLICT) + loaded = true + censusSent = false +} + /** Ensure configs are loaded. */ function ensureLoaded(): void { if (!loaded) load() diff --git a/packages/opencode/src/altimate/native/query-execution.ts b/packages/opencode/src/altimate/native/query-execution.ts index 84e3817c14..701e14b21e 100644 --- a/packages/opencode/src/altimate/native/query-execution.ts +++ b/packages/opencode/src/altimate/native/query-execution.ts @@ -6,6 +6,8 @@ import { RunBudget } from "../run-budget" export interface QueryContext { signal?: AbortSignal sessionID?: string + /** MCP source reads request database protection even when SQL classifies as a read. */ + forceReadOnly?: boolean } /** Share the session cutoff across probes, extraction and scratch writes. */ diff --git a/packages/opencode/src/altimate/tools/schema-inspect.ts b/packages/opencode/src/altimate/tools/schema-inspect.ts index 2474a76939..bfbb9ae2a5 100644 --- a/packages/opencode/src/altimate/tools/schema-inspect.ts +++ b/packages/opencode/src/altimate/tools/schema-inspect.ts @@ -133,7 +133,7 @@ export const SchemaInspectTool = Tool.define("schema_inspect", { ) if (profile) output += "\n\n" + profile } - const suggestion = PostConnectSuggestions.getProgressiveSuggestion("schema_inspect") + const suggestion = ctx.extra?.mcpClient ? null : PostConnectSuggestions.getProgressiveSuggestion("schema_inspect") if (suggestion) { output += "\n\n" + suggestion PostConnectSuggestions.trackSuggestions({ diff --git a/packages/opencode/src/altimate/tools/schema-values.ts b/packages/opencode/src/altimate/tools/schema-values.ts index 7cf7e1a9d1..10e5a48abd 100644 --- a/packages/opencode/src/altimate/tools/schema-values.ts +++ b/packages/opencode/src/altimate/tools/schema-values.ts @@ -134,7 +134,7 @@ export interface ValueProfileInput { /** Never throws: a failed profile becomes a one-line note. */ export async function profileValues( input: ValueProfileInput, - ctx: Pick & { abort?: AbortSignal }, + ctx: Pick & { abort?: AbortSignal }, ): Promise { try { const list = (await Dispatcher.call("warehouse.list", {})) as unknown @@ -165,7 +165,7 @@ export async function profileValues( limit: PROFILE_MAX_COLUMNS + 1, query_timeout_ms: PROFILE_TIMEOUT_MS, ...(dialect === "postgres" ? { read_only: true } : {}), - }, { signal: ctx.abort, sessionID: ctx.sessionID })) as unknown + }, { signal: ctx.abort, sessionID: ctx.sessionID, ...(ctx.extra?.forceReadOnly === true ? { forceReadOnly: true } : {}) })) as unknown const data = isRecord(res) && isRecord(res.data) ? res.data : res const error = normalizeError(isRecord(res) ? res.error : undefined) ?? normalizeError(isRecord(data) ? data.error : undefined) if (error) throw new Error(error) diff --git a/packages/opencode/src/altimate/tools/sql-execute.ts b/packages/opencode/src/altimate/tools/sql-execute.ts index b78d49e037..81c6f58244 100644 --- a/packages/opencode/src/altimate/tools/sql-execute.ts +++ b/packages/opencode/src/altimate/tools/sql-execute.ts @@ -140,7 +140,7 @@ export const SqlExecuteTool = Tool.define( ...(Registry.canonicalType(Registry.getConfig(source.warehouse)?.type) === "postgres" ? { read_only: true } : {}), - }, { signal: ctx.abort, sessionID: ctx.sessionID }) + }, { signal: ctx.abort, sessionID: ctx.sessionID, ...(ctx.extra?.forceReadOnly === true ? { forceReadOnly: true } : {}) }) const error = normalizeError(result.error) if (error !== undefined) { throw new Error(`query_from source failed: ${error.trim() || "SQL execution failed."}`) @@ -281,7 +281,7 @@ export const SqlExecuteTool = Tool.define( limit, query_timeout_ms: args.query_timeout_ms, ...(readOnly ? { read_only: true } : {}), - }, { signal: ctx.abort, sessionID: ctx.sessionID }) + }, { signal: ctx.abort, sessionID: ctx.sessionID, ...(ctx.extra?.forceReadOnly === true ? { forceReadOnly: true } : {}) }) // altimate_change start — a failure must not be rendered as "(0 rows)". // sql.execute never throws: it catches every connection and query error diff --git a/packages/opencode/src/altimate/tools/sql-materialize.ts b/packages/opencode/src/altimate/tools/sql-materialize.ts index 298c924269..698a9d3674 100644 --- a/packages/opencode/src/altimate/tools/sql-materialize.ts +++ b/packages/opencode/src/altimate/tools/sql-materialize.ts @@ -242,7 +242,7 @@ async function executeSource(source: Connector, input: MaterializeInput, sql: st const options = { queryTimeoutMs: input.query_timeout_ms, maxResultBytes: maxBytes, - ...(type === "postgres" ? { readOnly: true } : {}), + ...(type === "postgres" || context?.forceReadOnly ? { readOnly: true } : {}), } return executeQuery(config, options, context, (bounded) => source.execute(sql, limit, undefined, bounded)) } @@ -503,7 +503,7 @@ export const SqlMaterializeTool = Tool.define("sql_materialize", { if (verdict.redirect) return verdict.redirect // altimate_change end try { - const r = await materialize(args, { signal: ctx.abort, sessionID: ctx.sessionID }) + const r = await materialize(args, { signal: ctx.abort, sessionID: ctx.sessionID, ...(ctx.extra?.forceReadOnly === true ? { forceReadOnly: true } : {}) }) const lint = await lintSqlResult(args.sql, args.source_warehouse, ctx.sessionID) const checks = lint ? "\n\n" + lint.output : "" const lintMetadata = lint ? { lint_rules: lint.rules } : {} diff --git a/packages/opencode/src/altimate/tools/warehouse-list.ts b/packages/opencode/src/altimate/tools/warehouse-list.ts index 21dcdfab46..71c3105e3a 100644 --- a/packages/opencode/src/altimate/tools/warehouse-list.ts +++ b/packages/opencode/src/altimate/tools/warehouse-list.ts @@ -1,4 +1,5 @@ import z from "zod" +import { readOnlyCaveat } from "../native/connections/read-only" import { Tool } from "../../tool/tool" import { Dispatcher } from "../native" // altimate_change start — workspace precedence @@ -38,6 +39,11 @@ export const WarehouseListTool = Tool.define("warehouse_list", { lines.push(shadowedCount ? `${row} | ${notes.get(wh.name) ?? "local"}` : row) } + if (ctx.extra?.forceReadOnly) { + const caveats = warehouses.map(readOnlyCaveat).filter((note): note is string => !!note) + if (caveats.length) lines.push("", ...caveats) + } + return { title: `Warehouses: ${warehouses.length} configured`, metadata: { count: warehouses.length, shadowed: shadowedCount }, diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 3312876467..b68256cf08 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -135,11 +135,11 @@ let cli = yargs(args) }) // altimate_change end .middleware(async (opts) => { + if (opts.printLogs) process.env.OPENCODE_PRINT_LOGS = "1" + if (opts.logLevel) process.env.OPENCODE_LOG_LEVEL = opts.logLevel // altimate_change start — MCP stdio needs no banner, telemetry startup or agent initialization if (opts._[0] === "mcp" && opts._[1] === "serve") return // altimate_change end - if (opts.printLogs) process.env.OPENCODE_PRINT_LOGS = "1" - if (opts.logLevel) process.env.OPENCODE_LOG_LEVEL = opts.logLevel if (opts.pure) { process.env.OPENCODE_PURE = "1" } diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index d3a8a3b0b4..f74ce1646e 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -54,98 +54,119 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Truncate") {} -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* FSUtil.Service - - const cleanup = Effect.fn("Truncate.cleanup")(function* () { - // altimate_change start — upstream_fix: age files by mtime, not decoded ID timestamps. - // Identifier packs `timestamp * 4096` into 48 bits, which wraps every - // 2^36 ms (~795 days) — the 26th wrap landed 2026-08-14T11:19:55Z, after - // which every new ID decoded as "ancient" and cleanup deleted files the - // moment they were written. File mtime has no wrap. - const cutoffMs = Date.now() - Duration.toMillis(RETENTION) - const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe( - Effect.map((all) => all.filter((name) => name.startsWith("tool_"))), - Effect.catch(() => Effect.succeed([])), - ) - for (const entry of entries) { - const file = path.join(TRUNCATION_DIR, entry) - // Stat through the INJECTED filesystem (FSUtil extends FileSystem) so - // custom/in-memory providers behave identically to the host FS. - const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined))) - // Unstat-able file or absent mtime: keep it — deletion must fail safe. - const mtimeMs = - info && Option.isSome(info.mtime) ? info.mtime.value.getTime() : Number.POSITIVE_INFINITY - if (mtimeMs >= cutoffMs) continue - yield* fs.remove(file).pipe(Effect.catch(() => Effect.void)) - } +// altimate_change start — let standalone MCP sessions share output handling with private storage and neutral hints. +export function makeLayer( + options: { + directory?: string + limits?: Interface["limits"] + hint?: (file: string, outcome: "success" | "error") => string + } = {}, +) { + const directory = options.directory ?? TRUNCATION_DIR + return Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + + const cleanup = Effect.fn("Truncate.cleanup")(function* () { + // altimate_change start — upstream_fix: age files by mtime, not decoded ID timestamps. + // Identifier packs `timestamp * 4096` into 48 bits, which wraps every + // 2^36 ms (~795 days) — the 26th wrap landed 2026-08-14T11:19:55Z, after + // which every new ID decoded as "ancient" and cleanup deleted files the + // moment they were written. File mtime has no wrap. + const cutoffMs = Date.now() - Duration.toMillis(RETENTION) + const entries = yield* fs.readDirectory(directory).pipe( + Effect.map((all) => all.filter((name) => name.startsWith("tool_"))), + Effect.catch(() => Effect.succeed([])), + ) + for (const entry of entries) { + const file = path.join(directory, entry) + // Stat through the INJECTED filesystem (FSUtil extends FileSystem) so + // custom/in-memory providers behave identically to the host FS. + const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined))) + // Unstat-able file or absent mtime: keep it — deletion must fail safe. + const mtimeMs = info && Option.isSome(info.mtime) ? info.mtime.value.getTime() : Number.POSITIVE_INFINITY + if (mtimeMs >= cutoffMs) continue + yield* fs.remove(file).pipe(Effect.catch(() => Effect.void)) + } + // altimate_change end + }) + + const write = Effect.fn("Truncate.write")(function* (text: string) { + const file = path.join(directory, ToolID.ascending()) + yield* fs.ensureDir(directory).pipe(Effect.orDie) + yield* fs.writeFileString(file, text).pipe(Effect.orDie) + return file + }) + + const limits = Effect.fn("Truncate.limits")(function* () { + if (options.limits) return yield* options.limits() + const configSvc = yield* Effect.serviceOption(Config.Service) + if (Option.isNone(configSvc)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES } + const cfg = yield* configSvc.value.get().pipe(Effect.catch(() => Effect.succeed(undefined))) + return { + maxLines: cfg?.tool_output?.max_lines ?? MAX_LINES, + maxBytes: cfg?.tool_output?.max_bytes ?? MAX_BYTES, + } + }) + + // altimate_change start — default direction "middle" (head+tail, + // tail-weighted elision) via the shared truncate-core.ts algorithm. + const output = Effect.fn("Truncate.output")(function* ( + text: string, + outputOptions: Options = {}, + agent?: Agent.Info, + ) { + const resolved = yield* limits() + const maxLines = outputOptions.maxLines ?? resolved.maxLines + const maxBytes = outputOptions.maxBytes ?? resolved.maxBytes + const direction = outputOptions.direction ?? TruncateCore.DEFAULT_DIRECTION + const headRatio = outputOptions.headRatio ?? TruncateCore.DEFAULT_HEAD_RATIO + const lines = text.split("\n") + const totalBytes = Buffer.byteLength(text, "utf-8") + + if (TruncateCore.fits(lines, totalBytes, maxLines, maxBytes)) { + return { content: text, truncated: false } as const + } + + const preview = TruncateCore.preview(lines, totalBytes, { maxLines, maxBytes, direction, headRatio }) + const file = yield* write(text) + + const subagent = options.hint ? undefined : yield* Effect.promise(() => taskSubagent(agent)) + const outcome = outputOptions.outcome === "error" ? "failed" : "succeeded" + const hint = options.hint + ? options.hint(file, outputOptions.outcome ?? "success") + : subagent + ? `The tool call ${outcome} but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have ${subagent} agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` + : `The tool call ${outcome} but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.` + + return { + content: TruncateCore.assemble(preview, hint, direction), + truncated: true, + outputPath: file, + } as const + }) // altimate_change end - }) - - const write = Effect.fn("Truncate.write")(function* (text: string) { - const file = path.join(TRUNCATION_DIR, ToolID.ascending()) - yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie) - yield* fs.writeFileString(file, text).pipe(Effect.orDie) - return file - }) - - const limits = Effect.fn("Truncate.limits")(function* () { - const configSvc = yield* Effect.serviceOption(Config.Service) - if (Option.isNone(configSvc)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES } - const cfg = yield* configSvc.value.get().pipe(Effect.catch(() => Effect.succeed(undefined))) - return { - maxLines: cfg?.tool_output?.max_lines ?? MAX_LINES, - maxBytes: cfg?.tool_output?.max_bytes ?? MAX_BYTES, - } - }) - - // altimate_change start — default direction "middle" (head+tail, - // tail-weighted elision) via the shared truncate-core.ts algorithm. - const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) { - const resolved = yield* limits() - const maxLines = options.maxLines ?? resolved.maxLines - const maxBytes = options.maxBytes ?? resolved.maxBytes - const direction = options.direction ?? TruncateCore.DEFAULT_DIRECTION - const headRatio = options.headRatio ?? TruncateCore.DEFAULT_HEAD_RATIO - const lines = text.split("\n") - const totalBytes = Buffer.byteLength(text, "utf-8") - - if (TruncateCore.fits(lines, totalBytes, maxLines, maxBytes)) { - return { content: text, truncated: false } as const - } - - const preview = TruncateCore.preview(lines, totalBytes, { maxLines, maxBytes, direction, headRatio }) - const file = yield* write(text) - - const subagent = yield* Effect.promise(() => taskSubagent(agent)) - const outcome = options.outcome === "error" ? "failed" : "succeeded" - const hint = subagent - ? `The tool call ${outcome} but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have ${subagent} agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` - : `The tool call ${outcome} but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.` - - return { - content: TruncateCore.assemble(preview, hint, direction), - truncated: true, - outputPath: file, - } as const - }) - // altimate_change end - - yield* cleanup().pipe( - Effect.catchCause((cause) => Effect.logError("truncation cleanup failed", { cause: Cause.pretty(cause) })), - Effect.repeat(Schedule.spaced(Duration.hours(1))), - Effect.delay(Duration.minutes(1)), - Effect.forkScoped, - ) - - return Service.of({ cleanup, write, output, limits }) - }), -) + + yield* cleanup().pipe( + Effect.catchCause((cause) => Effect.logError("truncation cleanup failed", { cause: Cause.pretty(cause) })), + Effect.repeat(Schedule.spaced(Duration.hours(1))), + Effect.delay(Duration.minutes(1)), + Effect.forkScoped, + ) + + return Service.of({ cleanup, write, output, limits }) + }), + ) +} + +export const layer = makeLayer() +// altimate_change end // altimate_change start — Layer.suspend defers facade refs past circular module-init -export const defaultLayer = Layer.suspend(() => layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(NodePath.layer))) +export const defaultLayer = Layer.suspend(() => + layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(NodePath.layer)), +) // altimate_change end // altimate_change start — thunk LayerNode deps defers facade refs past circular module-init diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index c2e3bc573d..1a0a4ad145 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -1,15 +1,22 @@ +// altimate_change start — standalone MCP enforcement and lifecycle regressions import { afterEach, expect, spyOn, test } from "bun:test" import { Database } from "bun:sqlite" -import { mkdtemp, rm } from "node:fs/promises" +import { mkdtemp, rm, stat, readFile } from "node:fs/promises" import os from "node:os" import path from "node:path" import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" import { createServer } from "../../../src/altimate/mcp-server/server" import * as Registry from "../../../src/altimate/native/connections/registry" +import { Dispatcher } from "../../../src/altimate/native" import type { Connector } from "@altimateai/drivers/types" import { closeScratch } from "../../../src/altimate/native/connections/scratch" +// These tests need real SQLite/DuckDB drivers in their own `bun test` process: files elsewhere in +// test/altimate install top-level mock.module("@altimateai/drivers/duckdb", …), which a whole-package +// run evaluates first. CI runs them in a dedicated step with ALTIMATE_MCP_E2E=1. +const mcpTest = process.env["ALTIMATE_MCP_E2E"] === "1" ? test : test.skip + const cleanups: (() => Promise)[] = [] afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup() @@ -39,14 +46,14 @@ async function fixture(allowWrite = false) { await Registry.closeAll() }) const call = (name: string, args: Record) => client.callTool({ name, arguments: args }) - return { client, call } + return { client, call, server } } function text(result: Awaited>) { return JSON.stringify(result.content) } -test("lists exactly five tools and executes warehouse, schema, SQL and checks without routing notices", async () => { +mcpTest("lists exactly five tools and executes warehouse, schema, SQL and checks without routing notices", async () => { const { client, call } = await fixture() const listed = await client.listTools() expect(listed.tools.map((t) => t.name).sort()).toEqual([ @@ -57,20 +64,22 @@ test("lists exactly five tools and executes warehouse, schema, SQL and checks wi "warehouse_list", ]) expect(listed.tools.every((t) => t.inputSchema.type === "object")).toBe(true) + expect(JSON.stringify(listed.tools)).not.toMatch(/char_offset|lineage_check|tool_lookup|Task tool/) for (const [name, args, expected] of [ ["warehouse_list", {}, "source"], - ["schema_inspect", { warehouse: "source", table: "items", include_samples: false }, "name"], + ["schema_inspect", { warehouse: "source", table: "items", values: false }, "name"], ["sql_execute", { warehouse: "source", query: "SELECT * FROM items" }, "apple"], ["sql_check", { warehouse: "source", check: "nulls", sql: "SELECT * FROM items", columns: ["id"] }, "null"], ] as const) { const result = await call(name, args) expect(result.isError).toBe(false) expect(text(result)).toContain(expected) + expect(text(result)).not.toMatch(/lineage_check|tool_lookup|char_offset|Task tool/) expect(text(result)).not.toContain("Not routed through the bound workspace") } }) -test("source writes are denied by default and cannot modify the source", async () => { +mcpTest("source writes are denied by default and cannot modify the source", async () => { const { call } = await fixture() const denied = await call("sql_execute", { warehouse: "source", query: "INSERT INTO items VALUES (3, 'plum')" }) expect(denied.isError).toBe(true) @@ -80,7 +89,7 @@ test("source writes are denied by default and cannot modify the source", async ( expect(text(rows)).toContain("2") }) -test("allow-write permits SQL mutations", async () => { +mcpTest("allow-write permits SQL mutations", async () => { const { call } = await fixture(true) expect( (await call("sql_execute", { warehouse: "source", query: "INSERT INTO items VALUES (3, 'plum')" })).isError, @@ -90,7 +99,7 @@ test("allow-write permits SQL mutations", async () => { ).toContain("plum") }) -test("materializes read-only sources into scratch, refuses source mutations", async () => { +mcpTest("materializes read-only sources into scratch, refuses source mutations", async () => { const { call } = await fixture() const result = await call("sql_materialize", { source_warehouse: "source", @@ -110,7 +119,7 @@ test("materializes read-only sources into scratch, refuses source mutations", as expect(denied.isError).toBe(true) }) -test("MCP cancellation reaches the active tool abort signal", async () => { +mcpTest("MCP cancellation reaches the active tool abort signal", async () => { const { client } = await fixture() const started = Promise.withResolvers() const stopped = Promise.withResolvers() @@ -154,7 +163,7 @@ test("MCP cancellation reaches the active tool abort signal", async () => { } }) -test("spill output gives MCP-compatible file-reader instructions", async () => { +mcpTest("spill output gives MCP-compatible file-reader instructions", async () => { const { call } = await fixture() const result = await call("sql_execute", { warehouse: "source", @@ -167,7 +176,7 @@ test("spill output gives MCP-compatible file-reader instructions", async () => { expect(text(result)).not.toContain("Task tool") }) -test("generic truncation saves output with client-neutral hints", async () => { +mcpTest("generic truncation saves output with client-neutral hints", async () => { const { call } = await fixture() const listing = spyOn(Registry, "list").mockReturnValue({ warehouses: Array.from({ length: 2500 }, (_, i) => ({ name: `warehouse_${i}`, type: "sqlite" })), @@ -184,7 +193,7 @@ test("generic truncation saves output with client-neutral hints", async () => { } }) -test("server startup and tool listing make no network calls", async () => { +mcpTest("server startup and tool listing make no network calls", async () => { const fetch = spyOn(globalThis, "fetch").mockImplementation( Object.assign( () => { @@ -202,8 +211,215 @@ test("server startup and tool listing make no network calls", async () => { } }) -test("malformed arguments and unknown tools return MCP errors", async () => { +mcpTest("malformed arguments and unknown tools return MCP errors", async () => { const { call } = await fixture() expect((await call("unknown", {})).isError).toBe(true) expect((await call("sql_execute", { warehouse: "source", query: 42 })).isError).toBe(true) }) + +// altimate_change end + +// altimate_change start — database protection, disclosures, spill lifecycle +function body(result: Awaited>) { + return (result.content as { text: string }[]).map((part) => part.text).join("\n") +} + +mcpTest("SQLite MCP SELECT runs with database query_only enabled", async () => { + const { call } = await fixture() + const result = await call("sql_execute", { + warehouse: "source", + query: + "SELECT CASE WHEN query_only = 1 THEN 'database protected' ELSE 'unprotected' END AS protection FROM pragma_query_only()", + }) + expect(result.isError).toBe(false) + expect(text(result)).toContain("database protected") +}) + +mcpTest("MCP forces database protection for every SQLite source query path", async () => { + const { call } = await fixture() + const original = Registry.get + const options: unknown[] = [] + const get = spyOn(Registry, "get").mockImplementation(async (name) => { + const connector = await original(name) + if (name !== "source") return connector + return { + ...connector, + execute: (sql, limit, binds, opts) => { + options.push(opts) + return connector.execute(sql, limit, binds, opts) + }, + } as Connector + }) + try { + for (const [tool, args] of [ + ["sql_execute", { warehouse: "source", query: "SELECT * FROM items" }], + [ + "sql_execute", + { warehouse: "source", query_from: { warehouse: "source", sql: "SELECT 'SELECT * FROM items' AS sql" } }, + ], + ["schema_inspect", { warehouse: "source", table: "items", values: true }], + ["sql_check", { warehouse: "source", check: "nulls", sql: "SELECT * FROM items", columns: ["id"] }], + ["sql_materialize", { source_warehouse: "source", sql: "SELECT * FROM items", target_table: "protected_copy" }], + ] as const) { + options.length = 0 + const result = await call(tool, args) + expect(result.isError).toBe(false) + expect(options.length).toBeGreaterThan(0) + for (const option of options) expect(option).toMatchObject({ readOnly: true }) + } + } finally { + get.mockRestore() + } +}) + +mcpTest("Postgres MCP sql_execute requests dispatcher read_only", async () => { + const { call } = await fixture() + Registry.setConfigs({ source: { type: "postgres" } }) + const original = Dispatcher.call + const dispatch = spyOn(Dispatcher, "call").mockImplementation(async (method, args, context) => { + if (method === "sql.execute") { + expect(args).toMatchObject({ read_only: true }) + expect(context).toMatchObject({ forceReadOnly: true }) + return { columns: ["value"], rows: [[42]], row_count: 1, truncated: false } as never + } + return original(method, args, context) + }) + try { + const result = await call("sql_execute", { warehouse: "source", query: "SELECT app.mutate_source()" }) + expect(result.isError).toBe(false) + expect(dispatch.mock.calls.some(([method]) => method === "sql.execute")).toBe(true) + } finally { + dispatch.mockRestore() + } +}) + +mcpTest("classification-only connections warn at startup and in warehouse_list", async () => { + const notice = spyOn(console, "error").mockImplementation(() => {}) + Registry.setConfigs({ mysql_source: { type: "mysql" }, pg_source: { type: "postgres" } }) + const server = await createServer() + const client = new Client({ name: "test", version: "1" }) + const [left, right] = InMemoryTransport.createLinkedPair() + try { + expect(notice.mock.calls).toEqual([ + [ + "mysql_source (mysql): read-only is enforced by SQL classification only; stored functions with side effects are not blocked — use a read-only database user", + ], + ]) + await server.connect(right) + await client.connect(left) + const result = await client.callTool({ name: "warehouse_list", arguments: {} }) + expect(text(result)).toContain("mysql_source (mysql): read-only is enforced by SQL classification only") + expect(text(result)).toContain("use a read-only database user") + expect(text(result)).not.toContain("pg_source (postgres):") + } finally { + notice.mockRestore() + await client.close() + await server.close() + } +}) + +mcpTest("private spill directory and its files are removed on server close", async () => { + const { call, server } = await fixture() + const result = await call("sql_execute", { + warehouse: "source", + query: "SELECT name FROM items", + full_text_columns: ["name"], + }) + const saved = body(result).match(/(\/[^\s`]+\/tool_[A-Za-z0-9]+)/)?.[1] + expect(saved).toBeDefined() + expect((await stat(path.dirname(saved!))).mode & 0o777).toBe(0o700) + expect(await readFile(saved!, "utf8")).toContain("apple") + await server.close() + expect(await Bun.file(saved!).exists()).toBe(false) + await expect(stat(path.dirname(saved!))).rejects.toThrow() +}) + +mcpTest("shared truncation honors configured limits and preserves failure wording", async () => { + const previous = process.env.OPENCODE_CONFIG_CONTENT + process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ tool_output: { max_lines: 2, max_bytes: 40 } }) + try { + const { call } = await fixture() + const result = await call("warehouse_list", {}) + expect(text(result)).toContain("Full output saved to:") + expect(text(result)).toContain("succeeded but the output was truncated") + const listing = spyOn(Registry, "list").mockImplementation(() => { + throw new Error("A long warehouse listing failure that exceeds the configured output byte limit") + }) + try { + const failure = await call("warehouse_list", {}) + expect(failure.isError).toBe(true) + expect(text(failure)).toContain("failed but the output was truncated") + } finally { + listing.mockRestore() + } + } finally { + if (previous === undefined) delete process.env.OPENCODE_CONFIG_CONTENT + else process.env.OPENCODE_CONFIG_CONTENT = previous + } +}) +// altimate_change end + +// altimate_change start — SELECT over a macro hides a write from SQL classification +for (const allowWrite of [false, true]) { + mcpTest(`DuckDB hidden function writes reach the database read-only guard (allow-write=${allowWrite})`, async () => { + const { call } = await fixture(allowWrite) + Registry.setConfigs({ source: { type: "duckdb", path: ":memory:" } }) + const connector = await Registry.get("source") + await connector.execute("CREATE SEQUENCE source_sequence") + await connector.execute("CREATE MACRO mutate_source() AS nextval('source_sequence')") + const result = await call("sql_execute", { warehouse: "source", query: "SELECT mutate_source() AS id" }) + expect(result.isError).toBe(!allowWrite) + if (!allowWrite) expect(text(result)).toMatch(/read.only/i) + const state = await connector.execute( + "SELECT last_value FROM duckdb_sequences() WHERE sequence_name = 'source_sequence'", + ) + expect(state.rows).toEqual([[allowWrite ? 1n : null]]) + }) +} +// altimate_change end + +// altimate_change start — the explicit production loader owns validation and path resolution +for (const configs of [[], null, { bad: {} }, { bad: { type: 1 } }]) { + mcpTest(`explicit connections loader rejects invalid input ${JSON.stringify(configs)}`, async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "mcp-invalid-config-")) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + const file = path.join(dir, "connections.json") + await Bun.write(file, JSON.stringify(configs)) + await expect(createServer({ connections: file })).rejects.toThrow(/connection/i) + }) +} + +mcpTest("explicit connections loader preserves reserved scratch conflict", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "mcp-scratch-conflict-")) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + const file = path.join(dir, "connections.json") + await Bun.write(file, JSON.stringify({ scratch: { type: "sqlite", path: "scratch.sqlite" } })) + await Registry.loadFile(file) + expect(Registry.getConfig("scratch")?.path).toBe(path.join(dir, "scratch.sqlite")) + expect(Registry.list().warehouses[0]?.conflict).toContain("reserved built-in scratch") +}) +// altimate_change end + +// altimate_change start — closing aborts in-flight work before deleting private output +mcpTest("server close aborts active source queries before cleanup", async () => { + const { client, server } = await fixture() + const started = Promise.withResolvers() + const execute: Connector["execute"] = (_sql, _limit, _binds, options) => { + const signal = options!.signal! + started.resolve(signal) + return new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(signal.reason), { once: true })) + } + const original = Registry.get + const get = spyOn(Registry, "get").mockImplementation(async (name) => + name === "source" ? ({ execute, supportsResultByteLimit: true } as Connector) : original(name), + ) + try { + const pending = client.callTool({ name: "sql_execute", arguments: { warehouse: "source", query: "SELECT * FROM items" } }) + .catch((error) => error) + const signal = await started.promise + await server.close() + expect(signal.aborted).toBe(true) + await pending + } finally { get.mockRestore() } +}) +// altimate_change end diff --git a/packages/opencode/test/altimate/mcp-server/stdio.test.ts b/packages/opencode/test/altimate/mcp-server/stdio.test.ts index 8eb678e92d..23fb0d5f57 100644 --- a/packages/opencode/test/altimate/mcp-server/stdio.test.ts +++ b/packages/opencode/test/altimate/mcp-server/stdio.test.ts @@ -1,3 +1,4 @@ +// altimate_change start — standalone MCP policy and middleware regressions import { expect, test } from "bun:test" import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" @@ -43,6 +44,7 @@ for (const allowWrite of [false, true]) { stderr += data.toString() }) const client = new Client({ name: "stdio-test", version: "1" }) + let spill: string | undefined try { await client.connect(transport) expect((await client.listTools()).tools).toHaveLength(5) @@ -53,12 +55,23 @@ for (const allowWrite of [false, true]) { expect(write.isError).toBe(!allowWrite) const read = await client.callTool({ name: "sql_execute", - arguments: { warehouse: "source", query: "SELECT COUNT(*) AS n FROM items" }, + arguments: { + warehouse: "source", + query: "SELECT CASE WHEN COUNT(*) = 1 THEN 'one inserted item' ELSE 'empty source' END AS value FROM items", + }, }) expect(read.isError).toBe(false) - expect(JSON.stringify(read.content)).toContain(allowWrite ? "1" : "0") + expect(JSON.stringify(read.content)).toContain(allowWrite ? "one inserted item" : "empty source") + const output = await client.callTool({ + name: "sql_execute", + arguments: { warehouse: "source", query: "SELECT 'private server output' AS value", full_text_columns: ["value"] }, + }) + spill = JSON.stringify(output.content).match(/(\/[^\s`"\\]+\/tool_[A-Za-z0-9]+)/)?.[1] + expect(spill).toBeDefined() + expect(await Bun.file(spill!).exists()).toBe(true) } finally { await client.close() + if (spill) expect(await Bun.file(spill).exists()).toBe(false) await rm(dir, { recursive: true, force: true }) } expect(stderr).not.toContain("UNEXPECTED_NETWORK_CALL") @@ -82,3 +95,29 @@ test("CLI exits cleanly when the client closes stdin", async () => { process.kill() } }, 15_000) + +// altimate_change end + +// altimate_change start — execute the entrypoint's actual middleware without launching agent services +// Exercise the guard directly too, so deleting it fails deterministically even +// when the stdio child exits while the CLI is still unwinding. +test("mcp serve skips CLI middleware before starting the agent heap service", async () => { + const source = await Bun.file(path.resolve(import.meta.dir, "../../../src/index.ts")).text() + const opening = ".middleware(async (opts) => {" + const start = source.indexOf(opening) + const end = source.indexOf("\n })\n .usage", start) + expect(start).toBeGreaterThan(0) + expect(end).toBeGreaterThan(start) + const body = source.slice(start + opening.length, end) + const code = new Bun.Transpiler({ loader: "ts" }).transformSync(`async function middleware(opts: any) {${body}\n}`) + let starts = 0 + const middleware = new Function("Heap", `${code}; return middleware`)({ + start() { + starts++ + throw new Error("Agent heap initialization reached by MCP server") + }, + }) + await middleware({ _: ["mcp", "serve"] }) + expect(starts).toBe(0) +}) +// altimate_change end From 8ad54e6b81ada3e5b39e6ac334811344f5a95f20 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 13:30:23 -0700 Subject: [PATCH 03/39] feat(schema_inspect): list tables when no table is given; run MCP tests on MCP-only changes - `schema_inspect` without `table` (missing or empty) lists the warehouse's tables/collections with schema and type, plus row counts where cheap and safe (local SQLite/DuckDB, catalog statistics), capped at 500 entries. The with-`table` path is unchanged. Goes through the same dispatcher, permission and precedence checks and the MCP read-only enforcement. - CI: the driver-e2e path filter now includes the MCP server and schema tool paths, so the dedicated MCP test step runs for MCP-only PRs. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 + .../src/altimate/mcp-server/adapter.ts | 2 +- .../altimate/native/connections/register.ts | 3 + .../native/connections/schema-list.ts | 53 +++++++ .../opencode/src/altimate/native/types.ts | 12 ++ .../src/altimate/tools/schema-inspect.ts | 44 +++++- .../test/altimate/mcp-server/server.test.ts | 15 ++ .../altimate/precedence-guard-order.test.ts | 7 +- .../tools/schema-inspect-list.test.ts | 137 ++++++++++++++++++ .../tools/schema-inspect-warehouse.test.ts | 6 +- 10 files changed, 276 insertions(+), 9 deletions(-) create mode 100644 packages/opencode/src/altimate/native/connections/schema-list.ts create mode 100644 packages/opencode/test/altimate/tools/schema-inspect-list.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 827218ea84..7a8ee44e9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,12 @@ jobs: - '.github/workflows/ci.yml' # altimate_change end drivers: + # altimate_change start — run real MCP drivers for MCP/tool-only changes + - 'packages/opencode/src/altimate/mcp-server/**' + - 'packages/opencode/test/altimate/mcp-server/**' + - 'packages/opencode/src/altimate/tools/schema-inspect.ts' + - 'packages/opencode/src/altimate/tools/schema-values.ts' + # altimate_change end - 'packages/drivers/src/**' - 'packages/opencode/src/altimate/native/connections/**' - 'packages/opencode/test/altimate/drivers-e2e.test.ts' diff --git a/packages/opencode/src/altimate/mcp-server/adapter.ts b/packages/opencode/src/altimate/mcp-server/adapter.ts index 0e1eef103c..d5f90dd9be 100644 --- a/packages/opencode/src/altimate/mcp-server/adapter.ts +++ b/packages/opencode/src/altimate/mcp-server/adapter.ts @@ -20,7 +20,7 @@ import type { SessionID, MessageID } from "@/session/schema" export const descriptions: Record = { warehouse_list: "List configured warehouse connections and their database types.", - schema_inspect: "Inspect tables and columns in a connected warehouse, with optional sample values.", + schema_inspect: "List tables/collections when table is omitted or empty; otherwise inspect one table, with optional sample values.", sql_execute: "Execute SQL against a connected warehouse and return rows. Source writes require the server's --allow-write option.", sql_check: "Run a read-only SQL data quality check against a connected warehouse.", diff --git a/packages/opencode/src/altimate/native/connections/register.ts b/packages/opencode/src/altimate/native/connections/register.ts index 5761b38342..e7eddfa570 100644 --- a/packages/opencode/src/altimate/native/connections/register.ts +++ b/packages/opencode/src/altimate/native/connections/register.ts @@ -7,6 +7,7 @@ */ import { register } from "../dispatcher" +import { listSchemaTables } from "./schema-list" import * as Registry from "./registry" import { defaultInspectionSchema, resolveInspectionTable, resolveInspectionTarget } from "./inspection-target" import { discoverContainers } from "./docker-discovery" @@ -745,6 +746,8 @@ register("warehouse.discover", async (): Promise => { } }) +register("schema.list", listSchemaTables) + // --- schema.inspect --- function schemaAddressingHint(): string { const warehouses = Registry.list().warehouses diff --git a/packages/opencode/src/altimate/native/connections/schema-list.ts b/packages/opencode/src/altimate/native/connections/schema-list.ts new file mode 100644 index 0000000000..0073f39d47 --- /dev/null +++ b/packages/opencode/src/altimate/native/connections/schema-list.ts @@ -0,0 +1,53 @@ +import { absoluteFileUriPath, isLocalFilePath, requireStorePath } from "@altimateai/drivers/file-store" +import type { SchemaListParams, SchemaListResult } from "../types" +import { executeQuery, type QueryContext } from "../query-execution" +import * as Registry from "./registry" + +const TABLE_LIMIT = 500 +const quoteIdent = (id: string) => `"${id.replace(/"/g, '""')}"` + +export async function listSchemaTables(params: SchemaListParams, context?: QueryContext): Promise { + const warehouse = params.warehouse ?? Registry.list().warehouses[0]?.name + if (!warehouse) throw new Error("No warehouse configured.") + context?.signal?.throwIfAborted() + const connector = await Registry.get(warehouse) + const config = Registry.getConfig(warehouse) + const type = Registry.canonicalType(config?.type) + // A DuckDB connection can target MotherDuck or another remote store. Only local + // base tables are eligible; views may scan remote data even in a local file. + const store = type === "sqlite" || type === "duckdb" ? requireStorePath(config!, type) : undefined + const countLocal = + store !== undefined && (store === ":memory:" || isLocalFilePath(store) || absoluteFileUriPath(store) !== undefined) + const schema = params.schema_name?.trim() + const schemas = schema ? [schema] : await connector.listSchemas() + const tables: SchemaListResult["tables"] = [] + let truncated = false + listing: for (const schema of schemas) { + context?.signal?.throwIfAborted() + for (const table of await connector.listTables(schema)) { + if (tables.length === TABLE_LIMIT) { + truncated = true + break listing + } + tables.push({ schema, name: table.name, type: table.type }) + } + } + if (countLocal) { + for (const table of tables) { + context?.signal?.throwIfAborted() + if (table.type.toLowerCase() !== "table") continue + try { + const result = await executeQuery(config, { readOnly: true, queryTimeoutMs: 1_000 }, context, (options) => + connector.execute(`SELECT COUNT(*) FROM ${quoteIdent(table.schema)}.${quoteIdent(table.name)}`, 1, undefined, options), + ) + const count = Number(result.rows[0]?.[0]) + if (Number.isSafeInteger(count) && count >= 0) table.row_count = count + } catch { + // Counts are best effort: a denied, dropped or slow table still belongs in + // the inventory. Never fall back to an unprotected or unbounded scan. + context?.signal?.throwIfAborted() + } + } + } + return { warehouse, tables, truncated } +} diff --git a/packages/opencode/src/altimate/native/types.ts b/packages/opencode/src/altimate/native/types.ts index ed995589e5..1d7a91ccf7 100644 --- a/packages/opencode/src/altimate/native/types.ts +++ b/packages/opencode/src/altimate/native/types.ts @@ -106,6 +106,17 @@ export interface SqlOptimizeResult { // --- Schema --- +export interface SchemaListParams { + schema_name?: string + warehouse?: string +} + +export interface SchemaListResult { + warehouse: string + tables: Array<{ schema: string; name: string; type: string; row_count?: number }> + truncated: boolean +} + export interface SchemaInspectParams { table: string schema_name?: string @@ -1208,6 +1219,7 @@ export const BridgeMethods = { "sql.format": {} as { params: SqlFormatParams; result: SqlFormatResult }, "sql.fix": {} as { params: SqlFixParams; result: SqlFixResult }, "sql.autocomplete": {} as { params: SqlAutocompleteParams; result: SqlAutocompleteResult }, + "schema.list": {} as { params: SchemaListParams; result: SchemaListResult }, "schema.inspect": {} as { params: SchemaInspectParams; result: SchemaInspectResult }, "schema.index": {} as { params: SchemaIndexParams; result: SchemaIndexResult }, "schema.search": {} as { params: SchemaSearchParams; result: SchemaSearchResult }, diff --git a/packages/opencode/src/altimate/tools/schema-inspect.ts b/packages/opencode/src/altimate/tools/schema-inspect.ts index bfbb9ae2a5..33891f9104 100644 --- a/packages/opencode/src/altimate/tools/schema-inspect.ts +++ b/packages/opencode/src/altimate/tools/schema-inspect.ts @@ -15,10 +15,11 @@ import * as Precedence from "../workspace/precedence" // altimate_change end export const SchemaInspectTool = Tool.define("schema_inspect", { - description: "Inspect database schema — list columns, types, and constraints for a table.", + description: "List tables/collections when table is omitted or empty; otherwise inspect columns, types, and constraints for one table.", parameters: z.object({ table: z .string() + .optional() .describe( "Table name (optionally schema-qualified, e.g. public.orders); a warehouse qualifier such as scratch.orders resolves to that warehouse's default schema when no such schema exists", ), @@ -35,7 +36,7 @@ export const SchemaInspectTool = Tool.define("schema_inspect", { // Pre-flight validation outranks the redirect, as it does for `sql_explain`: bad // input is answered here rather than forwarded to the engine tool, and an empty // warehouse string is never read as "the default" by the routing decision. - const inputError = validateTableName(args.table) ?? validateWarehouseName(args.warehouse) + const inputError = (args.table?.trim() ? validateTableName(args.table) : null) ?? validateWarehouseName(args.warehouse) if (inputError) { return { title: "Schema: INVALID INPUT", @@ -60,6 +61,45 @@ export const SchemaInspectTool = Tool.define("schema_inspect", { // Strict-schema callers send schema_name: "" for "not specified"; a blank qualifier // must mean the engine default, not a schema literally named "". const schemaName = args.schema_name?.trim() || undefined + if (!args.table?.trim()) { + precedence = await Precedence.check(ctx.sessionID, "schema_inspect", args.warehouse) + if (precedence.redirect) return precedence.redirect + await ctx.ask({ + permission: "schema_inspect", + patterns: [args.warehouse ?? "*"], + always: ["*"], + metadata: { warehouse: args.warehouse, schema_name: schemaName }, + }) + const result = await Dispatcher.call( + "schema.list", + { warehouse: args.warehouse, schema_name: schemaName }, + { signal: ctx.abort, sessionID: ctx.sessionID, forceReadOnly: true }, + ) + const lines = [ + `Tables on warehouse "${result.warehouse}":`, + "", + "Schema | Name | Type | Rows", + "-------|------|------|-----", + ...result.tables.map((table) => `${table.schema} | ${table.name} | ${table.type} | ${table.row_count ?? ""}`), + ] + if (!result.tables.length) lines.push("No tables or collections found.") + if (result.truncated) lines.push("", "List truncated at 500 entries; pass schema_name to narrow the listing.") + lines.push( + "", + `Inspect one table with schema_inspect(warehouse="${result.warehouse}", schema_name="", table="").`, + ) + return Precedence.annotate(precedence, { + title: `Tables: ${result.warehouse}`, + metadata: { + success: true, + columnCount: 0, + rowCount: undefined, + tableCount: result.tables.length, + truncated: result.truncated, + }, + output: lines.join("\n"), + }) + } const target = await resolveInspectionTarget({ table: args.table, schema_name: schemaName, diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index 1a0a4ad145..f6c3163a09 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -79,6 +79,18 @@ mcpTest("lists exactly five tools and executes warehouse, schema, SQL and checks } }) +// altimate_change start — warehouse discovery accepts strict-schema empty table fillers +mcpTest("schema_inspect lists SQLite tables with protected counts when table is absent or empty", async () => { + const { call } = await fixture() + for (const args of [{ warehouse: "source" }, { warehouse: "source", table: "", schema_name: "", values: false }]) { + const result = await call("schema_inspect", args) + expect(result.isError).toBe(false) + expect(body(result)).toContain("main | items | table | 2") + expect(body(result)).toContain("Inspect one table with schema_inspect") + } +}) +// altimate_change end + mcpTest("source writes are denied by default and cannot modify the source", async () => { const { call } = await fixture() const denied = await call("sql_execute", { warehouse: "source", query: "INSERT INTO items VALUES (3, 'plum')" }) @@ -258,6 +270,9 @@ mcpTest("MCP forces database protection for every SQLite source query path", asy { warehouse: "source", query_from: { warehouse: "source", sql: "SELECT 'SELECT * FROM items' AS sql" } }, ], ["schema_inspect", { warehouse: "source", table: "items", values: true }], + // altimate_change start — inventory counts also enforce database read-only + ["schema_inspect", { warehouse: "source" }], + // altimate_change end ["sql_check", { warehouse: "source", check: "nulls", sql: "SELECT * FROM items", columns: ["id"] }], ["sql_materialize", { source_warehouse: "source", sql: "SELECT * FROM items", target_table: "protected_copy" }], ] as const) { diff --git a/packages/opencode/test/altimate/precedence-guard-order.test.ts b/packages/opencode/test/altimate/precedence-guard-order.test.ts index b39118a5c5..7386e79d46 100644 --- a/packages/opencode/test/altimate/precedence-guard-order.test.ts +++ b/packages/opencode/test/altimate/precedence-guard-order.test.ts @@ -201,13 +201,14 @@ describe("sql_explain — input validation outranks the redirect", () => { }) describe("schema_inspect — input validation outranks the redirect", () => { - test("an empty table reports invalid input rather than redirecting", async () => { + // altimate_change start — omitted/blank tables discover inventory through the same routing guard + test("an empty table listing still respects warehouse precedence", async () => { const tool = await initTool(SchemaInspectTool) const result: any = await tool.execute({ table: " ", warehouse: "shadowed_snow" }, ctx) - expect(result.metadata.error_class).toBe("input_validation") - expect(result.metadata.redirected).toBeUndefined() + expect(result.metadata.redirected).toBe(true) }) + // altimate_change end test("an empty warehouse string is invalid input, not the default target", async () => { const tool = await initTool(SchemaInspectTool) const result: any = await tool.execute({ table: "orders", warehouse: " " }, ctx) diff --git a/packages/opencode/test/altimate/tools/schema-inspect-list.test.ts b/packages/opencode/test/altimate/tools/schema-inspect-list.test.ts new file mode 100644 index 0000000000..61c546019b --- /dev/null +++ b/packages/opencode/test/altimate/tools/schema-inspect-list.test.ts @@ -0,0 +1,137 @@ +// altimate_change start — warehouse inventory discovery regressions +import { afterEach, expect, mock, spyOn, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import type { Connector } from "@altimateai/drivers" +import { initTool } from "../tool-fixture" +import { SchemaInspectTool } from "../../../src/altimate/tools/schema-inspect" +import * as Registry from "../../../src/altimate/native/connections/registry" +import { registerAll } from "../../../src/altimate/native/connections/register" +import * as Precedence from "../../../src/altimate/workspace/precedence" + +const ctx = { + sessionID: "schema-list-test", messageID: "message", callID: "call", agent: "test", + abort: new AbortController().signal, messages: [], metadata: () => {}, extra: { mcpClient: true, forceReadOnly: true }, +} +let restore: (() => void) | undefined +let directory: string | undefined + +afterEach(async () => { + restore?.() + restore = undefined + await Registry.closeAll() + Registry.reset() + Precedence.releaseLocalSession(ctx.sessionID) + if (directory) await rm(directory, { recursive: true, force: true }) + directory = undefined +}) + +async function inspect(args: Record = {}, ask = async (_input: unknown) => {}) { + Precedence.bindLocalSession(ctx.sessionID) + const tool = await initTool(SchemaInspectTool) + return tool.execute(args, { ...ctx, ask }) +} + +for (const type of ["sqlite", "duckdb"] as const) { + test(`${type}: real local tables list with counts; blank table and schema mean absent`, async () => { + registerAll() + directory = await mkdtemp(path.join(os.tmpdir(), "schema-list-")) + Registry.setConfigs({ local: { type, path: path.join(directory, `source.${type}`), create: true } }) + const connector = await Registry.get("local") + await connector.execute('CREATE TABLE "odd""name" (id INTEGER)') + await connector.execute('INSERT INTO "odd""name" VALUES (1), (2)') + await connector.execute('CREATE TABLE empty (id INTEGER)') + await connector.execute('CREATE VIEW sample AS SELECT * FROM "odd""name"') + const result = await inspect({ warehouse: "local" }) + expect(result.metadata.success).toBe(true) + expect(result.output).toContain('main | odd"name | table | 2') + expect(result.output).toContain("main | empty | table | 0") + expect(result.output).toContain("main | sample | view | \n") + expect(result.output).toContain('schema_inspect(warehouse="local", schema_name="", table="")') + expect(await inspect({ warehouse: "local", table: "", schema_name: "" })).toEqual(result) + expect(await inspect({ warehouse: "local", table: " " })).toEqual(result) + if (type === "duckdb") { + await connector.execute("CREATE SCHEMA analytics") + await connector.execute("CREATE TABLE analytics.other (id INTEGER)") + expect((await inspect({ warehouse: "local" })).output).toContain("analytics | other | table | 0") + const filtered = await inspect({ warehouse: "local", schema_name: "analytics" }) + expect(filtered.output).toContain("analytics | other | table | 0") + expect(filtered.output).not.toContain("main |") + } + const single = await inspect({ warehouse: "local", table: "empty", values: false }) + expect(single).toEqual({ + title: "Schema: empty", metadata: { success: true, columnCount: 1, rowCount: undefined, truncated: false }, + output: "Table: main.empty\n\nColumn | Type | Nullable | PK\n-------|------|----------|---\nid | INTEGER | YES | ", + }) + }) +} + +function fake(type: string, count = 1, store?: string) { + registerAll() + Registry.setConfigs({ remote: { type, ...(store ? { path: store } : {}) } }) + const connector = { + listSchemas: mock(async () => ["one", "two"]), + listTables: mock(async (_schema: string) => Array.from({ length: count }, (_, i) => ({ name: `t${i}`, type: type === "mongodb" ? "collection" : "table" }))), + execute: mock(async () => ({ rows: [[3]], columns: ["count"], row_count: 1, truncated: false })), + } + const get = spyOn(Registry, "get").mockResolvedValue(connector as unknown as Connector) + restore = () => get.mockRestore() + return connector +} + +test.each(["postgres", "snowflake", "mongodb", "trino"])("%s inventory never scans tables for counts", async (type) => { + const connector = fake(type) + const result = await inspect({ warehouse: "remote" }) + expect(result.metadata.success).toBe(true) + expect(result.metadata.tableCount).toBe(2) + expect(result.output).toContain(`one | t0 | ${type === "mongodb" ? "collection" : "table"} | \n`) + expect(connector.execute).not.toHaveBeenCalled() + expect(connector.listTables.mock.calls).toEqual([["one"], ["two"]]) + const filtered = await inspect({ warehouse: "remote", schema_name: "two" }) + expect(filtered.metadata.tableCount).toBe(1) + expect(filtered.output).not.toContain("one |") +}) + +test("remote DuckDB stores do not receive COUNT queries", async () => { + const connector = fake("duckdb", 1, "md:remote") + expect((await inspect({ warehouse: "remote" })).metadata.success).toBe(true) + expect(connector.execute).not.toHaveBeenCalled() +}) + +test("listing caps output at 500 and notes truncation only when more tables exist", async () => { + const connector = fake("postgres", 501) + const result = await inspect({ warehouse: "remote", schema_name: "one" }) + expect(result.metadata).toMatchObject({ tableCount: 500, truncated: true }) + expect(result.output).toContain("List truncated at 500 entries") + expect(result.output).not.toContain(" | t500 |") + connector.listTables.mockResolvedValueOnce(Array.from({ length: 500 }, (_, i) => ({ name: `t${i}`, type: "table" }))) + expect((await inspect({ warehouse: "remote", schema_name: "one" })).metadata.truncated).toBe(false) +}) + +test("denied schema permission stops inventory access", async () => { + const connector = fake("postgres") + const result = await inspect({ warehouse: "remote" }, async () => { throw new Error("permission denied") }) + expect(result.metadata.success).toBe(false) + expect(result.output).toContain("permission denied") + expect(connector.listSchemas).not.toHaveBeenCalled() +}) + +test("local count probes are protected and failures omit only the count", async () => { + const connector = fake("sqlite", 1, ":memory:") + connector.execute.mockRejectedValue(new Error("count unavailable")) + const result = await inspect({ warehouse: "remote", schema_name: "one" }) + expect(result.metadata.success).toBe(true) + expect(result.output).toContain("one | t0 | table | ") + expect(connector.execute.mock.calls[0]).toMatchObject([ + 'SELECT COUNT(*) FROM "one"."t0"', 1, undefined, { readOnly: true, queryTimeoutMs: 1000 }, + ]) +}) + +test("empty inventory gives next-step instructions", async () => { + fake("postgres", 0) + const result = await inspect({}) + expect(result.metadata.success).toBe(true) + expect(result.output).toContain("No tables or collections found.") +}) +// altimate_change end diff --git a/packages/opencode/test/altimate/tools/schema-inspect-warehouse.test.ts b/packages/opencode/test/altimate/tools/schema-inspect-warehouse.test.ts index d471ed191b..bccb6e9580 100644 --- a/packages/opencode/test/altimate/tools/schema-inspect-warehouse.test.ts +++ b/packages/opencode/test/altimate/tools/schema-inspect-warehouse.test.ts @@ -168,11 +168,11 @@ describe("schema_inspect warehouse qualifiers", () => { expect(result.output).toContain("warehouse=reporting, schema_name=main") }) - test("requires table and does not expose a table_name alias", async () => { + test("allows table discovery and does not expose a table_name alias", async () => { const tool = await initTool(SchemaInspectTool) - expect(tool.jsonSchema?.required).toContain("table") + expect(tool.jsonSchema?.required ?? []).not.toContain("table") expect(tool.jsonSchema?.properties).not.toHaveProperty("table_name") - await expect(inspect({ table_name: "t" })).rejects.toThrow("table") + expect((await inspect({ warehouse: "primary" })).output).toContain("main | t | table | 1") }) }) // altimate_change end From a203a7960ad5308a117c56ea799a3dc89e03bd84 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 13:56:45 -0700 Subject: [PATCH 04/39] fix(schema_inspect): bound table discovery for very large warehouses - No `table` and no `schema_name`: one catalog aggregate (`GROUP BY table_schema`, `sqlite_master`, BigQuery `INFORMATION_SCHEMA`) returns schemas with table counts, capped at 200; falls back to `listSchemas()` without counts - `schema_name` only: catalog query ordered by name with `LIMIT 501` pushed down; `connector.listTables` is used only for drivers without catalog SQL - MongoDB: `dbStats` collection counts and a `listCollections` cursor that stops at 501 - Row counts: local SQLite/DuckDB only, at most 50 tables under a ~3 s budget, rest marked "not counted"; remote engines show catalog estimates only, never `COUNT(*)` - Tests: 2,000,000-table fake catalog, schema aggregate, slow-count budget, real SQLite/DuckDB, MongoDB cap Co-Authored-By: Claude Opus 5.5 --- packages/drivers/src/mongodb.ts | 25 ++- .../test/mongodb-catalog-bounds.test.ts | 74 +++++++ packages/drivers/test/mongodb-unit.test.ts | 19 +- .../native/connections/schema-list.ts | 157 ++++++++++++-- .../opencode/src/altimate/native/types.ts | 11 +- .../src/altimate/tools/schema-inspect.ts | 50 +++-- .../test/altimate/mcp-server/server.test.ts | 10 +- .../tools/schema-inspect-list.test.ts | 202 ++++++++++++++---- .../tools/schema-inspect-warehouse.test.ts | 3 +- 9 files changed, 454 insertions(+), 97 deletions(-) create mode 100644 packages/drivers/test/mongodb-catalog-bounds.test.ts diff --git a/packages/drivers/src/mongodb.ts b/packages/drivers/src/mongodb.ts index 1d465f4f0f..76449e81e8 100644 --- a/packages/drivers/src/mongodb.ts +++ b/packages/drivers/src/mongodb.ts @@ -42,6 +42,9 @@ type MqlCommand = | "dropCollection" | "createIndex" | "listIndexes" + // altimate_change start — scalar database catalog counts + | "dbStats" + // altimate_change end | "listCollections" | "ping" @@ -474,7 +477,7 @@ export async function connect(config: ConnectionConfig): Promise { maxResultBytes?: number, signal?: AbortSignal, ): Promise { - async function readCursor(cursor: any): Promise[]> { + async function readCursor(cursor: any, rowLimit?: number): Promise[]> { const consume = createResultByteBudget(maxResultBytes) let stopped = false let failure: unknown @@ -499,7 +502,7 @@ export async function connect(config: ConnectionConfig): Promise { }) const read = async () => { if (stopped) throw failure - if (maxResultBytes === undefined && maxTimeMS === undefined && !signal) return cursor.toArray() + if (rowLimit === undefined && maxResultBytes === undefined && maxTimeMS === undefined && !signal) return cursor.toArray() // One document per round trip made a 127k-document read take ~10 minutes. A reply // batch is still bounded (a server reply never exceeds 16 MiB) and the byte budget // stops the read at the first document past the cap. @@ -508,6 +511,7 @@ export async function connect(config: ConnectionConfig): Promise { if (stopped) throw failure if (maxResultBytes === undefined) { docs.push(doc) + if (rowLimit !== undefined && docs.length >= rowLimit) break continue } const serialized = Object.fromEntries( @@ -565,17 +569,20 @@ export async function connect(config: ConnectionConfig): Promise { return { columns: ["ok"], rows: [[result.ok]], row_count: 1, truncated: false } } + // altimate_change start — catalog discovery consumes only a bounded cursor, never the metadata cache + if (cmd === "dbStats") { + const stats = await db.command({ dbStats: 1, ...withMaxTime(maxTimeMS) }) + return { columns: ["collections"], rows: [[stats.collections]], row_count: 1, truncated: false } + } if (cmd === "listCollections") { - const collections = await readCollections(db, maxTimeMS) - const matches = parsed.filter - ? await db.listCollections(parsed.filter, { - nameOnly: true, authorizedCollections: true, ...withMaxTime(maxTimeMS), - }).toArray() - : collections + const matches = await readCursor(db.listCollections(parsed.filter ?? {}, { + nameOnly: true, authorizedCollections: true, batchSize: Math.min(effectiveLimit + 1, 501), ...withMaxTime(maxTimeMS), + }), effectiveLimit + 1) const rows = matches.slice(0, effectiveLimit) - .map((collection: { name: string; type: string }) => [collection.name, collection.type]) + .map((collection) => [collection.name, collection.type]) return { columns: ["name", "type"], rows, row_count: rows.length, truncated: matches.length > effectiveLimit } } + // altimate_change end if (!MONGO_COLLECTION_READS.has(cmd)) clearMetadata() diff --git a/packages/drivers/test/mongodb-catalog-bounds.test.ts b/packages/drivers/test/mongodb-catalog-bounds.test.ts new file mode 100644 index 0000000000..a26daf4cd2 --- /dev/null +++ b/packages/drivers/test/mongodb-catalog-bounds.test.ts @@ -0,0 +1,74 @@ +// altimate_change start — schema discovery never materializes all collection metadata +import { expect, mock, test } from "bun:test" +import { connect } from "../src/mongodb" +import { restoreModuleMocksAfterTests } from "./module-mock" + +await restoreModuleMocksAfterTests("mongodb") + +test("two million MongoDB collections stop at 501 and close the cursor", async () => { + let fetched = 0 + let closed = 0 + const options: unknown[] = [] + const commands: unknown[] = [] + mock.module("mongodb", () => ({ + default: { + MongoClient: class { + async connect() {} + async close() {} + db() { + return { + databaseName: "large", + command: async (command: unknown) => { + commands.push(command) + return { collections: 2_000_000 } + }, + listCollections: (_filter: unknown, opts: unknown) => { + options.push(opts) + return { + async toArray() { + throw new Error("unbounded collection fetch") + }, + async close() { + closed++ + }, + async *[Symbol.asyncIterator]() { + for (let i = 0; i < 2_000_000; i++) { + fetched++ + yield { name: `c${i}`, type: "collection" } + } + }, + } + }, + } + } + }, + }, + })) + const connector = await connect({ type: "mongodb" }) + await connector.connect() + try { + const start = performance.now() + const result = await connector.execute( + JSON.stringify({ command: "listCollections", database: "large" }), + 500, + undefined, + { readOnly: true, queryTimeoutMs: 1000 }, + ) + expect(result.rows).toHaveLength(500) + expect(result.truncated).toBe(true) + expect(fetched).toBe(501) + expect(closed).toBe(1) + expect(performance.now() - start).toBeLessThan(1000) + expect(options[0]).toMatchObject({ nameOnly: true, authorizedCollections: true, batchSize: 501, maxTimeMS: 1000 }) + const counts = await connector.execute(JSON.stringify({ command: "dbStats", database: "large" }), 1, undefined, { + readOnly: true, + queryTimeoutMs: 1000, + }) + expect(counts.rows).toEqual([[2_000_000]]) + expect(fetched).toBe(501) + expect(commands).toEqual([{ dbStats: 1, maxTimeMS: 1000 }]) + } finally { + await connector.close() + } +}) +// altimate_change end diff --git a/packages/drivers/test/mongodb-unit.test.ts b/packages/drivers/test/mongodb-unit.test.ts index f7ef97baf2..eb48e64587 100644 --- a/packages/drivers/test/mongodb-unit.test.ts +++ b/packages/drivers/test/mongodb-unit.test.ts @@ -36,15 +36,22 @@ async function fakeMongo( return { databases: Object.keys(collections).map((name) => ({ name })) } }, }), - listCollections: (filter: { name?: string }, options: unknown) => ({ - toArray: async () => { + // altimate_change start — model listCollections as a cursor, including bounded consumption + listCollections: (filter: { name?: string }, options: unknown) => { + const rows = () => { collectionLists.push({ database, options }) if (metadata.error) throw metadata.error return (collections[database] ?? []) .filter((name) => !filter.name || name === filter.name) .map((name) => ({ name, type: "collection" })) - }, - }), + } + return { + toArray: async () => rows(), + async *[Symbol.asyncIterator]() { yield* rows() }, + async close() {}, + } + }, + // altimate_change end createCollection: async (name: string) => { collections[database] ??= [] collections[database].push(name) @@ -172,7 +179,9 @@ describe("MongoDB read namespace validation", () => { expect(await connector.execute(JSON.stringify({ command: "listCollections", database: "archive" }))).toEqual({ columns: ["name", "type"], rows: [], row_count: 0, truncated: false, }) - expect(databaseLists).toEqual([{ nameOnly: true, authorizedDatabases: true, maxTimeMS: 120_000 }]) + // altimate_change start — an empty bounded cursor needs no unbounded metadata validation + expect(databaseLists).toEqual([]) + // altimate_change end }) test("metadata failures cannot become an empty read", async () => { diff --git a/packages/opencode/src/altimate/native/connections/schema-list.ts b/packages/opencode/src/altimate/native/connections/schema-list.ts index 0073f39d47..23541fe72f 100644 --- a/packages/opencode/src/altimate/native/connections/schema-list.ts +++ b/packages/opencode/src/altimate/native/connections/schema-list.ts @@ -1,53 +1,164 @@ import { absoluteFileUriPath, isLocalFilePath, requireStorePath } from "@altimateai/drivers/file-store" +import type { ConnectionConfig } from "@altimateai/drivers/types" import type { SchemaListParams, SchemaListResult } from "../types" import { executeQuery, type QueryContext } from "../query-execution" +import { sanitizeBqRegion } from "../finops/bq-utils" import * as Registry from "./registry" const TABLE_LIMIT = 500 +const SCHEMA_LIMIT = 200 +const COUNT_LIMIT = 50 +const COUNT_BUDGET_MS = 3_000 const quoteIdent = (id: string) => `"${id.replace(/"/g, '""')}"` +/** Undefined means this driver has no supported catalog SQL, never a failed catalog query. */ +function catalogQuery(type: string, config: ConnectionConfig | undefined, schema?: string): string | undefined { + if (type === "sqlite") { + const catalog = `${quoteIdent(schema ?? "main")}.sqlite_master` + const filter = "type IN ('table','view') AND name NOT LIKE 'sqlite_%'" + return schema + ? `SELECT name, type FROM ${catalog} WHERE ${filter} ORDER BY name LIMIT 501` + : `SELECT 'main', COUNT(*) FROM ${catalog} WHERE ${filter} LIMIT 201` + } + if (!["postgres", "mysql", "snowflake", "duckdb", "redshift", "bigquery"].includes(type)) return undefined + const catalog = + type === "bigquery" + ? `\`region-${sanitizeBqRegion(config?.location)}.INFORMATION_SCHEMA.TABLES\`` + : "information_schema.tables" + const filters = ["t.table_schema NOT IN ('information_schema', 'pg_catalog', 'pg_internal', 'INFORMATION_SCHEMA')"] + if (type === "duckdb") filters.push("t.table_catalog = current_database()") + if (schema) + filters.push(`t.table_schema = ${type === "postgres" || type === "redshift" || type === "duckdb" ? "$1" : "?"}`) + if (!schema) { + return `SELECT t.table_schema, COUNT(*) FROM ${catalog} t WHERE ${filters.join(" AND ")} GROUP BY t.table_schema ORDER BY t.table_schema LIMIT 201` + } + const stats = + type === "mysql" + ? ", t.table_rows" + : type === "snowflake" + ? ", t.row_count" + : type === "postgres" + ? ", c.reltuples" + : "" + const join = + type === "postgres" + ? " LEFT JOIN pg_catalog.pg_namespace n ON n.nspname = t.table_schema LEFT JOIN pg_catalog.pg_class c ON c.relnamespace = n.oid AND c.relname = t.table_name" + : "" + return `SELECT t.table_name, t.table_type${stats} FROM ${catalog} t${join} WHERE ${filters.join(" AND ")} ORDER BY t.table_name LIMIT 501` +} + +function nonnegativeCount(value: unknown): number | undefined { + if (value === undefined || value === null) return undefined + const count = Number(value) + return Number.isSafeInteger(count) && count >= 0 ? count : undefined +} + export async function listSchemaTables(params: SchemaListParams, context?: QueryContext): Promise { const warehouse = params.warehouse ?? Registry.list().warehouses[0]?.name if (!warehouse) throw new Error("No warehouse configured.") context?.signal?.throwIfAborted() const connector = await Registry.get(warehouse) const config = Registry.getConfig(warehouse) - const type = Registry.canonicalType(config?.type) + const type = Registry.canonicalType(config?.type) ?? "" + const schema = params.schema_name?.trim() || undefined + const query = catalogQuery(type, config, schema) + const run = (sql: string, limit: number, binds?: unknown[], timeout = 10_000) => + executeQuery(config, { readOnly: true, queryTimeoutMs: timeout }, context, (options) => + connector.execute(sql, limit, binds, options), + ) + if (!schema) { + if (query) { + const result = await run(query, SCHEMA_LIMIT + 1) + return { + warehouse, + tables: [], + schemas: result.rows + .slice(0, SCHEMA_LIMIT) + .map((row) => ({ name: String(row[0]), table_count: nonnegativeCount(row[1]) })), + truncated: result.rows.length > SCHEMA_LIMIT || result.truncated, + } + } + const names = await connector.listSchemas() + const schemas: NonNullable = [] + for (const name of names.slice(0, SCHEMA_LIMIT).sort()) { + context?.signal?.throwIfAborted() + let table_count: number | undefined + if (type === "mongodb") { + // dbStats returns a scalar catalog count without transferring collection names. + try { + const result = await run(JSON.stringify({ command: "dbStats", database: name }), 1) + table_count = nonnegativeCount(result.rows[0]?.[0]) + } catch { + context?.signal?.throwIfAborted() + // Listing authorized databases may be allowed even when dbStats is denied. + } + } + schemas.push({ name, table_count }) + } + return { warehouse, tables: [], schemas, truncated: names.length > SCHEMA_LIMIT } + } + + let tables: SchemaListResult["tables"] + let truncated: boolean + const fallback = !query && type !== "mongodb" + if (query || type === "mongodb") { + const result = await run( + query ?? JSON.stringify({ command: "listCollections", database: schema }), + query ? TABLE_LIMIT + 1 : TABLE_LIMIT, + query && type !== "sqlite" ? [schema] : undefined, + ) + truncated = result.rows.length > TABLE_LIMIT || result.truncated + tables = result.rows.slice(0, TABLE_LIMIT).map((row) => ({ + schema, + name: String(row[0]), + type: String(row[1]).toLowerCase().includes("view") ? "view" : type === "mongodb" ? "collection" : "table", + ...(row.length > 2 ? { row_count: nonnegativeCount(row[2]), row_count_estimated: true } : {}), + })) + } else { + const listed = await connector.listTables(schema) + truncated = listed.length > TABLE_LIMIT + tables = listed + .sort((a, b) => a.name.localeCompare(b.name)) + .slice(0, TABLE_LIMIT) + .map((table) => ({ schema, ...table })) + } // A DuckDB connection can target MotherDuck or another remote store. Only local // base tables are eligible; views may scan remote data even in a local file. const store = type === "sqlite" || type === "duckdb" ? requireStorePath(config!, type) : undefined const countLocal = store !== undefined && (store === ":memory:" || isLocalFilePath(store) || absoluteFileUriPath(store) !== undefined) - const schema = params.schema_name?.trim() - const schemas = schema ? [schema] : await connector.listSchemas() - const tables: SchemaListResult["tables"] = [] - let truncated = false - listing: for (const schema of schemas) { - context?.signal?.throwIfAborted() - for (const table of await connector.listTables(schema)) { - if (tables.length === TABLE_LIMIT) { - truncated = true - break listing - } - tables.push({ schema, name: table.name, type: table.type }) - } - } if (countLocal) { + const deadline = Date.now() + COUNT_BUDGET_MS + let attempted = 0 for (const table of tables) { context?.signal?.throwIfAborted() - if (table.type.toLowerCase() !== "table") continue + table.row_count_status = "not counted" + if (table.type !== "table" || attempted >= COUNT_LIMIT || Date.now() >= deadline) continue + attempted++ try { - const result = await executeQuery(config, { readOnly: true, queryTimeoutMs: 1_000 }, context, (options) => - connector.execute(`SELECT COUNT(*) FROM ${quoteIdent(table.schema)}.${quoteIdent(table.name)}`, 1, undefined, options), + const timeout = Math.max(1, Math.min(1_000, deadline - Date.now())) + const result = await executeQuery( + config, + { + readOnly: true, + queryTimeoutMs: timeout, + signal: AbortSignal.timeout(timeout), + }, + context, + (options) => + connector.execute( + `SELECT COUNT(*) FROM ${quoteIdent(table.schema)}.${quoteIdent(table.name)}`, + 1, + undefined, + options, + ), ) - const count = Number(result.rows[0]?.[0]) - if (Number.isSafeInteger(count) && count >= 0) table.row_count = count + table.row_count = nonnegativeCount(result.rows[0]?.[0]) + if (table.row_count !== undefined) table.row_count_status = undefined } catch { - // Counts are best effort: a denied, dropped or slow table still belongs in - // the inventory. Never fall back to an unprotected or unbounded scan. context?.signal?.throwIfAborted() } } } - return { warehouse, tables, truncated } + return { warehouse, tables, truncated, ...(fallback ? { fallback: true } : {}) } } diff --git a/packages/opencode/src/altimate/native/types.ts b/packages/opencode/src/altimate/native/types.ts index 1d7a91ccf7..5e43145f08 100644 --- a/packages/opencode/src/altimate/native/types.ts +++ b/packages/opencode/src/altimate/native/types.ts @@ -113,7 +113,16 @@ export interface SchemaListParams { export interface SchemaListResult { warehouse: string - tables: Array<{ schema: string; name: string; type: string; row_count?: number }> + tables: Array<{ + schema: string + name: string + type: string + row_count?: number + row_count_estimated?: boolean + row_count_status?: "not counted" + }> + schemas?: Array<{ name: string; table_count?: number }> + fallback?: boolean truncated: boolean } diff --git a/packages/opencode/src/altimate/tools/schema-inspect.ts b/packages/opencode/src/altimate/tools/schema-inspect.ts index 33891f9104..c3dc0500f6 100644 --- a/packages/opencode/src/altimate/tools/schema-inspect.ts +++ b/packages/opencode/src/altimate/tools/schema-inspect.ts @@ -15,7 +15,8 @@ import * as Precedence from "../workspace/precedence" // altimate_change end export const SchemaInspectTool = Tool.define("schema_inspect", { - description: "List tables/collections when table is omitted or empty; otherwise inspect columns, types, and constraints for one table.", + description: + "List schemas with table/collection counts when table and schema_name are omitted; list up to 500 tables/collections when schema_name is given; otherwise inspect columns, types, and constraints for one table.", parameters: z.object({ table: z .string() @@ -36,7 +37,8 @@ export const SchemaInspectTool = Tool.define("schema_inspect", { // Pre-flight validation outranks the redirect, as it does for `sql_explain`: bad // input is answered here rather than forwarded to the engine tool, and an empty // warehouse string is never read as "the default" by the routing decision. - const inputError = (args.table?.trim() ? validateTableName(args.table) : null) ?? validateWarehouseName(args.warehouse) + const inputError = + (args.table?.trim() ? validateTableName(args.table) : null) ?? validateWarehouseName(args.warehouse) if (inputError) { return { title: "Schema: INVALID INPUT", @@ -75,26 +77,48 @@ export const SchemaInspectTool = Tool.define("schema_inspect", { { warehouse: args.warehouse, schema_name: schemaName }, { signal: ctx.abort, sessionID: ctx.sessionID, forceReadOnly: true }, ) - const lines = [ - `Tables on warehouse "${result.warehouse}":`, - "", - "Schema | Name | Type | Rows", - "-------|------|------|-----", - ...result.tables.map((table) => `${table.schema} | ${table.name} | ${table.type} | ${table.row_count ?? ""}`), - ] - if (!result.tables.length) lines.push("No tables or collections found.") - if (result.truncated) lines.push("", "List truncated at 500 entries; pass schema_name to narrow the listing.") + const schemas = result.schemas + const lines = schemas + ? [ + `Schemas on warehouse "${result.warehouse}":`, + "", + "Schema | Tables/collections", + "-------|-------------------", + ...schemas.map((schema) => `${schema.name} | ${schema.table_count ?? "unavailable"}`), + ] + : [ + `Tables on warehouse "${result.warehouse}":`, + "", + "Schema | Name | Type | Rows", + "-------|------|------|-----", + ...result.tables.map( + (table) => + `${table.schema} | ${table.name} | ${table.type} | ${table.row_count === undefined ? (table.row_count_status ?? "") : `${table.row_count}${table.row_count_estimated ? " (estimate)" : ""}`}`, + ), + ] + if (schemas ? !schemas.length : !result.tables.length) + lines.push(schemas ? "No schemas found." : "No tables or collections found.") + if (result.truncated) + lines.push( + "", + schemas + ? "Showing first 200 schemas of ≥201; pass schema_name to narrow the listing." + : "Showing first 500 of ≥501 — pass a narrower schema or inspect a table by name.", + ) + if (result.fallback) lines.push("", "Driver fallback: table list may be truncated by the driver.") + if (schemas) + lines.push("", `List tables with schema_inspect(warehouse="${result.warehouse}", schema_name="").`) lines.push( "", `Inspect one table with schema_inspect(warehouse="${result.warehouse}", schema_name="", table="").`, ) return Precedence.annotate(precedence, { - title: `Tables: ${result.warehouse}`, + title: `${schemas ? "Schemas" : "Tables"}: ${result.warehouse}`, metadata: { success: true, columnCount: 0, rowCount: undefined, - tableCount: result.tables.length, + ...(schemas ? { schemaCount: schemas.length } : { tableCount: result.tables.length }), truncated: result.truncated, }, output: lines.join("\n"), diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index f6c3163a09..4e1f7b9147 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -80,12 +80,18 @@ mcpTest("lists exactly five tools and executes warehouse, schema, SQL and checks }) // altimate_change start — warehouse discovery accepts strict-schema empty table fillers -mcpTest("schema_inspect lists SQLite tables with protected counts when table is absent or empty", async () => { +mcpTest("schema_inspect lists schemas, then SQLite tables with protected counts", async () => { const { call } = await fixture() for (const args of [{ warehouse: "source" }, { warehouse: "source", table: "", schema_name: "", values: false }]) { const result = await call("schema_inspect", args) expect(result.isError).toBe(false) - expect(body(result)).toContain("main | items | table | 2") + // altimate_change start — discovery starts with schema counts + expect(body(result)).toContain("main | 1") + expect(body(result)).not.toContain("main | items") + const tables = await call("schema_inspect", { warehouse: "source", schema_name: "main" }) + expect(tables.isError).toBe(false) + expect(body(tables)).toContain("main | items | table | 2") + // altimate_change end expect(body(result)).toContain("Inspect one table with schema_inspect") } }) diff --git a/packages/opencode/test/altimate/tools/schema-inspect-list.test.ts b/packages/opencode/test/altimate/tools/schema-inspect-list.test.ts index 61c546019b..fe14f66d12 100644 --- a/packages/opencode/test/altimate/tools/schema-inspect-list.test.ts +++ b/packages/opencode/test/altimate/tools/schema-inspect-list.test.ts @@ -11,8 +11,14 @@ import { registerAll } from "../../../src/altimate/native/connections/register" import * as Precedence from "../../../src/altimate/workspace/precedence" const ctx = { - sessionID: "schema-list-test", messageID: "message", callID: "call", agent: "test", - abort: new AbortController().signal, messages: [], metadata: () => {}, extra: { mcpClient: true, forceReadOnly: true }, + sessionID: "schema-list-test", + messageID: "message", + callID: "call", + agent: "test", + abort: new AbortController().signal, + messages: [], + metadata: () => {}, + extra: { mcpClient: true, forceReadOnly: true }, } let restore: (() => void) | undefined let directory: string | undefined @@ -41,28 +47,33 @@ for (const type of ["sqlite", "duckdb"] as const) { const connector = await Registry.get("local") await connector.execute('CREATE TABLE "odd""name" (id INTEGER)') await connector.execute('INSERT INTO "odd""name" VALUES (1), (2)') - await connector.execute('CREATE TABLE empty (id INTEGER)') + await connector.execute("CREATE TABLE empty (id INTEGER)") await connector.execute('CREATE VIEW sample AS SELECT * FROM "odd""name"') - const result = await inspect({ warehouse: "local" }) + const overview = await inspect({ warehouse: "local" }) + expect(overview.output).toContain("main | 3") + expect(overview.output).not.toContain('odd"name') + const result = await inspect({ warehouse: "local", schema_name: "main" }) expect(result.metadata.success).toBe(true) expect(result.output).toContain('main | odd"name | table | 2') expect(result.output).toContain("main | empty | table | 0") - expect(result.output).toContain("main | sample | view | \n") + expect(result.output).toContain("main | sample | view | not counted\n") expect(result.output).toContain('schema_inspect(warehouse="local", schema_name="", table="")') - expect(await inspect({ warehouse: "local", table: "", schema_name: "" })).toEqual(result) - expect(await inspect({ warehouse: "local", table: " " })).toEqual(result) + expect(await inspect({ warehouse: "local", table: "", schema_name: "" })).toEqual(overview) + expect(await inspect({ warehouse: "local", table: " " })).toEqual(overview) if (type === "duckdb") { await connector.execute("CREATE SCHEMA analytics") await connector.execute("CREATE TABLE analytics.other (id INTEGER)") - expect((await inspect({ warehouse: "local" })).output).toContain("analytics | other | table | 0") + expect((await inspect({ warehouse: "local" })).output).toContain("analytics | 1") const filtered = await inspect({ warehouse: "local", schema_name: "analytics" }) expect(filtered.output).toContain("analytics | other | table | 0") expect(filtered.output).not.toContain("main |") } const single = await inspect({ warehouse: "local", table: "empty", values: false }) expect(single).toEqual({ - title: "Schema: empty", metadata: { success: true, columnCount: 1, rowCount: undefined, truncated: false }, - output: "Table: main.empty\n\nColumn | Type | Nullable | PK\n-------|------|----------|---\nid | INTEGER | YES | ", + title: "Schema: empty", + metadata: { success: true, columnCount: 1, rowCount: undefined, truncated: false }, + output: + "Table: main.empty\n\nColumn | Type | Nullable | PK\n-------|------|----------|---\nid | INTEGER | YES | ", }) }) } @@ -72,66 +83,171 @@ function fake(type: string, count = 1, store?: string) { Registry.setConfigs({ remote: { type, ...(store ? { path: store } : {}) } }) const connector = { listSchemas: mock(async () => ["one", "two"]), - listTables: mock(async (_schema: string) => Array.from({ length: count }, (_, i) => ({ name: `t${i}`, type: type === "mongodb" ? "collection" : "table" }))), - execute: mock(async () => ({ rows: [[3]], columns: ["count"], row_count: 1, truncated: false })), + listTables: mock(async (_schema: string) => + Array.from({ length: count }, (_, i) => ({ name: `t${i}`, type: "table" })), + ), + execute: mock(async (sql: string, limit?: number, _binds?: unknown[], _options?: unknown) => { + let rows: unknown[][] + if (sql.includes("GROUP BY") || sql.includes("SELECT 'main'")) + rows = [ + ["one", count], + ["two", count], + ] + else if (sql.includes("dbStats")) rows = [[count]] + else if (/^SELECT COUNT/.test(sql)) rows = [[3]] + else + rows = Array.from({ length: Math.min(count, limit ?? 501) }, (_, i) => [ + `t${String(i).padStart(7, "0")}`, + type === "mongodb" ? "collection" : "BASE TABLE", + ]) + return { rows, columns: [], row_count: rows.length, truncated: type === "mongodb" && count > (limit ?? 501) } + }), } const get = spyOn(Registry, "get").mockResolvedValue(connector as unknown as Connector) restore = () => get.mockRestore() return connector } -test.each(["postgres", "snowflake", "mongodb", "trino"])("%s inventory never scans tables for counts", async (type) => { - const connector = fake(type) - const result = await inspect({ warehouse: "remote" }) - expect(result.metadata.success).toBe(true) - expect(result.metadata.tableCount).toBe(2) - expect(result.output).toContain(`one | t0 | ${type === "mongodb" ? "collection" : "table"} | \n`) - expect(connector.execute).not.toHaveBeenCalled() - expect(connector.listTables.mock.calls).toEqual([["one"], ["two"]]) - const filtered = await inspect({ warehouse: "remote", schema_name: "two" }) - expect(filtered.metadata.tableCount).toBe(1) - expect(filtered.output).not.toContain("one |") -}) +test.each(["postgres", "mysql", "snowflake", "duckdb", "redshift", "bigquery"])( + "%s uses one bounded aggregate, then an ordered bounded table catalog", + async (type) => { + const connector = fake(type, 2_000_000, type === "duckdb" ? "md:remote" : undefined) + const result = await inspect({ warehouse: "remote" }) + expect(result.metadata).toMatchObject({ success: true, schemaCount: 2 }) + expect(result.output).toContain("one | 2000000") + expect(result.output).not.toContain("t0000000") + expect(connector.execute).toHaveBeenCalledTimes(1) + expect(connector.execute.mock.calls[0][0]).toMatch(/GROUP BY.*ORDER BY.*LIMIT 201/) + expect(connector.execute.mock.calls[0][3]).toMatchObject({ readOnly: true, signal: expect.any(AbortSignal) }) + const start = performance.now() + const filtered = await inspect({ warehouse: "remote", schema_name: "two" }) + expect(performance.now() - start).toBeLessThan(2000) + expect(filtered.metadata).toMatchObject({ tableCount: 500, truncated: true }) + expect(filtered.output).toContain("Showing first 500 of ≥501") + expect(connector.execute.mock.calls[1]).toMatchObject([ + expect.stringMatching(/ORDER BY t.table_name LIMIT 501$/), + 501, + ["two"], + { readOnly: true }, + ]) + // The fake models a two-million-table catalog but allocates only the requested 501 rows. + expect( + ((await connector.execute.mock.results[1].value) as Awaited>).rows, + ).toHaveLength(501) + expect(connector.listTables).not.toHaveBeenCalled() + expect(connector.listSchemas).not.toHaveBeenCalled() + expect(connector.execute.mock.calls.some(([sql]) => /^SELECT COUNT/.test(sql))).toBe(false) + }, +) -test("remote DuckDB stores do not receive COUNT queries", async () => { - const connector = fake("duckdb", 1, "md:remote") - expect((await inspect({ warehouse: "remote" })).metadata.success).toBe(true) +test("fallback lists schemas without tables and warns when listing tables", async () => { + const connector = fake("trino") + expect((await inspect({ warehouse: "remote" })).output).toContain("one | unavailable") + expect(connector.listTables).not.toHaveBeenCalled() + const result = await inspect({ warehouse: "remote", schema_name: "two" }) + expect(result.output).toContain("Driver fallback: table list may be truncated") expect(connector.execute).not.toHaveBeenCalled() + expect(connector.listTables.mock.calls).toEqual([["two"]]) }) -test("listing caps output at 500 and notes truncation only when more tables exist", async () => { - const connector = fake("postgres", 501) - const result = await inspect({ warehouse: "remote", schema_name: "one" }) +test("MongoDB database counts use scalar metadata and collections use a bounded command", async () => { + const connector = fake("mongodb", 2_000_000) + const overview = await inspect({ warehouse: "remote" }) + expect(overview.output).toContain("one | 2000000") + expect(connector.listTables).not.toHaveBeenCalled() + const result = await inspect({ warehouse: "remote", schema_name: "two" }) expect(result.metadata).toMatchObject({ tableCount: 500, truncated: true }) - expect(result.output).toContain("List truncated at 500 entries") - expect(result.output).not.toContain(" | t500 |") - connector.listTables.mockResolvedValueOnce(Array.from({ length: 500 }, (_, i) => ({ name: `t${i}`, type: "table" }))) + expect(connector.execute.mock.calls[2]).toMatchObject([ + JSON.stringify({ command: "listCollections", database: "two" }), + 500, + undefined, + { readOnly: true }, + ]) +}) + +test("schema output caps at 200 using one aggregate", async () => { + const connector = fake("postgres") + connector.execute.mockResolvedValueOnce({ + rows: Array.from({ length: 201 }, (_, i) => [`s${i}`, 2_000_000]), + columns: [], + row_count: 201, + truncated: false, + }) + const result = await inspect({ warehouse: "remote" }) + expect(result.metadata).toMatchObject({ schemaCount: 200, truncated: true }) + expect(result.output).toContain("Showing first 200 schemas of ≥201") + expect(connector.execute).toHaveBeenCalledTimes(1) +}) + +test("exactly 500 tables is not truncated", async () => { + fake("postgres", 500) expect((await inspect({ warehouse: "remote", schema_name: "one" })).metadata.truncated).toBe(false) }) test("denied schema permission stops inventory access", async () => { const connector = fake("postgres") - const result = await inspect({ warehouse: "remote" }, async () => { throw new Error("permission denied") }) + const result = await inspect({ warehouse: "remote" }, async () => { + throw new Error("permission denied") + }) expect(result.metadata.success).toBe(false) - expect(result.output).toContain("permission denied") + expect(connector.execute).not.toHaveBeenCalled() expect(connector.listSchemas).not.toHaveBeenCalled() }) -test("local count probes are protected and failures omit only the count", async () => { - const connector = fake("sqlite", 1, ":memory:") - connector.execute.mockRejectedValue(new Error("count unavailable")) +test("catalog failure never falls back to an unbounded list", async () => { + const connector = fake("postgres") + connector.execute.mockRejectedValue(new Error("catalog denied")) + expect((await inspect({ warehouse: "remote", schema_name: "one" })).metadata.success).toBe(false) + expect(connector.listTables).not.toHaveBeenCalled() +}) + +test("local counts stop at 50 and mark the rest", async () => { + const connector = fake("sqlite", 100, ":memory:") const result = await inspect({ warehouse: "remote", schema_name: "one" }) expect(result.metadata.success).toBe(true) - expect(result.output).toContain("one | t0 | table | ") - expect(connector.execute.mock.calls[0]).toMatchObject([ - 'SELECT COUNT(*) FROM "one"."t0"', 1, undefined, { readOnly: true, queryTimeoutMs: 1000 }, + expect(connector.execute.mock.calls.filter(([sql]) => /^SELECT COUNT/.test(sql))).toHaveLength(50) + expect(result.output.match(/not counted/g)).toHaveLength(50) + expect(connector.execute.mock.calls[1]).toMatchObject([ + 'SELECT COUNT(*) FROM "one"."t0000000"', + 1, + undefined, + { readOnly: true, queryTimeoutMs: 1000 }, ]) }) +test("slow local counts share a three second budget, including non-cooperative probes", async () => { + const connector = fake("sqlite", 500, ":memory:") + const execute = connector.execute.getMockImplementation()! + connector.execute.mockImplementation((sql, ...args) => + /^SELECT COUNT/.test(sql) ? new Promise(() => {}) : execute(sql, ...args), + ) + const start = performance.now() + const result = await inspect({ warehouse: "remote", schema_name: "one" }) + expect(performance.now() - start).toBeLessThan(4000) + expect(result.metadata.success).toBe(true) + expect(result.output.match(/not counted/g)).toHaveLength(500) + expect(connector.execute.mock.calls.filter(([sql]) => /^SELECT COUNT/.test(sql)).length).toBeLessThanOrEqual(4) +}) + +test("remote catalog statistics are displayed as estimates without table scans", async () => { + const connector = fake("postgres") + connector.execute.mockResolvedValueOnce({ + rows: [["orders", "BASE TABLE", 2000000]], + columns: [], + row_count: 1, + truncated: false, + }) + const result = await inspect({ warehouse: "remote", schema_name: "one" }) + expect(result.output).toContain("orders | table | 2000000 (estimate)") + expect(connector.execute).toHaveBeenCalledTimes(1) +}) + test("empty inventory gives next-step instructions", async () => { - fake("postgres", 0) + const connector = fake("postgres", 0) + connector.execute.mockResolvedValueOnce({ rows: [], columns: [], row_count: 0, truncated: false }) const result = await inspect({}) expect(result.metadata.success).toBe(true) - expect(result.output).toContain("No tables or collections found.") + expect(result.output).toContain("No schemas found.") + expect(result.output).toContain("schema_name=") }) // altimate_change end diff --git a/packages/opencode/test/altimate/tools/schema-inspect-warehouse.test.ts b/packages/opencode/test/altimate/tools/schema-inspect-warehouse.test.ts index bccb6e9580..2ed60c8d01 100644 --- a/packages/opencode/test/altimate/tools/schema-inspect-warehouse.test.ts +++ b/packages/opencode/test/altimate/tools/schema-inspect-warehouse.test.ts @@ -172,7 +172,8 @@ describe("schema_inspect warehouse qualifiers", () => { const tool = await initTool(SchemaInspectTool) expect(tool.jsonSchema?.required ?? []).not.toContain("table") expect(tool.jsonSchema?.properties).not.toHaveProperty("table_name") - expect((await inspect({ warehouse: "primary" })).output).toContain("main | t | table | 1") + expect((await inspect({ warehouse: "primary" })).output).toContain("main | 1") + expect((await inspect({ warehouse: "primary", schema_name: "main" })).output).toContain("main | t | table | 1") }) }) // altimate_change end From 9b89b5b0d31ba4982dd62debf68d16dd672213e3 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 17:16:06 -0700 Subject: [PATCH 05/39] feat(mcp): send analyst-method instructions in the initialize response Claude Code includes MCP server instructions in the model context, so the method (profile before choosing keys, compute in the database, materialize for cross-connection joins, sql_check, report only requested values) now reaches the model without relying on a skill. Co-Authored-By: Claude Opus 5.5 --- .../opencode/src/altimate/mcp-server/server.ts | 16 +++++++++++++++- .../test/altimate/mcp-server/server.test.ts | 12 +++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/altimate/mcp-server/server.ts b/packages/opencode/src/altimate/mcp-server/server.ts index 2ee45a2e47..f0f7a97bef 100644 --- a/packages/opencode/src/altimate/mcp-server/server.ts +++ b/packages/opencode/src/altimate/mcp-server/server.ts @@ -6,6 +6,17 @@ import { createAdapter, descriptions } from "./adapter" import { readOnlyCaveat } from "../native/connections/read-only" import { InstallationVersion } from "@opencode-ai/core/installation/version" +export const SERVER_INSTRUCTIONS = [ + "Analyst method for data questions:", + "1. Start with warehouse_list, then schema_inspect without `table` to see the tables.", + "2. Read schema_inspect's value profile (ranked values, distinct/null counts, unique flag, value shapes) before choosing columns or join keys; formats often differ across databases.", + "3. Compute in the database.", + "4. For joins across connections, sql_materialize the smaller filtered side into `scratch` and join there.", + "5. Run sql_check when keys, fan-out, grain or nulls could change the answer.", + "6. Check that every condition in the question actually filters something.", + "7. Report only the requested values.", +].join("\n") + export interface ServeOptions { connections?: string allowWrite?: boolean @@ -20,7 +31,10 @@ export async function createServer(options: ServeOptions = {}) { } } const adapter = await createAdapter(options) - const server = new Server({ name: "altimate-code", version: InstallationVersion }, { capabilities: { tools: {} } }) + const server = new Server({ name: "altimate-code", version: InstallationVersion }, { + capabilities: { tools: {} }, + instructions: SERVER_INSTRUCTIONS, + }) server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [...adapter.tools].map(([name, tool]) => ({ name, diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index 4e1f7b9147..03f3ce93df 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -6,7 +6,7 @@ import os from "node:os" import path from "node:path" import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" -import { createServer } from "../../../src/altimate/mcp-server/server" +import { createServer, SERVER_INSTRUCTIONS } from "../../../src/altimate/mcp-server/server" import * as Registry from "../../../src/altimate/native/connections/registry" import { Dispatcher } from "../../../src/altimate/native" import type { Connector } from "@altimateai/drivers/types" @@ -53,6 +53,16 @@ function text(result: Awaited>) { return JSON.stringify(result.content) } +mcpTest("initialize result carries short analyst-method instructions", async () => { + const { client } = await fixture() + const instructions = client.getInstructions() + expect(instructions).toBe(SERVER_INSTRUCTIONS) + expect(instructions!.split(/\s+/).length).toBeLessThanOrEqual(120) + for (const tool of ["warehouse_list", "schema_inspect", "sql_materialize", "sql_check"]) { + expect(instructions).toContain(tool) + } +}) + mcpTest("lists exactly five tools and executes warehouse, schema, SQL and checks without routing notices", async () => { const { client, call } = await fixture() const listed = await client.listTools() From 15d2766115f2846c587d8a1793a538256c537c05 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 18:46:10 -0700 Subject: [PATCH 06/39] feat: enforce requested items and number provenance in `answer_commit`; default analyst effort to high - `answer_commit` takes an optional `asked` list (the requested items, in order). With more than one item, every item must map to its own non-empty field or `rows` column; otherwise the call is rejected with the missing items named. At most two such rejections per user turn, then accepted. Filler values (`[]`, `""`, `null`) are ignored; calls without `asked` behave as before. - Committed numbers are compared with numbers in this session's earlier tool output and the user's request, allowing rounding, truncation and x100 / /100 conversions. An unsourced number gets one warning-style rejection per turn; the next commit is accepted. Skipped when no SQL tool has produced output and for integers up to 10. - Rejection counts come from the turn's `answer_commit` error parts in `Tool.Context.messages`, so no extra session state is kept. - `readings` description and the analyst prompt ask for one entry naming the chosen and rejected reading when the wording is ambiguous; readings stay in metadata only. - The analyst requests `high` reasoning effort (adaptive thinking, summarized display) on adaptive-thinking models when no variant or agent effort option is set. `ALTIMATE_ANALYST_EFFORT` selects another level or `off`. The run-budget effort downgrade still applies afterwards. Co-Authored-By: Claude Opus 5.5 --- .../opencode/src/altimate/prompts/analyst.txt | 4 +- .../src/altimate/session/analyst-effort.ts | 36 +++ .../altimate/tools/answer-commit-checks.ts | 220 ++++++++++++++++++ .../src/altimate/tools/answer-commit.ts | 59 ++++- packages/opencode/src/session/llm.ts | 11 + .../test/altimate/analyst-effort.test.ts | 174 ++++++++++++++ .../test/altimate/sql-validation-e2e.test.ts | 4 +- .../test/altimate/tools/answer-commit.test.ts | 125 ++++++++++ 8 files changed, 626 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/src/altimate/session/analyst-effort.ts create mode 100644 packages/opencode/src/altimate/tools/answer-commit-checks.ts create mode 100644 packages/opencode/test/altimate/analyst-effort.test.ts diff --git a/packages/opencode/src/altimate/prompts/analyst.txt b/packages/opencode/src/altimate/prompts/analyst.txt index 27b2c4197c..84840808a5 100644 --- a/packages/opencode/src/altimate/prompts/analyst.txt +++ b/packages/opencode/src/altimate/prompts/analyst.txt @@ -1,6 +1,6 @@ You are altimate-code: a read-only analyst. Do not modify source data or files. When available, `sql_materialize` may write session-local `scratch` tables. -Inspect connections, schemas and actual rows before joining or parsing; key formats may differ across databases. Establish population, grain, denominator and assumptions. Every clause of the question must bite: a filter that removes nothing usually means the wrong column. When a phrase admits two readings (which population, denominator, transform or owner column), compute both, then choose by the question's own definitions and the data's shape; keep that reasoning out of the final message. When a rule or policy decides the answer, read its full text (not a preview), enumerate each competing clause, and evaluate every condition as satisfied, violated or unknown against the row's own fields and dates before choosing. Use `sql_check` for keys, fan-out, grain, nulls or extraction coverage when they affect the answer; trivial queries need no ritual. `sql_analyze` is optional. +Inspect connections, schemas and actual rows before joining or parsing; key formats may differ across databases. Establish population, grain, denominator and assumptions. Every clause of the question must bite: a filter that removes nothing usually means the wrong column. When a phrase admits two readings (which population, denominator, transform or owner column), compute both, then choose by the question's own definitions and the data's shape; keep that reasoning out of the final message, and record the chosen and rejected reading as one `answer_commit` `readings` entry. When a rule or policy decides the answer, read its full text (not a preview), enumerate each competing clause, and evaluate every condition as satisfied, violated or unknown against the row's own fields and dates before choosing. Use `sql_check` for keys, fan-out, grain, nulls or extraction coverage when they affect the answer; trivial queries need no ritual. `sql_analyze` is optional. Derive large intermediate sets in-engine. For a cross-engine join, move the smaller side: a few hundred keys go into an IN list or VALUES against the big table; materialize into `scratch` only the side that is small after filtering, never a multi-million-row table when a filter or aggregate could shrink it first. Preserve requested weighting: group means weighted by contributing counts can be exact; unweighted averages change the weighting. For many similar tables, prefer catalog-driven SQL (information_schema, DuckDB query_table/UNION ALL BY NAME) after confirming dialect support. @@ -20,4 +20,4 @@ Use `training_list` to review learned knowledge. Skills: /teach, /train, /traini When the request supplies an output JSON schema (the `StructuredOutput` tool is available), finish with `StructuredOutput` instead of `answer_commit` or a plain-text reply; follow that schema. The text finalization rules below apply when no output schema is supplied. -Finish by calling `answer_commit` with the single chosen value(s) with one field per item the request asks for (every named item, such as an entity and its related item or count, must be its own field: when the request asks for only the answer, anything left in the basis or prose is dropped), a one-or-two-sentence basis (population, grain, source object/column), or `unresolved` with the reason, then reply with exactly the text it returns and nothing else. Always supply `basis`, even when unresolved; it is never shown when the request asks for only the answer. Never list excluded candidates or alternative answers; a top-N with a tie at the boundary still returns N concrete rows (note the tie briefly in `rows.note`); when the request asks for only the answer or a specific format, set `answer_only`. If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** `, or `**Answer:** unable to determine — `; when the request asks for only the answer or a specific format, the final message is exactly the requested value(s) or table in the requested format, with no label and no basis (or `unable to determine — ` if unresolved); never guess. +Finish by calling `answer_commit` with the single chosen value(s) with one field per item the request asks for (every named item, such as an entity and its related item or count, must be its own field: when the request asks for only the answer, anything left in the basis or prose is dropped), `asked` listing those items from the question in order, a one-or-two-sentence basis (population, grain, source object/column), or `unresolved` with the reason, then reply with exactly the text it returns and nothing else. Always supply `basis`, even when unresolved; it is never shown when the request asks for only the answer. Never list excluded candidates or alternative answers; a top-N with a tie at the boundary still returns N concrete rows (note the tie briefly in `rows.note`); when the request asks for only the answer or a specific format, set `answer_only`. If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** `, or `**Answer:** unable to determine — `; when the request asks for only the answer or a specific format, the final message is exactly the requested value(s) or table in the requested format, with no label and no basis (or `unable to determine — ` if unresolved); never guess. diff --git a/packages/opencode/src/altimate/session/analyst-effort.ts b/packages/opencode/src/altimate/session/analyst-effort.ts new file mode 100644 index 0000000000..400f766383 --- /dev/null +++ b/packages/opencode/src/altimate/session/analyst-effort.ts @@ -0,0 +1,36 @@ +import type { Provider } from "@/provider/provider" +import { ProviderTransform } from "@/provider/transform" + +/** Reasoning effort the analyst requests when neither the user nor the agent config chooses one. */ +export const ANALYST_DEFAULT_EFFORT = "high" +/** Overrides the analyst default effort (low, medium, high, xhigh, max); "off" sends no effort option. */ +export const ANALYST_EFFORT_ENV = "ALTIMATE_ANALYST_EFFORT" + +const DISABLED = new Set(["", "off", "none", "default"]) + +function adaptive(options: Record | undefined): boolean { + return [options?.thinking, options?.reasoningConfig, options?.modelParams?.thinking].some( + (value) => value?.type === "adaptive", + ) +} + +/** + * Default request options that raise the analyst's reasoning effort on adaptive-thinking models. + * Without them the provider applies its own default effort, which can be lower than the analyst needs. + * An explicit variant (from the request or the agent's `variant` config), or `effort`/`thinking` in the + * agent's `options`, takes precedence; the run-budget effort downgrade still applies afterwards. + */ +export function analystEffortOptions(input: { + agent: { name: string; options?: Record } + model: Provider.Model + variant?: string + small?: boolean +}): Record { + if (input.small || input.variant || input.agent.name !== "analyst") return {} + const configured = input.agent.options ?? {} + if ("effort" in configured || "thinking" in configured || "reasoningConfig" in configured) return {} + const effort = (process.env[ANALYST_EFFORT_ENV] ?? ANALYST_DEFAULT_EFFORT).trim().toLowerCase() + if (DISABLED.has(effort) || !input.model.capabilities.reasoning) return {} + const options = ProviderTransform.variants(input.model)[effort] + return adaptive(options) ? options! : {} +} diff --git a/packages/opencode/src/altimate/tools/answer-commit-checks.ts b/packages/opencode/src/altimate/tools/answer-commit-checks.ts new file mode 100644 index 0000000000..3683991660 --- /dev/null +++ b/packages/opencode/src/altimate/tools/answer-commit-checks.ts @@ -0,0 +1,220 @@ +import { Decimal } from "decimal.js" +import type { Tool } from "../../tool/tool" + +/** Markers that identify each check's rejections in this turn's history. */ +export const COVERAGE_REJECTION_MARKER = "answer_commit requested-items check:" +export const NUMBER_REJECTION_MARKER = "answer_commit number check:" +/** Coverage rejections per user turn before a commit is accepted as is. */ +export const MAX_COVERAGE_REJECTIONS = 2 +/** Number-provenance warnings per user turn before a commit is accepted as is. */ +export const MAX_NUMBER_REJECTIONS = 1 +/** Integers up to this magnitude are commonly counted from listed rows; they are not checked. */ +const SMALL_INTEGER_LIMIT = 10 +/** Bound the scan over prior tool output so a huge session cannot stall the commit. */ +const MAX_SCANNED_CHARACTERS = 4_000_000 +/** Significant digits that strip binary float noise (0.1 + 0.2) without losing real precision. */ +const FLOAT_NOISE_PRECISION = 15 + +type Cell = string | number | null +type Committed = { + fields?: { name: string; value: string | number }[] + rows?: { columns: string[]; values: Cell[][] } + asked?: string[] +} + +type LoosePart = { + type?: string + tool?: string + text?: string + state?: { status?: string; output?: unknown; error?: unknown } +} + +/** Messages of the current user turn: everything after the latest user message, plus that message. */ +function currentTurn(messages: Tool.Context["messages"]) { + const start = messages.findLastIndex((message) => message.info.role === "user") + return start < 0 ? messages : messages.slice(start) +} + +/** Earlier answer_commit rejections in this turn carrying the given marker. */ +export function priorRejections(messages: Tool.Context["messages"], marker: string): number { + return currentTurn(messages) + .flatMap((message) => message.parts as LoosePart[]) + .filter( + (part) => + part.type === "tool" && + part.tool === "answer_commit" && + part.state?.status === "error" && + String(part.state.error ?? "").includes(marker), + ).length +} + +const STOPWORDS = new Set([ + "a", "an", "the", "of", "and", "or", "for", "with", "its", "their", "his", "her", "each", "per", "in", "on", + "by", "to", "from", "at", "is", "are", "which", "what", "name", "names", +]) + +function words(label: string): string[] { + return label + .toLowerCase() + .split(/[^\p{L}\p{N}]+/u) + .filter((word) => word && !STOPWORDS.has(word)) + .map((word) => (word.length > 3 && word.endsWith("s") ? word.slice(0, -1) : word)) +} + +/** 3 = same label, 2 = one label contains the other, 1 = a shared content word, 0 = unrelated. */ +function labelScore(asked: string, label: string): number { + const a = words(asked).join(" ") + const b = words(label).join(" ") + if (!a || !b) return 0 + if (a === b) return 3 + if (a.includes(b) || b.includes(a)) return 2 + const shared = new Set(words(asked)) + return words(label).some((word) => shared.has(word)) ? 1 : 0 +} + +/** Pair each asked item with at most one committed label, strongest matches first. */ +function matchAsked(asked: string[], labels: string[]): Map { + const pairs = asked + .flatMap((item, a) => labels.map((label, l) => ({ a, l, score: labelScore(item, label) }))) + .filter((pair) => pair.score > 0) + .sort((x, y) => y.score - x.score || x.a - y.a || x.l - y.l) + const matched = new Map() + const used = new Set() + for (const pair of pairs) { + if (matched.has(pair.a) || used.has(pair.l)) continue + matched.set(pair.a, pair.l) + used.add(pair.l) + } + return matched +} + +const blank = (cell: Cell) => cell === null || (typeof cell === "string" && !cell.trim()) + +/** Asked items that no committed field or column supplies with a non-empty value. */ +export function missingAskedItems(args: Committed): string[] { + const asked = (args.asked ?? []).map((item) => item.trim()).filter(Boolean) + if (asked.length <= 1) return [] + if (args.fields) { + const matched = matchAsked( + asked, + args.fields.map((field) => field.name), + ) + return asked.filter((_, index) => !matched.has(index)) + } + if (args.rows) { + const { columns, values } = args.rows + const matched = matchAsked(asked, columns) + return asked.filter((_, index) => { + const column = matched.get(index) + return column === undefined || values.every((row) => blank(row[column] ?? null)) + }) + } + return [] +} + +export function coverageRejection(missing: string[], asked: string[]): string { + return `${COVERAGE_REJECTION_MARKER} the request asks for ${asked.length} items (${asked.map((item) => JSON.stringify(item)).join(", ")}), but no committed field or column supplies ${missing.map((item) => JSON.stringify(item)).join(", ")}. Call answer_commit again with one non-empty field (or rows column) per asked item, named after that item, in the order asked; a value kept only in basis is dropped from answer-only output.` +} + +/** A committed value as a plain decimal, or undefined for text that is not purely numeric. */ +function numericValue(value: Cell): Decimal | undefined { + if (typeof value === "number") { + return Number.isFinite(value) ? new Decimal(value.toPrecision(FLOAT_NOISE_PRECISION)) : undefined + } + if (typeof value !== "string") return undefined + const stripped = value.trim().replace(/^[$€£¥]\s*/, "").replace(/\s*%$/, "") + if (!/^[-+]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?$/.test(stripped)) return undefined + return new Decimal(stripped.replaceAll(",", "")) +} + +function committedNumbers(args: Committed): Decimal[] { + const cells: Cell[] = [ + ...(args.fields ?? []).map((field) => field.value), + ...(args.rows?.values.flat() ?? []), + ] + return cells + .map(numericValue) + .filter((value): value is Decimal => value !== undefined) + .map((value) => value.abs()) + .filter((value) => !(value.isInteger() && value.lte(SMALL_INTEGER_LIMIT))) +} + +const NUMBER_TOKENS = /\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?(?:[eE][-+]?\d+)?/g + +function textNumbers(text: string, into: Set) { + for (const [token] of text.matchAll(NUMBER_TOKENS)) { + // "12,345" may be one grouped number or two delimited cells; keep both readings. + for (const candidate of token.includes(",") ? [token.replaceAll(",", ""), ...token.split(",")] : [token]) { + try { + into.add(new Decimal(candidate).toString()) + } catch { + // Malformed tokens are not evidence. + } + } + } +} + +const isSqlTool = (tool: string | undefined) => !!tool?.startsWith("sql_") + +/** Numbers seen in this session's tool output and user requests, or undefined when no SQL tool has produced output. */ +export function sessionNumbers(messages: Tool.Context["messages"]): Set | undefined { + const parts = messages.flatMap((message) => + (message.parts as LoosePart[]).map((part) => ({ role: message.info.role, part })), + ) + const sql = parts.some( + ({ part }) => part.type === "tool" && isSqlTool(part.tool) && part.state?.status === "completed", + ) + if (!sql) return undefined + const seen = new Set() + let budget = MAX_SCANNED_CHARACTERS + for (const { role, part } of parts.toReversed()) { + const text = + part.type === "tool" && part.tool !== "answer_commit" && part.state?.status === "completed" + ? String(part.state.output ?? "") + : role === "user" && part.type === "text" + ? (part.text ?? "") + : "" + if (!text) continue + textNumbers(text.slice(0, budget), seen) + budget -= text.length + if (budget <= 0) break + } + return seen +} + +/** Whether any sorted value lies in [low, high). */ +function anyWithin(sorted: number[], low: number, high: number): boolean { + let start = 0 + let end = sorted.length + while (start < end) { + const middle = (start + end) >> 1 + if (sorted[middle]! < low) start = middle + 1 + else end = middle + } + return start < sorted.length && sorted[start]! < high +} + +/** + * Whether the committed number is a seen number rounded (half up) or truncated to the committed + * decimal places, directly or as a ratio/percentage conversion (x100 or /100). + */ +function derivable(value: Decimal, sorted: number[]): boolean { + const unit = new Decimal(10).pow(-value.decimalPlaces()) + // Half-up rounding reaches value from [value - unit/2, value + unit/2); truncation from [value, value + unit). + const low = value.minus(unit.dividedBy(2)) + const high = value.plus(unit) + return [new Decimal(1), new Decimal(100), new Decimal(0.01)].some((scale) => + anyWithin(sorted, low.dividedBy(scale).toNumber(), high.dividedBy(scale).toNumber()), + ) +} + +/** Committed numbers that no prior tool output (or the request) contains, even after simple rounding. */ +export function unsourcedNumbers(args: Committed, seen: Set): string[] { + const sorted = [...seen].map(Number).sort((a, b) => a - b) + const unsourced = committedNumbers(args).filter((value) => !seen.has(value.toString()) && !derivable(value, sorted)) + return [...new Set(unsourced.map((value) => value.toString()))] +} + +export function numberRejection(unsourced: string[]): string { + return `${NUMBER_REJECTION_MARKER} ${unsourced.join(", ")} did not appear in any query result this session and is not a rounding of one. Compute it in SQL and commit the queried value, or, if it is correct as is (for example derived from the question), call answer_commit again unchanged and say how it was derived in basis. This warning is shown once.` +} diff --git a/packages/opencode/src/altimate/tools/answer-commit.ts b/packages/opencode/src/altimate/tools/answer-commit.ts index baf76f3059..74ee3353a7 100644 --- a/packages/opencode/src/altimate/tools/answer-commit.ts +++ b/packages/opencode/src/altimate/tools/answer-commit.ts @@ -3,6 +3,18 @@ import { Decimal } from "decimal.js" import { Effect, Option, Schema, SchemaIssue } from "effect" import type { JSONSchema7 } from "@ai-sdk/provider" import { Tool } from "../../tool/tool" +import { + COVERAGE_REJECTION_MARKER, + MAX_COVERAGE_REJECTIONS, + MAX_NUMBER_REJECTIONS, + NUMBER_REJECTION_MARKER, + coverageRejection, + missingAskedItems, + numberRejection, + priorRejections, + sessionNumbers, + unsourcedNumbers, +} from "./answer-commit-checks" /** Maximum rendered answer length, including Markdown and basis, before the model echoes it. */ export const MAX_RENDERED_ANSWER_CHARACTERS = 60_000 @@ -114,6 +126,9 @@ const parameters = z }) }), ).describe("One entry per item the request asks for, in the order asked."), + asked: optional(z.array(z.string())).describe( + "The items the user's request asks for, in order, as short labels taken from the request (e.g. ['repository', 'most copied file']); name each field or rows column after its item. Never rendered.", + ), rows: optional(rows), unresolved: optional( z @@ -141,7 +156,9 @@ const parameters = z }) .strict(), ), - ).describe("Advisory readings recorded only in metadata, never in the answer text"), + ).describe( + "Required when the request's wording admits more than one reading (population, threshold unit, which status or date column) and you weighed alternatives: one entry whose reading names the chosen interpretation and whose decided_by names the rejected one and why. Recorded only in metadata, never in the answer text", + ), }) .strict() .refine( @@ -279,6 +296,32 @@ export function renderAnswer(payload: unknown): string { return limitRenderedAnswer(args.answer_only ? unresolved : `${basis}**Answer:** ${unresolved}`) } +type CommitMetadata = { + readings: NonNullable["readings"]> + asked: string[] + truncated: boolean + committed_text?: string +} + +/** + * Commit-time checks, each bounded per user turn so a commit is always accepted eventually: + * every asked item needs its own non-empty field or column (two rejections), and numbers should + * come from query output (one warning). Earlier rejections are counted from this turn's history. + */ +function commitRejection( + args: z.output, + messages: Tool.Context["messages"], +): string | undefined { + if (args.unresolved) return undefined + const missing = missingAskedItems(args) + if (missing.length && priorRejections(messages, COVERAGE_REJECTION_MARKER) < MAX_COVERAGE_REJECTIONS) + return coverageRejection(missing, args.asked!.map((item) => item.trim()).filter(Boolean)) + if (priorRejections(messages, NUMBER_REJECTION_MARKER) >= MAX_NUMBER_REJECTIONS) return undefined + const seen = sessionNumbers(messages) + const unsourced = seen ? unsourcedNumbers(args, seen) : [] + return unsourced.length ? numberRejection(unsourced) : undefined +} + // Keep the renderer and native Effect tool on the same parser, including filler normalization. const Parameters = Schema.declareConstructor, AnswerCommitInput>()([], () => (input) => { const parsed = parser.safeParse(input) @@ -291,7 +334,7 @@ export const AnswerCommitTool = Tool.define( "answer_commit", Effect.succeed({ // altimate_change start - expose format selection to the model before the terminal tool call - description: `Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Supply one field per item the request asks for, in the order asked: a request for 'the repository and the most copied file' needs two fields (one named for the repository, one for the file); never collapse a compound request into one field, and never add fields the request did not ask for. Every named item (an entity AND a related item or count) must be its own field, because basis, notes and prose are not shown when the request asks for only the answer; an element found but kept only there is lost. Numbers use JavaScript precision; integers must be safe. Pass exact decimal values, trailing zeros, or currency strings as strings. For fields.precision and each rows.precision value, use an integer from 0 to 10 (inclusive). Set rows.precision by column name to display numeric cells with fixed decimal places, including integers; strings are never changed. Without explicit precision, each column uses the maximum decimal scale of its numeric cells. When the request names a tabular format, set rows.format accordingly: markdown (default), csv, tsv, or lines (one row per line with cells separated by a single space, without headers, for single-column or two-column plain lists). CSV/TSV include headers and quote cells containing the delimiter, quotes, or newlines, doubling embedded quotes; null cells are empty. Always supply \`basis\`; it is never shown when the request asks for only the answer. Supply one or two sentences, at most ${MAX_BASIS_CHARACTERS} characters, naming population, grain, the source object/column that supplies the requested entity or value, and caveats. Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in \`basis\` which source object and column supply it. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. ${renderedLengthGuidance} This tool is not available for JSON-schema requests; use StructuredOutput instead.`, + description: `Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Supply one field per item the request asks for, in the order asked: a request for 'the repository and the most copied file' needs two fields (one named for the repository, one for the file); never collapse a compound request into one field, and never add fields the request did not ask for. Always set \`asked\` to the requested items, in order, as short labels from the request, and name each field or rows column after its item; a commit that leaves an asked item without its own non-empty value is rejected. When the wording admits more than one reading, add one \`readings\` entry naming the chosen reading and the rejected one. Committed numbers should appear in query output (rounding is fine); compute derived numbers in SQL.Every named item (an entity AND a related item or count) must be its own field, because basis, notes and prose are not shown when the request asks for only the answer; an element found but kept only there is lost. Numbers use JavaScript precision; integers must be safe. Pass exact decimal values, trailing zeros, or currency strings as strings. For fields.precision and each rows.precision value, use an integer from 0 to 10 (inclusive). Set rows.precision by column name to display numeric cells with fixed decimal places, including integers; strings are never changed. Without explicit precision, each column uses the maximum decimal scale of its numeric cells. When the request names a tabular format, set rows.format accordingly: markdown (default), csv, tsv, or lines (one row per line with cells separated by a single space, without headers, for single-column or two-column plain lists). CSV/TSV include headers and quote cells containing the delimiter, quotes, or newlines, doubling embedded quotes; null cells are empty. Always supply \`basis\`; it is never shown when the request asks for only the answer. Supply one or two sentences, at most ${MAX_BASIS_CHARACTERS} characters, naming population, grain, the source object/column that supplies the requested entity or value, and caveats. Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in \`basis\` which source object and column supply it. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. ${renderedLengthGuidance} This tool is not available for JSON-schema requests; use StructuredOutput instead.`, // altimate_change end parameters: Parameters, formatValidationError: (error: unknown) => (Schema.isSchemaError(error) ? error.message : String(error)), @@ -304,10 +347,20 @@ export const AnswerCommitTool = Tool.define( return yield* Effect.die(new Error("answer_commit is only available to the analyst agent")) yield* ctx.ask({ permission: "answer_commit", patterns: ["*"], always: ["*"], metadata: {} }) const output = renderAnswer(args) + const rejection = commitRejection(args, ctx.messages ?? []) + if (rejection) { + const rejected: Tool.ExecuteResult = { + title: "Answer not committed", + metadata: { readings: args.readings ?? [], asked: args.asked ?? [], truncated: false }, + output: rejection, + status: "error", + } + return rejected + } return { title: "Answer committed", // The processor caps model-facing output but preserves metadata for the final echo. - metadata: { readings: args.readings ?? [], truncated: false, committed_text: output }, + metadata: { readings: args.readings ?? [], asked: args.asked ?? [], truncated: false, committed_text: output }, output, } }), diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 846c2e8ea2..985ad05fe2 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -51,6 +51,7 @@ import { LLMRequestEvent } from "@/altimate/observability/tracing" // altimate_change start — run deadlines override reasoning effort for each remaining request import { applyBudgetEffort, budgetMessages } from "@/altimate/session/budget-effort" import { activeRun, effortOverride, hasActiveRuns, toolsDisabled } from "@/altimate/session/run-budget-effort" +import { analystEffortOptions } from "@/altimate/session/analyst-effort" import { Session } from "@/session" import { SessionID } from "@/session/schema" // altimate_change end @@ -194,6 +195,16 @@ export namespace LLM { const options: Record = pipe( base, mergeDeep(input.model.options), + // altimate_change start — analyst default reasoning effort; agent options and variants still win + mergeDeep( + analystEffortOptions({ + agent: input.agent, + model: input.model, + variant: input.user.variant, + small: input.small, + }), + ), + // altimate_change end mergeDeep(input.agent.options), mergeDeep(variant), ) diff --git a/packages/opencode/test/altimate/analyst-effort.test.ts b/packages/opencode/test/altimate/analyst-effort.test.ts new file mode 100644 index 0000000000..7f54325d6e --- /dev/null +++ b/packages/opencode/test/altimate/analyst-effort.test.ts @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test" +import { createAnthropic } from "@ai-sdk/anthropic" +import { ANALYST_EFFORT_ENV, analystEffortOptions } from "@/altimate/session/analyst-effort" +import { begin, downgradeEffort } from "@/altimate/session/run-budget-effort" +import { Auth } from "@/auth" +import { Config } from "@/config/config" +import { Plugin } from "@/plugin" +import { Provider } from "@/provider/provider" +import { ModelID, ProviderID } from "@/provider/schema" +import { ProviderTransform } from "@/provider/transform" +import { Session } from "@/session" +import { LLM } from "@/session/llm" +import { MessageID, SessionID } from "@/session/schema" + +function model(npm: string, id: string): Provider.Model { + const result: Provider.Model = { + id: ModelID.make(id), + providerID: ProviderID.make("test"), + api: { npm, id, url: "https://example.invalid" }, + name: id, + capabilities: { + reasoning: true, + temperature: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 200_000, output: 32_000 }, + status: "active", + release_date: "2026-01-01", + options: {}, + headers: {}, + variants: {}, + } + result.variants = ProviderTransform.variants(result) + return result +} + +const analyst = { name: "analyst", options: {} } +const opus = model("@ai-sdk/anthropic", "claude-opus-5-5") + +afterEach(() => { + delete process.env[ANALYST_EFFORT_ENV] +}) + +describe("analyst default reasoning effort", () => { + test("adaptive Claude models get high effort with summarized adaptive thinking", () => { + expect(analystEffortOptions({ agent: analyst, model: opus })).toEqual({ + thinking: { type: "adaptive", display: "summarized" }, + effort: "high", + }) + expect( + analystEffortOptions({ agent: analyst, model: model("@ai-sdk/amazon-bedrock", "anthropic.claude-opus-5-5") }), + ).toMatchObject({ reasoningConfig: { type: "adaptive", maxReasoningEffort: "high" } }) + }) + + test.each([ + ["an older Claude model", model("@ai-sdk/anthropic", "claude-sonnet-4-20250514")], + ["an OpenAI model", model("@ai-sdk/openai", "gpt-5.2")], + ["a Gemini model", model("@ai-sdk/google", "gemini-3-pro")], + ])("adds nothing for %s", (_, other) => { + expect(analystEffortOptions({ agent: analyst, model: other })).toEqual({}) + }) + + test("other agents, small requests, explicit variants and agent options keep their settings", () => { + expect(analystEffortOptions({ agent: { name: "builder", options: {} }, model: opus })).toEqual({}) + expect(analystEffortOptions({ agent: analyst, model: opus, small: true })).toEqual({}) + expect(analystEffortOptions({ agent: analyst, model: opus, variant: "low" })).toEqual({}) + expect(analystEffortOptions({ agent: { name: "analyst", options: { effort: "max" } }, model: opus })).toEqual({}) + }) + + test("the environment selects another effort or disables the default", () => { + process.env[ANALYST_EFFORT_ENV] = "XHIGH" + expect(analystEffortOptions({ agent: analyst, model: opus })).toMatchObject({ effort: "xhigh" }) + process.env[ANALYST_EFFORT_ENV] = "off" + expect(analystEffortOptions({ agent: analyst, model: opus })).toEqual({}) + process.env[ANALYST_EFFORT_ENV] = "unknown" + expect(analystEffortOptions({ agent: analyst, model: opus })).toEqual({}) + }) +}) + +describe("analyst effort in the outgoing request", () => { + async function request(input: { agent: string; model: Provider.Model; downgrade?: boolean }) { + const requests: Record[] = [] + const language = createAnthropic({ + apiKey: "test-key", + fetch: (async (_url, init) => { + requests.push(JSON.parse(String(init?.body))) + const chunks = [ + { + type: "message_start", + message: { + id: "msg_answer", + type: "message", + role: "assistant", + model: input.model.api.id, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 0 }, + }, + }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "ok" } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 1 } }, + { type: "message_stop" }, + ] + return new Response(chunks.map((chunk) => `event: ${chunk.type}\ndata: ${JSON.stringify(chunk)}\n\n`).join(""), { + headers: { "Content-Type": "text/event-stream" }, + }) + }) as typeof fetch, + })(input.model.api.id) + const spies = [ + spyOn(Provider, "getLanguage").mockResolvedValue(language), + spyOn(Provider, "getProvider").mockResolvedValue({ id: input.model.providerID, options: {} } as Provider.Info), + spyOn(Config, "get").mockResolvedValue({ tracing: { enabled: false } }), + spyOn(Auth, "get").mockResolvedValue(undefined), + spyOn(Session, "get").mockImplementation((async (id) => ({ id }) as Session.Info) as typeof Session.get), + spyOn(Plugin, "trigger").mockImplementation(async (_name, _input, output) => output), + ] + const sessionID = SessionID.make(`ses_analyst_effort_${input.agent}_${input.downgrade ? "downgrade" : "default"}`) + const end = input.downgrade ? begin(sessionID) : undefined + if (input.downgrade) downgradeEffort(sessionID) + try { + const stream = await LLM.stream({ + sessionID, + user: { + id: MessageID.make("msg_analyst_effort_test"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: input.agent, + model: { providerID: input.model.providerID, modelID: input.model.id }, + }, + model: input.model, + agent: { name: input.agent, mode: "primary", options: {}, permission: [] }, + system: [], + abort: new AbortController().signal, + messages: [{ role: "user", content: "How many?" }], + tools: {}, + }) + for await (const _event of stream.fullStream) { + /* drain the SDK stream */ + } + expect(requests).toHaveLength(1) + return requests[0]! + } finally { + end?.() + for (const spy of spies) spy.mockRestore() + } + } + + test("an Opus-5.5-style analyst request carries high effort and adaptive thinking", async () => { + const body = await request({ agent: "analyst", model: opus }) + expect(body.output_config?.effort).toBe("high") + expect(body.thinking).toEqual({ type: "adaptive", display: "summarized" }) + }) + + test("other agents and older models send no effort", async () => { + expect((await request({ agent: "builder", model: opus })).output_config?.effort).toBeUndefined() + const older = await request({ agent: "analyst", model: model("@ai-sdk/anthropic", "claude-sonnet-4-20250514") }) + expect(older.output_config?.effort).toBeUndefined() + expect(older.thinking).toBeUndefined() + }) + + test("the time-budget downgrade still lowers the analyst effort", async () => { + const body = await request({ agent: "analyst", model: opus, downgrade: true }) + expect(body.output_config?.effort).toBe("low") + }) +}) diff --git a/packages/opencode/test/altimate/sql-validation-e2e.test.ts b/packages/opencode/test/altimate/sql-validation-e2e.test.ts index ae8c1d3ed2..7ea54c0384 100644 --- a/packages/opencode/test/altimate/sql-validation-e2e.test.ts +++ b/packages/opencode/test/altimate/sql-validation-e2e.test.ts @@ -68,7 +68,7 @@ describe("Tool name consistency in prompts", () => { expect(analyst).toBeDefined() expect(analyst!.prompt).not.toContain("sql_validate") expect(analyst!.prompt).toContain( - "keep that reasoning out of the final message. When a rule or policy decides the answer, read its full text (not a preview), enumerate each competing clause, and evaluate every condition as satisfied, violated or unknown against the row's own fields and dates before choosing.", + "keep that reasoning out of the final message, and record the chosen and rejected reading as one `answer_commit` `readings` entry. When a rule or policy decides the answer, read its full text (not a preview), enumerate each competing clause, and evaluate every condition as satisfied, violated or unknown against the row's own fields and dates before choosing.", ) }, }) @@ -109,7 +109,7 @@ describe("Tool name consistency in prompts", () => { const paragraph = prompt.trim().split("\n\n").at(-1) // altimate_change start - require one field for each requested answer item expect(paragraph).toContain( - "Finish by calling `answer_commit` with the single chosen value(s) with one field per item the request asks for (every named item, such as an entity and its related item or count, must be its own field: when the request asks for only the answer, anything left in the basis or prose is dropped), a one-or-two-sentence basis (population, grain, source object/column), or `unresolved` with the reason, then reply with exactly the text it returns and nothing else.", + "Finish by calling `answer_commit` with the single chosen value(s) with one field per item the request asks for (every named item, such as an entity and its related item or count, must be its own field: when the request asks for only the answer, anything left in the basis or prose is dropped), `asked` listing those items from the question in order, a one-or-two-sentence basis (population, grain, source object/column), or `unresolved` with the reason, then reply with exactly the text it returns and nothing else.", ) // altimate_change end expect(paragraph).toContain("Always supply `basis`, even when unresolved") diff --git a/packages/opencode/test/altimate/tools/answer-commit.test.ts b/packages/opencode/test/altimate/tools/answer-commit.test.ts index 23a43fe90e..53ac86eeb4 100644 --- a/packages/opencode/test/altimate/tools/answer-commit.test.ts +++ b/packages/opencode/test/altimate/tools/answer-commit.test.ts @@ -1130,6 +1130,7 @@ test("answer_commit exposes a serializable object input schema", async () => { expect(tool.jsonSchema?.type).toBe("object") expect(Object.keys(tool.jsonSchema?.properties ?? {})).toEqual([ "fields", + "asked", "rows", "unresolved", "basis", @@ -1240,3 +1241,127 @@ test("answer_commit guidance requires every named item as its own field because }), ).toBe("repo-a; src/a.swift; 23") }) + +describe("answer_commit commit-time checks", () => { + type Part = Record + const user = (text: string) => ({ info: { role: "user" }, parts: [{ type: "text", text }] }) + const assistant = (...parts: Part[]) => ({ info: { role: "assistant" }, parts }) + const sql = (output: string) => ({ type: "tool", tool: "sql_execute", state: { status: "completed", output } }) + const failedCommit = (error: string) => ({ type: "tool", tool: "answer_commit", state: { status: "error", error } }) + const run = async (payload: Record, messages: unknown[]) => { + const tool = await initTool(AnswerCommit.AnswerCommitTool) + return tool.execute({ basis: recordBasis, ...payload }, { ...context, messages }) + } + const compound = { + answer_only: true, + asked: ["repository", "most copied file"], + fields: [{ name: "repository", value: "repo-a" }], + } + + test("rejects a commit that leaves the second asked item in basis only", async () => { + const result = await run(compound, [user("Which repository, and its most copied file?")]) + expect(result.status).toBe("error") + expect(result.output).toContain("answer_commit requested-items check:") + expect(result.output).toContain('"most copied file"') + expect(result.output).not.toContain('supplies "repository"') + expect(result.metadata.committed_text).toBeUndefined() + }) + + test("accepts full coverage and keeps asked out of the answer", async () => { + const result = await run( + { ...compound, fields: [...compound.fields, { name: "file", value: "src/a.swift" }] }, + [user("Which repository, and its most copied file?")], + ) + expect(result.status).toBeUndefined() + expect(result.output).toBe("repo-a; src/a.swift") + expect(result.metadata.asked).toEqual(compound.asked) + }) + + test("rows columns cover asked items and an all-empty column does not", async () => { + const rows = { columns: ["package", "version"], values: [["a", null], ["b", ""]] } + const asked = ["package name", "version"] + expect((await run({ asked, rows }, [])).output).toContain('supplies "version"') + const filled = { ...rows, values: [["a", "1.0"], ["b", null]] } + expect((await run({ asked, rows: filled }, [])).status).toBeUndefined() + }) + + test.each([[[]], [""], [null], [["only item"]], [["", " "]]])("ignores asked filler %j", async (asked) => { + const result = await run({ asked, fields: [{ name: "", value: "repo-a" }], answer_only: true }, []) + expect(result.status).toBeUndefined() + expect(result.output).toBe("repo-a") + }) + + test("accepts after two coverage rejections in the same turn", async () => { + const rejection = (await run(compound, [])).output + const once = [user("q"), assistant(failedCommit(rejection))] + expect((await run(compound, once)).status).toBe("error") + const twice = [user("q"), assistant(failedCommit(rejection)), assistant(failedCommit(rejection))] + const accepted = await run(compound, twice) + expect(accepted.status).toBeUndefined() + expect(accepted.output).toBe("repo-a") + // A new user turn starts a fresh allowance. + expect((await run(compound, [...twice, user("next question")])).status).toBe("error") + }) + + test("legacy calls without asked behave as before", async () => { + const payload = { answer_only: true, fields: [{ name: "repository", value: "repo-a" }] } + const result = await run(payload, [user("Which repository and file?")]) + expect(result).toEqual({ + title: "Answer committed", + metadata: { readings: [], asked: [], truncated: false, committed_text: "repo-a" }, + output: "repo-a", + }) + }) + + test("readings stay in metadata and out of answer-only text", async () => { + const readings = [ + { + clause: "over $5", + reading: "quantity over 5 units", + result_summary: "line 3 violates", + decided_by: "rejected the dollar reading: every line satisfies it, so the clause would not bite", + }, + ] + const result = await run({ answer_only: true, fields: [{ name: "", value: "Volume tiers" }], readings }, []) + expect(result.output).toBe("Volume tiers") + expect(result.metadata.readings).toEqual(readings) + for (const reading of Object.values(readings[0]!)) expect(result.output).not.toContain(reading) + }) + + test("warns once about a number that no query returned, then accepts", async () => { + const history = [user("Fastest average cycle?"), assistant(sql("owner | days\nA | 79\nB | 84\nC | 95"))] + const payload = { answer_only: true, fields: [{ name: "days", value: 81 }] } + const warned = await run(payload, history) + expect(warned.status).toBe("error") + expect(warned.output).toContain("answer_commit number check: 81") + const accepted = await run(payload, [...history, assistant(failedCommit(warned.output))]) + expect(accepted.status).toBeUndefined() + expect(accepted.output).toBe("81") + }) + + test.each([ + ["rounded", 0.16, "ratio\n0.1621"], + ["truncated", 12.3, "avg 12.39"], + ["percentage of a ratio", "16.2%", "share 0.1621"], + ["grouped", "$1,234.50", '{"total": 1234.5}'], + ["float noise", 0.1 + 0.2, "0.3"], + ["from the question", 2015, ""], + ])("accepts a %s number", async (_, value, output) => { + const history = [user("Articles in 2015?"), assistant(sql(output || "count\n42"))] + const result = await run({ answer_only: true, fields: [{ name: "", value }] }, history) + expect(result.status).toBeUndefined() + }) + + test("skips the number check without prior SQL output and for small counts", async () => { + expect((await run({ fields: [{ name: "", value: 81 }] }, [user("q")])).status).toBeUndefined() + const history = [user("q"), assistant(sql("a\nb\nc"))] + expect((await run({ fields: [{ name: "", value: 3 }] }, history)).status).toBeUndefined() + }) + + test("checks numeric row cells", async () => { + const history = [user("q"), assistant(sql("name,score\nx,10.5\ny,9.25"))] + const rows = { columns: ["name", "score"], values: [["x", 10.5], ["y", 47.75]] } + const result = await run({ rows }, history) + expect(result.output).toContain("number check: 47.75") + }) +}) From 7b1060364db098899ddc93b78f0aab6be5292599 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 19:38:26 -0700 Subject: [PATCH 07/39] fix: trim over-long `answer_commit` basis, challenge size-based reading rejections, bind supplied definitions - `basis` over the bound is trimmed with an ellipsis instead of rejecting the whole commit (saves a retry turn) - add a once-per-turn reading check when `readings.decided_by` rejects an alternative only because it returns few or trivial rows - analyst prompt and tool description: a definition or policy in the request is binding, items listed alongside the entity are asked, and rules are tested per line in SQL Co-Authored-By: Claude Sonnet 5.5 --- .../opencode/src/altimate/prompts/analyst.txt | 2 +- .../altimate/tools/answer-commit-checks.ts | 18 ++++++ .../src/altimate/tools/answer-commit.ts | 19 ++++++- .../test/altimate/tools/answer-commit.test.ts | 57 +++++++++++++++++-- 4 files changed, 89 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/altimate/prompts/analyst.txt b/packages/opencode/src/altimate/prompts/analyst.txt index 84840808a5..da1dc2af0d 100644 --- a/packages/opencode/src/altimate/prompts/analyst.txt +++ b/packages/opencode/src/altimate/prompts/analyst.txt @@ -1,6 +1,6 @@ You are altimate-code: a read-only analyst. Do not modify source data or files. When available, `sql_materialize` may write session-local `scratch` tables. -Inspect connections, schemas and actual rows before joining or parsing; key formats may differ across databases. Establish population, grain, denominator and assumptions. Every clause of the question must bite: a filter that removes nothing usually means the wrong column. When a phrase admits two readings (which population, denominator, transform or owner column), compute both, then choose by the question's own definitions and the data's shape; keep that reasoning out of the final message, and record the chosen and rejected reading as one `answer_commit` `readings` entry. When a rule or policy decides the answer, read its full text (not a preview), enumerate each competing clause, and evaluate every condition as satisfied, violated or unknown against the row's own fields and dates before choosing. Use `sql_check` for keys, fan-out, grain, nulls or extraction coverage when they affect the answer; trivial queries need no ritual. `sql_analyze` is optional. +Inspect connections, schemas and actual rows before joining or parsing; key formats may differ across databases. Establish population, grain, denominator and assumptions. Every clause of the question must bite: a filter that removes nothing usually means the wrong column. When a phrase admits two readings (which population, denominator, transform or owner column), compute both, then choose by the question's own definitions and the data's shape; keep that reasoning out of the final message, and record the chosen and rejected reading as one `answer_commit` `readings` entry. A definition or policy supplied in the request is binding: apply its wording literally even when that returns one row or looks trivial, and never reject a reading because it yields few rows. When a sentence lists a related item alongside the entity as something to identify ("the repository and the most copied file"), commit both; an item that only selects or filters the answer is not asked. When a rule or policy decides the answer, read its full text (not a preview), enumerate each competing clause, and evaluate every condition as satisfied, violated or unknown against the row's own fields and dates before choosing, testing each rule against every line with SQL (never by eye); when a rule no row can violate under its literal reading, check the text for a unit, scope or date-column reading that gives it force before dismissing it, while supplied definitions stay authoritative. Use `sql_check` for keys, fan-out, grain, nulls or extraction coverage when they affect the answer; trivial queries need no ritual. `sql_analyze` is optional. Derive large intermediate sets in-engine. For a cross-engine join, move the smaller side: a few hundred keys go into an IN list or VALUES against the big table; materialize into `scratch` only the side that is small after filtering, never a multi-million-row table when a filter or aggregate could shrink it first. Preserve requested weighting: group means weighted by contributing counts can be exact; unweighted averages change the weighting. For many similar tables, prefer catalog-driven SQL (information_schema, DuckDB query_table/UNION ALL BY NAME) after confirming dialect support. diff --git a/packages/opencode/src/altimate/tools/answer-commit-checks.ts b/packages/opencode/src/altimate/tools/answer-commit-checks.ts index 3683991660..ec52aa3ff4 100644 --- a/packages/opencode/src/altimate/tools/answer-commit-checks.ts +++ b/packages/opencode/src/altimate/tools/answer-commit-checks.ts @@ -4,10 +4,13 @@ import type { Tool } from "../../tool/tool" /** Markers that identify each check's rejections in this turn's history. */ export const COVERAGE_REJECTION_MARKER = "answer_commit requested-items check:" export const NUMBER_REJECTION_MARKER = "answer_commit number check:" +export const READING_REJECTION_MARKER = "answer_commit reading check:" /** Coverage rejections per user turn before a commit is accepted as is. */ export const MAX_COVERAGE_REJECTIONS = 2 /** Number-provenance warnings per user turn before a commit is accepted as is. */ export const MAX_NUMBER_REJECTIONS = 1 +/** Reading-challenge warnings per user turn before a commit is accepted as is. */ +export const MAX_READING_REJECTIONS = 1 /** Integers up to this magnitude are commonly counted from listed rows; they are not checked. */ const SMALL_INTEGER_LIMIT = 10 /** Bound the scan over prior tool output so a huge session cannot stall the commit. */ @@ -218,3 +221,18 @@ export function unsourcedNumbers(args: Committed, seen: Set): string[] { export function numberRejection(unsourced: string[]): string { return `${NUMBER_REJECTION_MARKER} ${unsourced.join(", ")} did not appear in any query result this session and is not a rounding of one. Compute it in SQL and commit the queried value, or, if it is correct as is (for example derived from the question), call answer_commit again unchanged and say how it was derived in basis. This warning is shown once.` } + +/** A rejection reason that rests on how few rows a reading returns rather than on what the request says. */ +const RESULT_SIZE_REASON = + /\b(?:only (?:one|1|a single)\b(?![\d,.])|just (?:one|1)\b(?![\d,.])|(?:returns?|yields?|gives?|leaves?) (?:just |only )?(?:one|1|a single|no|zero|0)\b(?![\d,.])|single (?:row|record|result|match|value|candidate|opportunit\w*|entity|group)|no comparison|nothing to compare|too few|too small|trivial(?:ly)?|degenerate)/i +/** decided_by that cites the request's own wording is a reasoned choice, not a size-based one. */ +const CITES_REQUEST = /\b(?:asks?|requests?|requested|explicit(?:ly)?|states?|stated|says?|defines?|defined)\b/i + +/** Entries whose decided_by rejects an alternative reading because it yields a small or trivial result. */ +export function sizeBasedRejections(readings: { clause: string; decided_by: string }[] | undefined): string[] { + return (readings ?? []).filter((entry) => RESULT_SIZE_REASON.test(entry.decided_by) && !CITES_REQUEST.test(entry.decided_by)).map((entry) => entry.clause) +} + +export function readingRejection(clauses: string[]): string { + return `${READING_REJECTION_MARKER} your reading of ${clauses.map((clause) => JSON.stringify(clause)).join(", ")} rejected an alternative because it returns few or trivial rows. A result being small is not evidence against a reading: when the request, a policy or a definition it supplies states the rule, apply that text literally and accept a one-row answer. Choose the alternative only if the request's own wording supports it. Call answer_commit again with the reading the supplied text dictates, or unchanged with decided_by naming the wording that supports your choice. This warning is shown once.` +} diff --git a/packages/opencode/src/altimate/tools/answer-commit.ts b/packages/opencode/src/altimate/tools/answer-commit.ts index 74ee3353a7..ea81b8bcbb 100644 --- a/packages/opencode/src/altimate/tools/answer-commit.ts +++ b/packages/opencode/src/altimate/tools/answer-commit.ts @@ -7,12 +7,16 @@ import { COVERAGE_REJECTION_MARKER, MAX_COVERAGE_REJECTIONS, MAX_NUMBER_REJECTIONS, + MAX_READING_REJECTIONS, NUMBER_REJECTION_MARKER, + READING_REJECTION_MARKER, coverageRejection, missingAskedItems, numberRejection, priorRejections, + readingRejection, sessionNumbers, + sizeBasedRejections, unsourcedNumbers, } from "./answer-commit-checks" @@ -139,8 +143,14 @@ const parameters = z ), basis: z .string({ error: basisRequired }) - .max(MAX_BASIS_CHARACTERS, `basis must contain at most ${MAX_BASIS_CHARACTERS} characters`) .refine((value) => value.trim().length > 0, basisRequired) + // altimate_change start - trim an over-long basis instead of rejecting the commit (a rejected commit costs a retry turn) + .transform((value) => { + if (value.length <= MAX_BASIS_CHARACTERS) return value + const text = value.trim() + return text.length <= MAX_BASIS_CHARACTERS ? text : `${text.slice(0, MAX_BASIS_CHARACTERS - 1).trimEnd()}…` + }) + // altimate_change end .describe( `basis is always required: one or two sentences, at most ${MAX_BASIS_CHARACTERS} characters, naming population, grain, the source object/column that supplies the requested entity or value, and caveats; never rendered with answer_only`, ), @@ -316,6 +326,11 @@ function commitRejection( const missing = missingAskedItems(args) if (missing.length && priorRejections(messages, COVERAGE_REJECTION_MARKER) < MAX_COVERAGE_REJECTIONS) return coverageRejection(missing, args.asked!.map((item) => item.trim()).filter(Boolean)) + // altimate_change start - challenge a reading rejected only because it yields a small result + const sizeBased = sizeBasedRejections(args.readings) + if (sizeBased.length && priorRejections(messages, READING_REJECTION_MARKER) < MAX_READING_REJECTIONS) + return readingRejection(sizeBased) + // altimate_change end if (priorRejections(messages, NUMBER_REJECTION_MARKER) >= MAX_NUMBER_REJECTIONS) return undefined const seen = sessionNumbers(messages) const unsourced = seen ? unsourcedNumbers(args, seen) : [] @@ -334,7 +349,7 @@ export const AnswerCommitTool = Tool.define( "answer_commit", Effect.succeed({ // altimate_change start - expose format selection to the model before the terminal tool call - description: `Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Supply one field per item the request asks for, in the order asked: a request for 'the repository and the most copied file' needs two fields (one named for the repository, one for the file); never collapse a compound request into one field, and never add fields the request did not ask for. Always set \`asked\` to the requested items, in order, as short labels from the request, and name each field or rows column after its item; a commit that leaves an asked item without its own non-empty value is rejected. When the wording admits more than one reading, add one \`readings\` entry naming the chosen reading and the rejected one. Committed numbers should appear in query output (rounding is fine); compute derived numbers in SQL.Every named item (an entity AND a related item or count) must be its own field, because basis, notes and prose are not shown when the request asks for only the answer; an element found but kept only there is lost. Numbers use JavaScript precision; integers must be safe. Pass exact decimal values, trailing zeros, or currency strings as strings. For fields.precision and each rows.precision value, use an integer from 0 to 10 (inclusive). Set rows.precision by column name to display numeric cells with fixed decimal places, including integers; strings are never changed. Without explicit precision, each column uses the maximum decimal scale of its numeric cells. When the request names a tabular format, set rows.format accordingly: markdown (default), csv, tsv, or lines (one row per line with cells separated by a single space, without headers, for single-column or two-column plain lists). CSV/TSV include headers and quote cells containing the delimiter, quotes, or newlines, doubling embedded quotes; null cells are empty. Always supply \`basis\`; it is never shown when the request asks for only the answer. Supply one or two sentences, at most ${MAX_BASIS_CHARACTERS} characters, naming population, grain, the source object/column that supplies the requested entity or value, and caveats. Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in \`basis\` which source object and column supply it. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. ${renderedLengthGuidance} This tool is not available for JSON-schema requests; use StructuredOutput instead.`, + description: `Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Supply one field per item the request asks for, in the order asked: a request for 'the repository and the most copied file' needs two fields (one named for the repository, one for the file); never collapse a compound request into one field, and never add fields the request did not ask for. Always set \`asked\` to the requested items, in order, as short labels from the request, and name each field or rows column after its item; a commit that leaves an asked item without its own non-empty value is rejected. When the wording admits more than one reading, add one \`readings\` entry naming the chosen reading and the rejected one; a rule or definition supplied in the request outranks plausibility, so never reject a reading just because it returns few rows. When a sentence lists a related item alongside the entity as something to identify (for example 'the repository and the most copied file'), treat it as asked and commit both; an item that only selects or filters the answer ('the customer with the largest invoice') is not asked. Committed numbers should appear in query output (rounding is fine); compute derived numbers in SQL.Every named item (an entity AND a related item or count) must be its own field, because basis, notes and prose are not shown when the request asks for only the answer; an element found but kept only there is lost. Numbers use JavaScript precision; integers must be safe. Pass exact decimal values, trailing zeros, or currency strings as strings. For fields.precision and each rows.precision value, use an integer from 0 to 10 (inclusive). Set rows.precision by column name to display numeric cells with fixed decimal places, including integers; strings are never changed. Without explicit precision, each column uses the maximum decimal scale of its numeric cells. When the request names a tabular format, set rows.format accordingly: markdown (default), csv, tsv, or lines (one row per line with cells separated by a single space, without headers, for single-column or two-column plain lists). CSV/TSV include headers and quote cells containing the delimiter, quotes, or newlines, doubling embedded quotes; null cells are empty. Always supply \`basis\`; it is never shown when the request asks for only the answer. Supply one or two sentences, at most ${MAX_BASIS_CHARACTERS} characters, naming population, grain, the source object/column that supplies the requested entity or value, and caveats. Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in \`basis\` which source object and column supply it. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. ${renderedLengthGuidance} This tool is not available for JSON-schema requests; use StructuredOutput instead.`, // altimate_change end parameters: Parameters, formatValidationError: (error: unknown) => (Schema.isSchemaError(error) ? error.message : String(error)), diff --git a/packages/opencode/test/altimate/tools/answer-commit.test.ts b/packages/opencode/test/altimate/tools/answer-commit.test.ts index 53ac86eeb4..b9c946d904 100644 --- a/packages/opencode/test/altimate/tools/answer-commit.test.ts +++ b/packages/opencode/test/altimate/tools/answer-commit.test.ts @@ -1012,17 +1012,35 @@ describe("renderAnswer basis length bound", () => { expect((await tool.execute(payload, context)).output).toBe(expected) }) - test.each(["x".repeat(401), ` ${"x".repeat(399)} `])( - "rejects 401 raw basis characters with the named bound", + test.each(["x".repeat(401), "x".repeat(1200), ` ${"x".repeat(400)}`])( + "trims an over-long basis to the bound instead of rejecting the commit", async (basis) => { const tool = await initTool(AnswerCommit.AnswerCommitTool) for (const answer_only of [false, true]) { const payload = { basis, answer_only, fields: [{ name: "", value: 2 }] } - expect(() => AnswerCommit.renderAnswer(payload)).toThrow(/basis.*400 characters/i) - await rejects(tool.execute(payload, context), /basis.*400 characters/i) + const result = await tool.execute(payload, context) + expect(result.status).toBeUndefined() + const shown = String(result.output) + if (answer_only) expect(shown).toBe("2") + else { + const trimmed = shown.split("\n\n**Answer:** 2")[0]! + expect(trimmed.length).toBeLessThanOrEqual(400) + expect(trimmed.endsWith("…")).toBe(basis.trim().length > 400) + } } }, ) + + test("keeps substantive basis text that follows leading whitespace", async () => { + const tool = await initTool(AnswerCommit.AnswerCommitTool) + const payload = { basis: `${" ".repeat(450)}real caveat`, fields: [{ name: "", value: 2 }] } + expect(String((await tool.execute(payload, context)).output)).toContain("real caveat") + }) + + test("still requires a non-blank basis", async () => { + const tool = await initTool(AnswerCommit.AnswerCommitTool) + await rejects(tool.execute({ basis: " ", fields: [{ name: "", value: 2 }] }, context), /basis is always required/i) + }) }) describe("renderAnswer structural validation", () => { @@ -1358,6 +1376,37 @@ describe("answer_commit commit-time checks", () => { expect((await run({ fields: [{ name: "", value: 3 }] }, history)).status).toBeUndefined() }) + const sized = (decided_by: string) => ({ + answer_only: true, + fields: [{ name: "agent", value: "A1" }], + readings: [{ clause: "closed in April", reading: "close date", result_summary: "3 agents", decided_by }], + }) + + test("challenges a reading rejected only for returning a single row, once", async () => { + const payload = sized("Rejected the signed-date reading because it yields only one opportunity, no comparison") + const warned = await run(payload, [user("q")]) + expect(warned.status).toBe("error") + expect(warned.output).toContain("answer_commit reading check:") + expect(warned.output).toContain('"closed in April"') + const accepted = await run(payload, [user("q"), assistant(failedCommit(warned.output))]) + expect(accepted.status).toBeUndefined() + expect(accepted.output).toBe("A1") + }) + + test("challenges a reading rejected because it returns 1 row", async () => { + const result = await run(sized("Rejected the second reading since it returns 1 row"), [user("q")]) + expect(result.output).toContain("answer_commit reading check:") + }) + + test.each([ + "Rejected the signed-date reading because the policy defines closing as the close date", + "Rejected summing orders: the question asks for distinct customers", + "The request explicitly asks for one row", + "Rejected the 100-customer subset: only 100 customers matched the stated filter", + ])("does not challenge a reading rejected on the request's wording: %s", async (decided_by) => { + expect((await run(sized(decided_by), [user("q")])).status).toBeUndefined() + }) + test("checks numeric row cells", async () => { const history = [user("q"), assistant(sql("name,score\nx,10.5\ny,9.25"))] const rows = { columns: ["name", "score"], values: [["x", 10.5], ["y", 47.75]] } From 7684b382008ce3d023fb0096d03e939ee9579f93 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 19:45:01 -0700 Subject: [PATCH 08/39] feat(mcp): serve answer_commit with requested-item and number-provenance checks Merge the native answer_commit checks (15d2766115) and expose the tool over MCP. The server records tool output and rejections as turn history so the same check code runs unchanged; an over-long basis is trimmed instead of rejected, and an accepted commit returns canonical FINAL ANSWER text. Co-Authored-By: Claude Sonnet 5.5 --- .../src/altimate/mcp-server/adapter.ts | 55 +++++++++++++++++-- .../src/altimate/mcp-server/server.ts | 2 +- .../test/altimate/mcp-server/server.test.ts | 49 ++++++++++++++++- .../test/altimate/mcp-server/stdio.test.ts | 2 +- 4 files changed, 100 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/altimate/mcp-server/adapter.ts b/packages/opencode/src/altimate/mcp-server/adapter.ts index d5f90dd9be..cc6a7b406a 100644 --- a/packages/opencode/src/altimate/mcp-server/adapter.ts +++ b/packages/opencode/src/altimate/mcp-server/adapter.ts @@ -14,6 +14,7 @@ import { SchemaInspectTool } from "../tools/schema-inspect" import { SqlExecuteTool } from "../tools/sql-execute" import { SqlCheckTool } from "../tools/sql-check" import { SqlMaterializeTool } from "../tools/sql-materialize" +import { AnswerCommitTool } from "../tools/answer-commit" import * as Precedence from "../workspace/precedence" import { closeScratch } from "../native/connections/scratch" import type { SessionID, MessageID } from "@/session/schema" @@ -25,6 +26,21 @@ export const descriptions: Record = { "Execute SQL against a connected warehouse and return rows. Source writes require the server's --allow-write option.", sql_check: "Run a read-only SQL data quality check against a connected warehouse.", sql_materialize: "Copy a read-only source query into a session-local scratch table for further analysis.", + answer_commit: + "Finish a data question. Call this last, with one field (or rows column) per item the request asks for and `asked` listing those items in order; the call is rejected when an asked item has no value of its own or a number was not seen in query output. Reply with exactly the returned FINAL ANSWER text.", +} + +// The native tool caps `basis` at 400 characters; over the MCP boundary the text is trimmed instead of rejected. +const BASIS_LIMIT = 400 +const MCP_ONLY_TOOLS = new Set(["answer_commit"]) + +/** Session-level record the native commit checks read: tool outputs and rejections of the current turn. */ +type TurnMessage = Tool.Context["messages"][number] +function userMessage(): TurnMessage { + return { info: { role: "user" }, parts: [] } as unknown as TurnMessage +} +function toolMessage(tool: string, state: Record): TurnMessage { + return { info: { role: "assistant" }, parts: [{ type: "tool", tool, state }] } as unknown as TurnMessage } // No agent runtime or provider initialization; only local output settings are loaded. @@ -50,6 +66,14 @@ function services(directory: string, limits: Awaited(schema: T): T { + const copy = JSON.parse(JSON.stringify(schema).replaceAll(`at most ${BASIS_LIMIT} characters`, "kept brief")) as { + properties?: { basis?: { maxLength?: number } } + } + delete copy.properties?.basis?.maxLength + return copy as T +} + export async function createAdapter(options: { allowWrite?: boolean } = {}) { const limits = await outputLimits() const directory = await mkdtemp(path.join(tmpdir(), "altimate-code-mcp-results-")) @@ -60,7 +84,7 @@ export async function createAdapter(options: { allowWrite?: boolean } = {}) { Effect.scoped( Effect.gen(function* () { const result = new Map() - for (const source of [WarehouseListTool, SchemaInspectTool, SqlExecuteTool, SqlCheckTool, SqlMaterializeTool]) { + for (const source of [WarehouseListTool, SchemaInspectTool, SqlExecuteTool, SqlCheckTool, SqlMaterializeTool, AnswerCommitTool]) { const info = yield* source const tool = yield* Tool.init(info, { agent: analyst }) if (tool.jsonSchema) { @@ -71,6 +95,7 @@ export async function createAdapter(options: { allowWrite?: boolean } = {}) { ), ) } + if (tool.jsonSchema && info.id === "answer_commit") tool.jsonSchema = relaxBasis(tool.jsonSchema) result.set(info.id, tool) } return result @@ -82,8 +107,22 @@ export async function createAdapter(options: { allowWrite?: boolean } = {}) { throw error }) + const messages: TurnMessage[] = [userMessage()] const shutdown = new AbortController() const active = new Set>() + // Feed the native commit checks: completed tool output and rejections, with a new turn after an accepted commit. + const record = (name: string, result: Tool.ExecuteResult) => { + if (name !== "answer_commit") { + if (result.status !== "error") messages.push(toolMessage(name, { status: "completed", output: result.output })) + return result + } + if (result.status === "error") { + messages.push(toolMessage(name, { status: "error", error: result.output })) + return result + } + messages.push(userMessage()) + return { ...result, output: `FINAL ANSWER: ${result.metadata.committed_text ?? result.output}` } + } return { tools, async execute(name: string, args: Record, signal: AbortSignal) { @@ -97,18 +136,26 @@ export async function createAdapter(options: { allowWrite?: boolean } = {}) { agent: "analyst", abort: signal, extra: { mcpClient: true, forceReadOnly: !options.allowWrite }, - messages: [], + messages, metadata: () => Effect.void, // Fail closed for new permission kinds. sql_execute is the validated source-read permission. ask: ({ permission }) => - permission === "sql_execute" || permission === "schema_inspect" || options.allowWrite + permission === "sql_execute" || + permission === "schema_inspect" || + permission === "answer_commit" || options.allowWrite ? Effect.void : Effect.die(new Error(`Permission denied: ${permission}. Restart with --allow-write to enable writes.`)), } + if (MCP_ONLY_TOOLS.has(name) && typeof args.basis === "string" && args.basis.length > BASIS_LIMIT) + args = { ...args, basis: args.basis.slice(0, BASIS_LIMIT) } const pending = Effect.runPromise(tool.execute(args, ctx)) active.add(pending) try { - return await pending + const result = await pending + return record(name, result) + } catch (error) { + if (name === "answer_commit") messages.push(toolMessage(name, { status: "error", error: String(error) })) + throw error } finally { active.delete(pending) } diff --git a/packages/opencode/src/altimate/mcp-server/server.ts b/packages/opencode/src/altimate/mcp-server/server.ts index f0f7a97bef..146fb42a4f 100644 --- a/packages/opencode/src/altimate/mcp-server/server.ts +++ b/packages/opencode/src/altimate/mcp-server/server.ts @@ -14,7 +14,7 @@ export const SERVER_INSTRUCTIONS = [ "4. For joins across connections, sql_materialize the smaller filtered side into `scratch` and join there.", "5. Run sql_check when keys, fan-out, grain or nulls could change the answer.", "6. Check that every condition in the question actually filters something.", - "7. Report only the requested values.", + "7. Finish with answer_commit: `asked` lists the requested items, one field per item; reply with exactly the FINAL ANSWER text it returns.", ].join("\n") export interface ServeOptions { diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index 03f3ce93df..f243692f4b 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -63,10 +63,11 @@ mcpTest("initialize result carries short analyst-method instructions", async () } }) -mcpTest("lists exactly five tools and executes warehouse, schema, SQL and checks without routing notices", async () => { +mcpTest("lists exactly six tools and executes warehouse, schema, SQL and checks without routing notices", async () => { const { client, call } = await fixture() const listed = await client.listTools() expect(listed.tools.map((t) => t.name).sort()).toEqual([ + "answer_commit", "schema_inspect", "sql_check", "sql_execute", @@ -232,7 +233,7 @@ mcpTest("server startup and tool listing make no network calls", async () => { ) try { const { client } = await fixture() - expect((await client.listTools()).tools).toHaveLength(5) + expect((await client.listTools()).tools).toHaveLength(6) expect(fetch).not.toHaveBeenCalled() } finally { fetch.mockRestore() @@ -454,3 +455,47 @@ mcpTest("server close aborts active source queries before cleanup", async () => } finally { get.mockRestore() } }) // altimate_change end + +// altimate_change start — answer_commit over MCP: requested-item coverage, number provenance, basis trimming +const commit = (fields: { name: string; value: string | number }[], asked: string[], basis = "Counted items.") => ({ + fields, + asked, + basis, +}) + +mcpTest("answer_commit returns the canonical FINAL ANSWER text and trims a long basis", async () => { + const { call } = await fixture() + const result = await call("answer_commit", commit([{ name: "", value: "apple" }], ["item"], "x".repeat(900))) + expect(result.isError).toBeFalsy() + const out = (result.content as { text: string }[])[0]!.text + expect(out.startsWith("FINAL ANSWER: ")).toBe(true) + expect(out).toContain("apple") +}) + +mcpTest("answer_commit rejects a missing asked item at most twice, then accepts", async () => { + const { call } = await fixture() + const args = commit([{ name: "item", value: "apple" }], ["item", "count"]) + for (let i = 0; i < 2; i++) { + const rejected = await call("answer_commit", args) + expect(rejected.isError).toBe(true) + expect(text(rejected)).toContain("requested-items check") + } + expect((await call("answer_commit", args)).isError).toBeFalsy() +}) + +mcpTest("answer_commit warns once about a number absent from query output", async () => { + const { call } = await fixture() + await call("sql_execute", { warehouse: "source", query: "SELECT COUNT(*) AS n FROM items" }) + const args = commit([{ name: "total", value: 98765 }], ["total"]) + const warned = await call("answer_commit", args) + expect(warned.isError).toBe(true) + expect(text(warned)).toContain("number check") + expect((await call("answer_commit", args)).isError).toBeFalsy() +}) + +mcpTest("answer_commit accepts a number that appeared in query output", async () => { + const { call } = await fixture() + await call("sql_execute", { warehouse: "source", query: "SELECT 4321 AS n" }) + expect((await call("answer_commit", commit([{ name: "n", value: 4321 }], ["n"]))).isError).toBeFalsy() +}) +// altimate_change end diff --git a/packages/opencode/test/altimate/mcp-server/stdio.test.ts b/packages/opencode/test/altimate/mcp-server/stdio.test.ts index 23fb0d5f57..61eeb11abc 100644 --- a/packages/opencode/test/altimate/mcp-server/stdio.test.ts +++ b/packages/opencode/test/altimate/mcp-server/stdio.test.ts @@ -47,7 +47,7 @@ for (const allowWrite of [false, true]) { let spill: string | undefined try { await client.connect(transport) - expect((await client.listTools()).tools).toHaveLength(5) + expect((await client.listTools()).tools).toHaveLength(6) const write = await client.callTool({ name: "sql_execute", arguments: { warehouse: "source", query: "INSERT INTO items VALUES (7)" }, From 461fd2423f217770710fd7666ff7052f9d8f04e8 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 19:58:39 -0700 Subject: [PATCH 09/39] test: update help snapshot for `mcp serve` Co-Authored-By: Claude Sonnet 5.5 --- .../test/cli/help/__snapshots__/help-snapshots.test.ts.snap | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index 6c6eecf454..7cef008fca 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -34,6 +34,7 @@ manage MCP (Model Context Protocol) servers Commands: altimate-code mcp add add an MCP server altimate-code mcp list list MCP servers and their status [aliases: ls] + altimate-code mcp serve serve data tools over MCP stdio (no model required) altimate-code mcp status show MCP server health altimate-code mcp auth [name] authenticate with an OAuth-enabled MCP server altimate-code mcp logout [name] remove OAuth credentials for an MCP server From 8edbe3d539de47af85ea10e2b8b59ab4ef2fe852 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 20:14:24 -0700 Subject: [PATCH 10/39] test: pin the bound-definition sentence in the analyst prompt Co-Authored-By: Claude Sonnet 5.5 --- packages/opencode/test/altimate/sql-validation-e2e.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/altimate/sql-validation-e2e.test.ts b/packages/opencode/test/altimate/sql-validation-e2e.test.ts index 7ea54c0384..460fbfdaa8 100644 --- a/packages/opencode/test/altimate/sql-validation-e2e.test.ts +++ b/packages/opencode/test/altimate/sql-validation-e2e.test.ts @@ -68,7 +68,10 @@ describe("Tool name consistency in prompts", () => { expect(analyst).toBeDefined() expect(analyst!.prompt).not.toContain("sql_validate") expect(analyst!.prompt).toContain( - "keep that reasoning out of the final message, and record the chosen and rejected reading as one `answer_commit` `readings` entry. When a rule or policy decides the answer, read its full text (not a preview), enumerate each competing clause, and evaluate every condition as satisfied, violated or unknown against the row's own fields and dates before choosing.", + "keep that reasoning out of the final message, and record the chosen and rejected reading as one `answer_commit` `readings` entry. A definition or policy supplied in the request is binding: apply its wording literally even when that returns one row or looks trivial, and never reject a reading because it yields few rows.", + ) + expect(analyst!.prompt).toContain( + "When a rule or policy decides the answer, read its full text (not a preview), enumerate each competing clause, and evaluate every condition as satisfied, violated or unknown against the row's own fields and dates before choosing", ) }, }) From e8934cc75141d72027c56234556a77b76ba6db7b Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 20:16:29 -0700 Subject: [PATCH 11/39] fix: keep supplied definitions from moving the requested entity A definition or policy in the request binds the metric, date column, threshold and eligibility it defines, but the requested entity still comes from the object the metric is about. Co-Authored-By: Claude Sonnet 5.5 --- packages/opencode/src/altimate/prompts/analyst.txt | 2 +- packages/opencode/src/altimate/tools/answer-commit.ts | 2 +- packages/opencode/test/altimate/sql-validation-e2e.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/altimate/prompts/analyst.txt b/packages/opencode/src/altimate/prompts/analyst.txt index da1dc2af0d..4f37ca040c 100644 --- a/packages/opencode/src/altimate/prompts/analyst.txt +++ b/packages/opencode/src/altimate/prompts/analyst.txt @@ -1,6 +1,6 @@ You are altimate-code: a read-only analyst. Do not modify source data or files. When available, `sql_materialize` may write session-local `scratch` tables. -Inspect connections, schemas and actual rows before joining or parsing; key formats may differ across databases. Establish population, grain, denominator and assumptions. Every clause of the question must bite: a filter that removes nothing usually means the wrong column. When a phrase admits two readings (which population, denominator, transform or owner column), compute both, then choose by the question's own definitions and the data's shape; keep that reasoning out of the final message, and record the chosen and rejected reading as one `answer_commit` `readings` entry. A definition or policy supplied in the request is binding: apply its wording literally even when that returns one row or looks trivial, and never reject a reading because it yields few rows. When a sentence lists a related item alongside the entity as something to identify ("the repository and the most copied file"), commit both; an item that only selects or filters the answer is not asked. When a rule or policy decides the answer, read its full text (not a preview), enumerate each competing clause, and evaluate every condition as satisfied, violated or unknown against the row's own fields and dates before choosing, testing each rule against every line with SQL (never by eye); when a rule no row can violate under its literal reading, check the text for a unit, scope or date-column reading that gives it force before dismissing it, while supplied definitions stay authoritative. Use `sql_check` for keys, fan-out, grain, nulls or extraction coverage when they affect the answer; trivial queries need no ritual. `sql_analyze` is optional. +Inspect connections, schemas and actual rows before joining or parsing; key formats may differ across databases. Establish population, grain, denominator and assumptions. Every clause of the question must bite: a filter that removes nothing usually means the wrong column. When a phrase admits two readings (which population, denominator, transform or owner column), compute both, then choose by the question's own definitions and the data's shape; keep that reasoning out of the final message, and record the chosen and rejected reading as one `answer_commit` `readings` entry. A definition or policy supplied in the request is binding for what it defines (the metric, date column, threshold or eligibility): apply its wording literally even when that returns one row or looks trivial, and never reject a reading because it yields few rows. It does not move the requested entity: take the entity from the object the question's metric is about, not from an object that only supplies eligibility. When a sentence lists a related item alongside the entity as something to identify ("the repository and the most copied file"), commit both; an item that only selects or filters the answer is not asked. When a rule or policy decides the answer, read its full text (not a preview), enumerate each competing clause, and evaluate every condition as satisfied, violated or unknown against the row's own fields and dates before choosing, testing each rule against every line with SQL (never by eye); when a rule no row can violate under its literal reading, check the text for a unit, scope or date-column reading that gives it force before dismissing it, while supplied definitions stay authoritative. Use `sql_check` for keys, fan-out, grain, nulls or extraction coverage when they affect the answer; trivial queries need no ritual. `sql_analyze` is optional. Derive large intermediate sets in-engine. For a cross-engine join, move the smaller side: a few hundred keys go into an IN list or VALUES against the big table; materialize into `scratch` only the side that is small after filtering, never a multi-million-row table when a filter or aggregate could shrink it first. Preserve requested weighting: group means weighted by contributing counts can be exact; unweighted averages change the weighting. For many similar tables, prefer catalog-driven SQL (information_schema, DuckDB query_table/UNION ALL BY NAME) after confirming dialect support. diff --git a/packages/opencode/src/altimate/tools/answer-commit.ts b/packages/opencode/src/altimate/tools/answer-commit.ts index ea81b8bcbb..347890cd99 100644 --- a/packages/opencode/src/altimate/tools/answer-commit.ts +++ b/packages/opencode/src/altimate/tools/answer-commit.ts @@ -349,7 +349,7 @@ export const AnswerCommitTool = Tool.define( "answer_commit", Effect.succeed({ // altimate_change start - expose format selection to the model before the terminal tool call - description: `Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Supply one field per item the request asks for, in the order asked: a request for 'the repository and the most copied file' needs two fields (one named for the repository, one for the file); never collapse a compound request into one field, and never add fields the request did not ask for. Always set \`asked\` to the requested items, in order, as short labels from the request, and name each field or rows column after its item; a commit that leaves an asked item without its own non-empty value is rejected. When the wording admits more than one reading, add one \`readings\` entry naming the chosen reading and the rejected one; a rule or definition supplied in the request outranks plausibility, so never reject a reading just because it returns few rows. When a sentence lists a related item alongside the entity as something to identify (for example 'the repository and the most copied file'), treat it as asked and commit both; an item that only selects or filters the answer ('the customer with the largest invoice') is not asked. Committed numbers should appear in query output (rounding is fine); compute derived numbers in SQL.Every named item (an entity AND a related item or count) must be its own field, because basis, notes and prose are not shown when the request asks for only the answer; an element found but kept only there is lost. Numbers use JavaScript precision; integers must be safe. Pass exact decimal values, trailing zeros, or currency strings as strings. For fields.precision and each rows.precision value, use an integer from 0 to 10 (inclusive). Set rows.precision by column name to display numeric cells with fixed decimal places, including integers; strings are never changed. Without explicit precision, each column uses the maximum decimal scale of its numeric cells. When the request names a tabular format, set rows.format accordingly: markdown (default), csv, tsv, or lines (one row per line with cells separated by a single space, without headers, for single-column or two-column plain lists). CSV/TSV include headers and quote cells containing the delimiter, quotes, or newlines, doubling embedded quotes; null cells are empty. Always supply \`basis\`; it is never shown when the request asks for only the answer. Supply one or two sentences, at most ${MAX_BASIS_CHARACTERS} characters, naming population, grain, the source object/column that supplies the requested entity or value, and caveats. Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in \`basis\` which source object and column supply it. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. ${renderedLengthGuidance} This tool is not available for JSON-schema requests; use StructuredOutput instead.`, + description: `Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Supply one field per item the request asks for, in the order asked: a request for 'the repository and the most copied file' needs two fields (one named for the repository, one for the file); never collapse a compound request into one field, and never add fields the request did not ask for. Always set \`asked\` to the requested items, in order, as short labels from the request, and name each field or rows column after its item; a commit that leaves an asked item without its own non-empty value is rejected. When the wording admits more than one reading, add one \`readings\` entry naming the chosen reading and the rejected one; a rule or definition supplied in the request outranks plausibility for what it defines (metric, date column, threshold, eligibility) but does not change which object supplies the requested entity, so never reject a reading just because it returns few rows. When a sentence lists a related item alongside the entity as something to identify (for example 'the repository and the most copied file'), treat it as asked and commit both; an item that only selects or filters the answer ('the customer with the largest invoice') is not asked. Committed numbers should appear in query output (rounding is fine); compute derived numbers in SQL.Every named item (an entity AND a related item or count) must be its own field, because basis, notes and prose are not shown when the request asks for only the answer; an element found but kept only there is lost. Numbers use JavaScript precision; integers must be safe. Pass exact decimal values, trailing zeros, or currency strings as strings. For fields.precision and each rows.precision value, use an integer from 0 to 10 (inclusive). Set rows.precision by column name to display numeric cells with fixed decimal places, including integers; strings are never changed. Without explicit precision, each column uses the maximum decimal scale of its numeric cells. When the request names a tabular format, set rows.format accordingly: markdown (default), csv, tsv, or lines (one row per line with cells separated by a single space, without headers, for single-column or two-column plain lists). CSV/TSV include headers and quote cells containing the delimiter, quotes, or newlines, doubling embedded quotes; null cells are empty. Always supply \`basis\`; it is never shown when the request asks for only the answer. Supply one or two sentences, at most ${MAX_BASIS_CHARACTERS} characters, naming population, grain, the source object/column that supplies the requested entity or value, and caveats. Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in \`basis\` which source object and column supply it. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. ${renderedLengthGuidance} This tool is not available for JSON-schema requests; use StructuredOutput instead.`, // altimate_change end parameters: Parameters, formatValidationError: (error: unknown) => (Schema.isSchemaError(error) ? error.message : String(error)), diff --git a/packages/opencode/test/altimate/sql-validation-e2e.test.ts b/packages/opencode/test/altimate/sql-validation-e2e.test.ts index 460fbfdaa8..cc70ef691c 100644 --- a/packages/opencode/test/altimate/sql-validation-e2e.test.ts +++ b/packages/opencode/test/altimate/sql-validation-e2e.test.ts @@ -68,7 +68,7 @@ describe("Tool name consistency in prompts", () => { expect(analyst).toBeDefined() expect(analyst!.prompt).not.toContain("sql_validate") expect(analyst!.prompt).toContain( - "keep that reasoning out of the final message, and record the chosen and rejected reading as one `answer_commit` `readings` entry. A definition or policy supplied in the request is binding: apply its wording literally even when that returns one row or looks trivial, and never reject a reading because it yields few rows.", + "keep that reasoning out of the final message, and record the chosen and rejected reading as one `answer_commit` `readings` entry. A definition or policy supplied in the request is binding for what it defines (the metric, date column, threshold or eligibility): apply its wording literally even when that returns one row or looks trivial, and never reject a reading because it yields few rows.", ) expect(analyst!.prompt).toContain( "When a rule or policy decides the answer, read its full text (not a preview), enumerate each competing clause, and evaluate every condition as satisfied, violated or unknown against the row's own fields and dates before choosing", From f5830182340a8f7f893b3a7449f10a838e7a02cd Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 20:55:38 -0700 Subject: [PATCH 12/39] fix: drop the supplied-definition-is-binding rule from the analyst prompt It pushed the analyst toward the eligibility object's owner when the metric belongs to another object. Keep only the rule against rejecting a reading for returning few rows. Co-Authored-By: Claude Sonnet 5.5 --- packages/opencode/src/altimate/prompts/analyst.txt | 2 +- packages/opencode/src/altimate/tools/answer-commit.ts | 2 +- packages/opencode/test/altimate/sql-validation-e2e.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/altimate/prompts/analyst.txt b/packages/opencode/src/altimate/prompts/analyst.txt index 4f37ca040c..80ae9aaf85 100644 --- a/packages/opencode/src/altimate/prompts/analyst.txt +++ b/packages/opencode/src/altimate/prompts/analyst.txt @@ -1,6 +1,6 @@ You are altimate-code: a read-only analyst. Do not modify source data or files. When available, `sql_materialize` may write session-local `scratch` tables. -Inspect connections, schemas and actual rows before joining or parsing; key formats may differ across databases. Establish population, grain, denominator and assumptions. Every clause of the question must bite: a filter that removes nothing usually means the wrong column. When a phrase admits two readings (which population, denominator, transform or owner column), compute both, then choose by the question's own definitions and the data's shape; keep that reasoning out of the final message, and record the chosen and rejected reading as one `answer_commit` `readings` entry. A definition or policy supplied in the request is binding for what it defines (the metric, date column, threshold or eligibility): apply its wording literally even when that returns one row or looks trivial, and never reject a reading because it yields few rows. It does not move the requested entity: take the entity from the object the question's metric is about, not from an object that only supplies eligibility. When a sentence lists a related item alongside the entity as something to identify ("the repository and the most copied file"), commit both; an item that only selects or filters the answer is not asked. When a rule or policy decides the answer, read its full text (not a preview), enumerate each competing clause, and evaluate every condition as satisfied, violated or unknown against the row's own fields and dates before choosing, testing each rule against every line with SQL (never by eye); when a rule no row can violate under its literal reading, check the text for a unit, scope or date-column reading that gives it force before dismissing it, while supplied definitions stay authoritative. Use `sql_check` for keys, fan-out, grain, nulls or extraction coverage when they affect the answer; trivial queries need no ritual. `sql_analyze` is optional. +Inspect connections, schemas and actual rows before joining or parsing; key formats may differ across databases. Establish population, grain, denominator and assumptions. Every clause of the question must bite: a filter that removes nothing usually means the wrong column. When a phrase admits two readings (which population, denominator, transform or owner column), compute both, then choose by the question's own definitions and the data's shape; keep that reasoning out of the final message, and record the chosen and rejected reading as one `answer_commit` `readings` entry. Never reject a reading only because it yields few rows; reject it on the question's own wording or the data's shape. When a sentence lists a related item alongside the entity as something to identify ("the repository and the most copied file"), commit both; an item that only selects or filters the answer is not asked. When a rule or policy decides the answer, read its full text (not a preview), enumerate each competing clause, and evaluate every condition as satisfied, violated or unknown against the row's own fields and dates before choosing, testing each rule against every line with SQL (never by eye); when a rule no row can violate under its literal reading, check the text for a unit, scope or date-column reading that gives it force before dismissing it, while supplied definitions stay authoritative. Use `sql_check` for keys, fan-out, grain, nulls or extraction coverage when they affect the answer; trivial queries need no ritual. `sql_analyze` is optional. Derive large intermediate sets in-engine. For a cross-engine join, move the smaller side: a few hundred keys go into an IN list or VALUES against the big table; materialize into `scratch` only the side that is small after filtering, never a multi-million-row table when a filter or aggregate could shrink it first. Preserve requested weighting: group means weighted by contributing counts can be exact; unweighted averages change the weighting. For many similar tables, prefer catalog-driven SQL (information_schema, DuckDB query_table/UNION ALL BY NAME) after confirming dialect support. diff --git a/packages/opencode/src/altimate/tools/answer-commit.ts b/packages/opencode/src/altimate/tools/answer-commit.ts index 347890cd99..542b34a435 100644 --- a/packages/opencode/src/altimate/tools/answer-commit.ts +++ b/packages/opencode/src/altimate/tools/answer-commit.ts @@ -349,7 +349,7 @@ export const AnswerCommitTool = Tool.define( "answer_commit", Effect.succeed({ // altimate_change start - expose format selection to the model before the terminal tool call - description: `Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Supply one field per item the request asks for, in the order asked: a request for 'the repository and the most copied file' needs two fields (one named for the repository, one for the file); never collapse a compound request into one field, and never add fields the request did not ask for. Always set \`asked\` to the requested items, in order, as short labels from the request, and name each field or rows column after its item; a commit that leaves an asked item without its own non-empty value is rejected. When the wording admits more than one reading, add one \`readings\` entry naming the chosen reading and the rejected one; a rule or definition supplied in the request outranks plausibility for what it defines (metric, date column, threshold, eligibility) but does not change which object supplies the requested entity, so never reject a reading just because it returns few rows. When a sentence lists a related item alongside the entity as something to identify (for example 'the repository and the most copied file'), treat it as asked and commit both; an item that only selects or filters the answer ('the customer with the largest invoice') is not asked. Committed numbers should appear in query output (rounding is fine); compute derived numbers in SQL.Every named item (an entity AND a related item or count) must be its own field, because basis, notes and prose are not shown when the request asks for only the answer; an element found but kept only there is lost. Numbers use JavaScript precision; integers must be safe. Pass exact decimal values, trailing zeros, or currency strings as strings. For fields.precision and each rows.precision value, use an integer from 0 to 10 (inclusive). Set rows.precision by column name to display numeric cells with fixed decimal places, including integers; strings are never changed. Without explicit precision, each column uses the maximum decimal scale of its numeric cells. When the request names a tabular format, set rows.format accordingly: markdown (default), csv, tsv, or lines (one row per line with cells separated by a single space, without headers, for single-column or two-column plain lists). CSV/TSV include headers and quote cells containing the delimiter, quotes, or newlines, doubling embedded quotes; null cells are empty. Always supply \`basis\`; it is never shown when the request asks for only the answer. Supply one or two sentences, at most ${MAX_BASIS_CHARACTERS} characters, naming population, grain, the source object/column that supplies the requested entity or value, and caveats. Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in \`basis\` which source object and column supply it. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. ${renderedLengthGuidance} This tool is not available for JSON-schema requests; use StructuredOutput instead.`, + description: `Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Supply one field per item the request asks for, in the order asked: a request for 'the repository and the most copied file' needs two fields (one named for the repository, one for the file); never collapse a compound request into one field, and never add fields the request did not ask for. Always set \`asked\` to the requested items, in order, as short labels from the request, and name each field or rows column after its item; a commit that leaves an asked item without its own non-empty value is rejected. When the wording admits more than one reading, add one \`readings\` entry naming the chosen reading and the rejected one; never reject a reading just because it returns few rows. When a sentence lists a related item alongside the entity as something to identify (for example 'the repository and the most copied file'), treat it as asked and commit both; an item that only selects or filters the answer ('the customer with the largest invoice') is not asked. Committed numbers should appear in query output (rounding is fine); compute derived numbers in SQL.Every named item (an entity AND a related item or count) must be its own field, because basis, notes and prose are not shown when the request asks for only the answer; an element found but kept only there is lost. Numbers use JavaScript precision; integers must be safe. Pass exact decimal values, trailing zeros, or currency strings as strings. For fields.precision and each rows.precision value, use an integer from 0 to 10 (inclusive). Set rows.precision by column name to display numeric cells with fixed decimal places, including integers; strings are never changed. Without explicit precision, each column uses the maximum decimal scale of its numeric cells. When the request names a tabular format, set rows.format accordingly: markdown (default), csv, tsv, or lines (one row per line with cells separated by a single space, without headers, for single-column or two-column plain lists). CSV/TSV include headers and quote cells containing the delimiter, quotes, or newlines, doubling embedded quotes; null cells are empty. Always supply \`basis\`; it is never shown when the request asks for only the answer. Supply one or two sentences, at most ${MAX_BASIS_CHARACTERS} characters, naming population, grain, the source object/column that supplies the requested entity or value, and caveats. Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in \`basis\` which source object and column supply it. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. ${renderedLengthGuidance} This tool is not available for JSON-schema requests; use StructuredOutput instead.`, // altimate_change end parameters: Parameters, formatValidationError: (error: unknown) => (Schema.isSchemaError(error) ? error.message : String(error)), diff --git a/packages/opencode/test/altimate/sql-validation-e2e.test.ts b/packages/opencode/test/altimate/sql-validation-e2e.test.ts index cc70ef691c..5324ce79a9 100644 --- a/packages/opencode/test/altimate/sql-validation-e2e.test.ts +++ b/packages/opencode/test/altimate/sql-validation-e2e.test.ts @@ -68,7 +68,7 @@ describe("Tool name consistency in prompts", () => { expect(analyst).toBeDefined() expect(analyst!.prompt).not.toContain("sql_validate") expect(analyst!.prompt).toContain( - "keep that reasoning out of the final message, and record the chosen and rejected reading as one `answer_commit` `readings` entry. A definition or policy supplied in the request is binding for what it defines (the metric, date column, threshold or eligibility): apply its wording literally even when that returns one row or looks trivial, and never reject a reading because it yields few rows.", + "keep that reasoning out of the final message, and record the chosen and rejected reading as one `answer_commit` `readings` entry. Never reject a reading only because it yields few rows; reject it on the question's own wording or the data's shape.", ) expect(analyst!.prompt).toContain( "When a rule or policy decides the answer, read its full text (not a preview), enumerate each competing clause, and evaluate every condition as satisfied, violated or unknown against the row's own fields and dates before choosing", From 3707cd1e063524e6db498e2422ce01d79726930e Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 21:16:35 -0700 Subject: [PATCH 13/39] feat(mcp): add a resource_link for spilled sql_execute results The text result keeps the preview, row count and saved-file path; a resource_link to the file is added beside it. Co-Authored-By: Claude Sonnet 5.5 --- .../src/altimate/mcp-server/server.ts | 26 +++++++++++++++++-- .../test/altimate/mcp-server/server.test.ts | 22 ++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/altimate/mcp-server/server.ts b/packages/opencode/src/altimate/mcp-server/server.ts index 146fb42a4f..b1992e8e19 100644 --- a/packages/opencode/src/altimate/mcp-server/server.ts +++ b/packages/opencode/src/altimate/mcp-server/server.ts @@ -1,9 +1,17 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js" import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" -import { CallToolRequestSchema, ListToolsRequestSchema, type Tool as McpTool } from "@modelcontextprotocol/sdk/types.js" +import { + CallToolRequestSchema, + GetPromptRequestSchema, + ListPromptsRequestSchema, + ListToolsRequestSchema, + type Tool as McpTool, +} from "@modelcontextprotocol/sdk/types.js" import * as Registry from "../native/connections/registry" import { createAdapter, descriptions } from "./adapter" import { readOnlyCaveat } from "../native/connections/read-only" +import path from "node:path" +import { pathToFileURL } from "node:url" import { InstallationVersion } from "@opencode-ai/core/installation/version" export const SERVER_INSTRUCTIONS = [ @@ -17,6 +25,20 @@ export const SERVER_INSTRUCTIONS = [ "7. Finish with answer_commit: `asked` lists the requested items, one field per item; reply with exactly the FINAL ANSWER text it returns.", ].join("\n") +/** Spilled results: a resource_link next to the text, which keeps the preview, row count and path. */ +function resultLinks(metadata: Record) { + const file = [metadata.result_path, metadata.outputPath].find((value) => typeof value === "string" && value) + if (typeof file !== "string") return [] + return [ + { + type: "resource_link" as const, + uri: pathToFileURL(file).href, + name: path.basename(file), + mimeType: file.endsWith(".jsonl") ? "application/x-ndjson" : "text/plain", + }, + ] +} + export interface ServeOptions { connections?: string allowWrite?: boolean @@ -46,7 +68,7 @@ export async function createServer(options: ServeOptions = {}) { try { const result = await adapter.execute(request.params.name, request.params.arguments ?? {}, extra.signal) const isError = result.status === "error" || (result.metadata.success !== true && Boolean(result.metadata.error)) - return { content: [{ type: "text" as const, text: result.output }], isError } + return { content: [{ type: "text" as const, text: result.output }, ...resultLinks(result.metadata)], isError } } catch (error) { return { content: [{ type: "text" as const, text: error instanceof Error ? error.message : String(error) }], diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index f243692f4b..a233f0e971 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -499,3 +499,25 @@ mcpTest("answer_commit accepts a number that appeared in query output", async () expect((await call("answer_commit", commit([{ name: "n", value: 4321 }], ["n"]))).isError).toBeFalsy() }) // altimate_change end + +// altimate_change start — spilled sql_execute results carry a resource_link beside the text fallback +mcpTest("sql_execute spill returns text with the path plus a resource_link to the saved rows", async () => { + const { call } = await fixture() + const result = await call("sql_execute", { + warehouse: "source", + query: "SELECT id, name FROM items", + full_text_columns: ["name"], + }) + const content = result.content as { type: string; text?: string; uri?: string }[] + const link = content.find((item) => item.type === "resource_link") + expect(link?.uri?.startsWith("file://")).toBe(true) + expect(content[0]!.type).toBe("text") + expect(content[0]!.text).toContain("saved to") +}) + +mcpTest("sql_execute without a spill returns text only", async () => { + const { call } = await fixture() + const result = await call("sql_execute", { warehouse: "source", query: "SELECT id FROM items" }) + expect((result.content as { type: string }[]).map((item) => item.type)).toEqual(["text"]) +}) +// altimate_change end From 9680417108e5f10b3b2202be95ff075582479469 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 21:17:00 -0700 Subject: [PATCH 14/39] feat(mcp): give answer_commit an outputSchema and structuredContent The text result remains the fallback. Co-Authored-By: Claude Sonnet 5.5 --- .../src/altimate/mcp-server/server.ts | 18 +++++++++++++++++- .../test/altimate/mcp-server/server.test.ts | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/mcp-server/server.ts b/packages/opencode/src/altimate/mcp-server/server.ts index b1992e8e19..cf06c9fe9d 100644 --- a/packages/opencode/src/altimate/mcp-server/server.ts +++ b/packages/opencode/src/altimate/mcp-server/server.ts @@ -39,6 +39,16 @@ function resultLinks(metadata: Record) { ] } +const FINAL_ANSWER_PREFIX = "FINAL ANSWER: " +const answerOutputSchema = { + type: "object" as const, + properties: { answer: { type: "string", description: "The committed answer text, without the FINAL ANSWER prefix" } }, + required: ["answer"], +} +function structuredAnswer(output: string) { + return { answer: output.startsWith(FINAL_ANSWER_PREFIX) ? output.slice(FINAL_ANSWER_PREFIX.length) : output } +} + export interface ServeOptions { connections?: string allowWrite?: boolean @@ -62,13 +72,19 @@ export async function createServer(options: ServeOptions = {}) { name, description: descriptions[name], inputSchema: tool.jsonSchema as McpTool["inputSchema"], + ...(name === "answer_commit" && { outputSchema: answerOutputSchema }), })), })) server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { try { const result = await adapter.execute(request.params.name, request.params.arguments ?? {}, extra.signal) const isError = result.status === "error" || (result.metadata.success !== true && Boolean(result.metadata.error)) - return { content: [{ type: "text" as const, text: result.output }, ...resultLinks(result.metadata)], isError } + return { + content: [{ type: "text" as const, text: result.output }, ...resultLinks(result.metadata)], + // The text above stays the fallback for clients that ignore structured output. + ...(request.params.name === "answer_commit" && !isError && { structuredContent: structuredAnswer(result.output) }), + isError, + } } catch (error) { return { content: [{ type: "text" as const, text: error instanceof Error ? error.message : String(error) }], diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index a233f0e971..f56cde8b68 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -521,3 +521,22 @@ mcpTest("sql_execute without a spill returns text only", async () => { expect((result.content as { type: string }[]).map((item) => item.type)).toEqual(["text"]) }) // altimate_change end + +// altimate_change start — answer_commit advertises an output schema and returns structuredContent beside the text +mcpTest("answer_commit returns structuredContent and keeps the FINAL ANSWER text", async () => { + const { client, call } = await fixture() + const tool = (await client.listTools()).tools.find((item) => item.name === "answer_commit") + expect(tool?.outputSchema?.required).toEqual(["answer"]) + const result = await call("answer_commit", commit([{ name: "", value: "apple" }], ["item"])) + const structured = result.structuredContent as { answer: string } + expect(structured.answer).toContain("apple") + expect((result.content as { text: string }[])[0]!.text).toBe(`FINAL ANSWER: ${structured.answer}`) +}) + +mcpTest("a rejected answer_commit has no structuredContent", async () => { + const { call } = await fixture() + const rejected = await call("answer_commit", commit([{ name: "item", value: "apple" }], ["item", "count"])) + expect(rejected.isError).toBe(true) + expect(rejected.structuredContent).toBeUndefined() +}) +// altimate_change end From fd7879063c90b90fd63275474102b9fd3bb7f108 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 21:18:24 -0700 Subject: [PATCH 15/39] feat(mcp): expose an `analyze` prompt; ask which reading was meant via elicitation - `analyze` prompt carries the analyst method (slash command in interactive clients). - `answer_commit` asks once per session which reading was meant, only when the commit records two readings and the client supports elicitation. Decline, cancel or no elicitation support leaves the commit accepted (headless clients cancel). Co-Authored-By: Claude Sonnet 5.5 --- .../src/altimate/mcp-server/server.ts | 42 ++++++++++++++- .../test/altimate/mcp-server/server.test.ts | 53 ++++++++++++++++++- 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/altimate/mcp-server/server.ts b/packages/opencode/src/altimate/mcp-server/server.ts index cf06c9fe9d..fb9868a7de 100644 --- a/packages/opencode/src/altimate/mcp-server/server.ts +++ b/packages/opencode/src/altimate/mcp-server/server.ts @@ -49,6 +49,13 @@ function structuredAnswer(output: string) { return { answer: output.startsWith(FINAL_ANSWER_PREFIX) ? output.slice(FINAL_ANSWER_PREFIX.length) : output } } +// Surfaces as a slash command in interactive clients. +const analyzePrompt = { + name: "analyze", + description: "Answer a data question with the analyst method: inspect, compute in the database, then answer_commit.", + arguments: [{ name: "question", description: "The data question to answer", required: false }], +} + export interface ServeOptions { connections?: string allowWrite?: boolean @@ -64,7 +71,7 @@ export async function createServer(options: ServeOptions = {}) { } const adapter = await createAdapter(options) const server = new Server({ name: "altimate-code", version: InstallationVersion }, { - capabilities: { tools: {} }, + capabilities: { tools: {}, prompts: {} }, instructions: SERVER_INSTRUCTIONS, }) server.setRequestHandler(ListToolsRequestSchema, async () => ({ @@ -75,9 +82,42 @@ export async function createServer(options: ServeOptions = {}) { ...(name === "answer_commit" && { outputSchema: answerOutputSchema }), })), })) + // Ask the user once per session when the model weighed two readings and the client can ask; any + // failure, decline or cancel (headless clients cancel) leaves the commit as accepted. + let elicited = false + const clarifyReading = async (args: Record | undefined, result: { status?: string }) => { + const readings = Array.isArray(args?.readings) ? (args.readings as { reading?: unknown }[]) : [] + const options = readings.map((item) => item.reading).filter((item): item is string => typeof item === "string" && !!item) + if (elicited || result.status === "error" || options.length < 2 || !server.getClientCapabilities()?.elicitation) return undefined + elicited = true + try { + const answer = await server.elicitInput({ + message: "The question can be read two ways. Which did you mean?", + requestedSchema: { + type: "object", + properties: { reading: { type: "string", enum: options, description: "Intended reading" } }, + required: ["reading"], + }, + }) + const chosen = answer.action === "accept" ? answer.content?.reading : undefined + if (typeof chosen !== "string" || chosen === options[0]) return undefined + return `The user chose this reading: ${JSON.stringify(chosen)}. Recompute under it and call answer_commit again.` + } catch { + return undefined + } + } + server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [analyzePrompt] })) + server.setRequestHandler(GetPromptRequestSchema, async (request) => { + if (request.params.name !== analyzePrompt.name) throw new Error(`Unknown prompt: ${request.params.name}`) + const question = request.params.arguments?.question?.trim() + const text = [SERVER_INSTRUCTIONS, ...(question ? ["", `Question: ${question}`] : [])].join("\n") + return { description: analyzePrompt.description, messages: [{ role: "user" as const, content: { type: "text" as const, text } }] } + }) server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { try { const result = await adapter.execute(request.params.name, request.params.arguments ?? {}, extra.signal) + const clarified = request.params.name === "answer_commit" ? await clarifyReading(request.params.arguments, result) : undefined + if (clarified) return { content: [{ type: "text" as const, text: clarified }], isError: true } const isError = result.status === "error" || (result.metadata.success !== true && Boolean(result.metadata.error)) return { content: [{ type: "text" as const, text: result.output }, ...resultLinks(result.metadata)], diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index f56cde8b68..047d722f0c 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -5,6 +5,7 @@ import { mkdtemp, rm, stat, readFile } from "node:fs/promises" import os from "node:os" import path from "node:path" import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" import { createServer, SERVER_INSTRUCTIONS } from "../../../src/altimate/mcp-server/server" import * as Registry from "../../../src/altimate/native/connections/registry" @@ -25,7 +26,7 @@ afterEach(async () => { Registry.reset() }) -async function fixture(allowWrite = false) { +async function fixture(allowWrite = false, capabilities: Record = {}) { const dir = await mkdtemp(path.join(os.tmpdir(), "mcp-tools-test-")) cleanups.push(() => rm(dir, { recursive: true, force: true })) const dbPath = path.join(dir, "source.sqlite") @@ -36,7 +37,7 @@ async function fixture(allowWrite = false) { const config = path.join(dir, "connections.json") await Bun.write(config, JSON.stringify({ source: { type: "sqlite", path: "source.sqlite" } })) const server = await createServer({ allowWrite, connections: config }) - const client = new Client({ name: "test", version: "1" }) + const client = new Client({ name: "test", version: "1" }, { capabilities }) const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() await server.connect(serverTransport) await client.connect(clientTransport) @@ -540,3 +541,51 @@ mcpTest("a rejected answer_commit has no structuredContent", async () => { expect(rejected.structuredContent).toBeUndefined() }) // altimate_change end + +// altimate_change start — the analyze prompt carries the analyst method for interactive clients +mcpTest("analyze prompt returns the analyst method and the question", async () => { + const { client } = await fixture() + expect((await client.listPrompts()).prompts.map((item) => item.name)).toEqual(["analyze"]) + const prompt = await client.getPrompt({ name: "analyze", arguments: { question: "How many items?" } }) + const body = (prompt.messages[0]!.content as { text: string }).text + expect(body).toContain(SERVER_INSTRUCTIONS) + expect(body).toContain("Question: How many items?") + await expect(client.getPrompt({ name: "nope" })).rejects.toThrow() +}) +// altimate_change end + +// altimate_change start — answer_commit asks which reading was meant only when two exist and the client can ask +const twoReadings = { + ...commit([{ name: "", value: "apple" }], ["item"]), + readings: [ + { clause: "c", reading: "by name", result_summary: "apple", decided_by: "x" }, + { clause: "c", reading: "by id", result_summary: "pear", decided_by: "y" }, + ], +} + +mcpTest("answer_commit elicits a reading and asks for a recompute when another is chosen", async () => { + const { client, call } = await fixture(false, { elicitation: {} }) + let asked = 0 + client.setRequestHandler(ElicitRequestSchema, async () => { + asked++ + return { action: "accept", content: { reading: "by id" } } + }) + const first = await call("answer_commit", twoReadings) + expect(first.isError).toBe(true) + expect(text(first)).toContain("by id") + // Asked once per session: the follow-up commit is accepted. + expect((await call("answer_commit", twoReadings)).isError).toBeFalsy() + expect(asked).toBe(1) +}) + +mcpTest("answer_commit degrades silently when the client cancels or lacks elicitation", async () => { + const cancelled = await fixture(false, { elicitation: {} }) + cancelled.client.setRequestHandler(ElicitRequestSchema, async () => ({ action: "cancel" })) + expect((await cancelled.call("answer_commit", twoReadings)).isError).toBeFalsy() +}) + +mcpTest("answer_commit without elicitation support or with one reading never asks", async () => { + const plain = await fixture() + expect((await plain.call("answer_commit", twoReadings)).isError).toBeFalsy() +}) +// altimate_change end From 2c2dc014480159a3cbb258a89733fb0d42773c58 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 21:22:28 -0700 Subject: [PATCH 16/39] fix(mcp): bound the `answer_commit` reading question so a silent client cannot stall the commit `elicitInput` had no deadline and ignored the tool call's abort signal, so a client that advertises elicitation but never answers (common headless) held `answer_commit` until the client's tool-call timeout and the accepted answer never arrived. The question now times out after 20s (configurable via `elicitTimeoutMs`) and follows the tool call's signal; any timeout or abort keeps the commit accepted. Adds a regression test with a client that never responds. Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/mcp-server/server.ts | 22 +++++++++++++------ .../test/altimate/mcp-server/server.test.ts | 13 +++++++++-- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/altimate/mcp-server/server.ts b/packages/opencode/src/altimate/mcp-server/server.ts index fb9868a7de..c86bb7aa58 100644 --- a/packages/opencode/src/altimate/mcp-server/server.ts +++ b/packages/opencode/src/altimate/mcp-server/server.ts @@ -59,8 +59,12 @@ const analyzePrompt = { export interface ServeOptions { connections?: string allowWrite?: boolean + // Bounds the reading question so an unanswered prompt cannot outlast the client's tool-call timeout. + elicitTimeoutMs?: number } +const ELICIT_TIMEOUT_MS = 20_000 + export async function createServer(options: ServeOptions = {}) { if (options.connections) await Registry.loadFile(options.connections) if (!options.allowWrite) { @@ -85,22 +89,26 @@ export async function createServer(options: ServeOptions = {}) { // Ask the user once per session when the model weighed two readings and the client can ask; any // failure, decline or cancel (headless clients cancel) leaves the commit as accepted. let elicited = false - const clarifyReading = async (args: Record | undefined, result: { status?: string }) => { + const clarifyReading = async ( + args: Record | undefined, + result: { status?: string }, + signal: AbortSignal, + ) => { const readings = Array.isArray(args?.readings) ? (args.readings as { reading?: unknown }[]) : [] - const options = readings.map((item) => item.reading).filter((item): item is string => typeof item === "string" && !!item) - if (elicited || result.status === "error" || options.length < 2 || !server.getClientCapabilities()?.elicitation) return undefined + const choices = readings.map((item) => item.reading).filter((item): item is string => typeof item === "string" && !!item) + if (elicited || result.status === "error" || choices.length < 2 || !server.getClientCapabilities()?.elicitation) return undefined elicited = true try { const answer = await server.elicitInput({ message: "The question can be read two ways. Which did you mean?", requestedSchema: { type: "object", - properties: { reading: { type: "string", enum: options, description: "Intended reading" } }, + properties: { reading: { type: "string", enum: choices, description: "Intended reading" } }, required: ["reading"], }, - }) + }, { signal, timeout: options.elicitTimeoutMs ?? ELICIT_TIMEOUT_MS }) const chosen = answer.action === "accept" ? answer.content?.reading : undefined - if (typeof chosen !== "string" || chosen === options[0]) return undefined + if (typeof chosen !== "string" || chosen === choices[0]) return undefined return `The user chose this reading: ${JSON.stringify(chosen)}. Recompute under it and call answer_commit again.` } catch { return undefined @@ -116,7 +124,7 @@ export async function createServer(options: ServeOptions = {}) { server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { try { const result = await adapter.execute(request.params.name, request.params.arguments ?? {}, extra.signal) - const clarified = request.params.name === "answer_commit" ? await clarifyReading(request.params.arguments, result) : undefined + const clarified = request.params.name === "answer_commit" ? await clarifyReading(request.params.arguments, result, extra.signal) : undefined if (clarified) return { content: [{ type: "text" as const, text: clarified }], isError: true } const isError = result.status === "error" || (result.metadata.success !== true && Boolean(result.metadata.error)) return { diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index 047d722f0c..44489050f6 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -26,7 +26,7 @@ afterEach(async () => { Registry.reset() }) -async function fixture(allowWrite = false, capabilities: Record = {}) { +async function fixture(allowWrite = false, capabilities: Record = {}, elicitTimeoutMs?: number) { const dir = await mkdtemp(path.join(os.tmpdir(), "mcp-tools-test-")) cleanups.push(() => rm(dir, { recursive: true, force: true })) const dbPath = path.join(dir, "source.sqlite") @@ -36,7 +36,7 @@ async function fixture(allowWrite = false, capabilities: Record db.close() const config = path.join(dir, "connections.json") await Bun.write(config, JSON.stringify({ source: { type: "sqlite", path: "source.sqlite" } })) - const server = await createServer({ allowWrite, connections: config }) + const server = await createServer({ allowWrite, connections: config, elicitTimeoutMs }) const client = new Client({ name: "test", version: "1" }, { capabilities }) const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() await server.connect(serverTransport) @@ -584,6 +584,15 @@ mcpTest("answer_commit degrades silently when the client cancels or lacks elicit expect((await cancelled.call("answer_commit", twoReadings)).isError).toBeFalsy() }) +mcpTest("answer_commit accepts the commit when the client never answers the reading question", async () => { + const silent = await fixture(false, { elicitation: {} }, 200) + silent.client.setRequestHandler(ElicitRequestSchema, () => new Promise(() => {})) + const started = Date.now() + const result = await silent.call("answer_commit", twoReadings) + expect(result.isError).toBeFalsy() + expect(Date.now() - started).toBeLessThan(5_000) +}) + mcpTest("answer_commit without elicitation support or with one reading never asks", async () => { const plain = await fixture() expect((await plain.call("answer_commit", twoReadings)).isError).toBeFalsy() From 5e0edda74adf0be124a002fe8a1d1af00c46468a Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 17 Oct 2026 22:54:07 -0700 Subject: [PATCH 17/39] fix(mcp): return `answer_commit` as `FINAL ANSWER` text only, without structured output Claude Code shows the model `structuredContent` in place of the text content, so after an accepted commit the model saw `{"answer":"..."}` instead of `FINAL ANSWER: ...` and went on to restate the answer in prose, which can drop requested items. Drops the `outputSchema` and `structuredContent` added for `answer_commit`; the text result is unchanged. The test now asserts that neither is returned. Co-Authored-By: Claude Opus 5.5 --- .../opencode/src/altimate/mcp-server/server.ts | 13 ------------- .../test/altimate/mcp-server/server.test.ts | 18 +++++------------- 2 files changed, 5 insertions(+), 26 deletions(-) diff --git a/packages/opencode/src/altimate/mcp-server/server.ts b/packages/opencode/src/altimate/mcp-server/server.ts index c86bb7aa58..79d7e9fbd6 100644 --- a/packages/opencode/src/altimate/mcp-server/server.ts +++ b/packages/opencode/src/altimate/mcp-server/server.ts @@ -39,16 +39,6 @@ function resultLinks(metadata: Record) { ] } -const FINAL_ANSWER_PREFIX = "FINAL ANSWER: " -const answerOutputSchema = { - type: "object" as const, - properties: { answer: { type: "string", description: "The committed answer text, without the FINAL ANSWER prefix" } }, - required: ["answer"], -} -function structuredAnswer(output: string) { - return { answer: output.startsWith(FINAL_ANSWER_PREFIX) ? output.slice(FINAL_ANSWER_PREFIX.length) : output } -} - // Surfaces as a slash command in interactive clients. const analyzePrompt = { name: "analyze", @@ -83,7 +73,6 @@ export async function createServer(options: ServeOptions = {}) { name, description: descriptions[name], inputSchema: tool.jsonSchema as McpTool["inputSchema"], - ...(name === "answer_commit" && { outputSchema: answerOutputSchema }), })), })) // Ask the user once per session when the model weighed two readings and the client can ask; any @@ -129,8 +118,6 @@ export async function createServer(options: ServeOptions = {}) { const isError = result.status === "error" || (result.metadata.success !== true && Boolean(result.metadata.error)) return { content: [{ type: "text" as const, text: result.output }, ...resultLinks(result.metadata)], - // The text above stays the fallback for clients that ignore structured output. - ...(request.params.name === "answer_commit" && !isError && { structuredContent: structuredAnswer(result.output) }), isError, } } catch (error) { diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index 44489050f6..b07bdf2215 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -523,22 +523,14 @@ mcpTest("sql_execute without a spill returns text only", async () => { }) // altimate_change end -// altimate_change start — answer_commit advertises an output schema and returns structuredContent beside the text -mcpTest("answer_commit returns structuredContent and keeps the FINAL ANSWER text", async () => { +// altimate_change start — answer_commit returns only the FINAL ANSWER text: Claude Code shows structuredContent in place of the text +mcpTest("answer_commit returns the FINAL ANSWER text without structured output", async () => { const { client, call } = await fixture() const tool = (await client.listTools()).tools.find((item) => item.name === "answer_commit") - expect(tool?.outputSchema?.required).toEqual(["answer"]) + expect(tool?.outputSchema).toBeUndefined() const result = await call("answer_commit", commit([{ name: "", value: "apple" }], ["item"])) - const structured = result.structuredContent as { answer: string } - expect(structured.answer).toContain("apple") - expect((result.content as { text: string }[])[0]!.text).toBe(`FINAL ANSWER: ${structured.answer}`) -}) - -mcpTest("a rejected answer_commit has no structuredContent", async () => { - const { call } = await fixture() - const rejected = await call("answer_commit", commit([{ name: "item", value: "apple" }], ["item", "count"])) - expect(rejected.isError).toBe(true) - expect(rejected.structuredContent).toBeUndefined() + expect(result.structuredContent).toBeUndefined() + expect((result.content as { text: string }[])[0]!.text).toStartWith("FINAL ANSWER: ") }) // altimate_change end From 67f0f8254e573df621ad6f838eeaf7b6d53157cf Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 18 Oct 2026 03:30:13 -0700 Subject: [PATCH 18/39] test(mcp): drive the stdio server with raw JSON-RPC so SDK client mocks cannot leak in CI runs every test file in one Bun process. `test/mcp/*.test.ts` replace `@modelcontextprotocol/sdk/client/index.js` with a mock that is never restored, so `stdio.test.ts` got a no-op client whose `listTools()` returns no tools. The test now spawns the server with `Bun.spawn` and speaks newline-delimited JSON-RPC itself; cases and assertions are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../test/altimate/mcp-server/stdio.test.ts | 69 +++++++++++++++---- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/packages/opencode/test/altimate/mcp-server/stdio.test.ts b/packages/opencode/test/altimate/mcp-server/stdio.test.ts index 61eeb11abc..8bda9b75e2 100644 --- a/packages/opencode/test/altimate/mcp-server/stdio.test.ts +++ b/packages/opencode/test/altimate/mcp-server/stdio.test.ts @@ -1,12 +1,49 @@ // altimate_change start — standalone MCP policy and middleware regressions import { expect, test } from "bun:test" -import { Client } from "@modelcontextprotocol/sdk/client/index.js" -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { mkdtemp, rm } from "node:fs/promises" import path from "node:path" import os from "node:os" import { Database } from "bun:sqlite" +// Minimal newline-delimited JSON-RPC client over the child's stdio. The full suite shares one Bun process and +// test/mcp/* mock the MCP SDK client modules (never restored), so using the SDK here would talk to those mocks. +function rpcClient(child: ReturnType) { + const pending = new Map void>() + let id = 0 + void (async () => { + let buffer = "" + for await (const chunk of child.stdout as unknown as AsyncIterable) { + buffer += Buffer.from(chunk).toString() + for (let nl = buffer.indexOf("\n"); nl >= 0; nl = buffer.indexOf("\n")) { + const line = buffer.slice(0, nl).trim() + buffer = buffer.slice(nl + 1) + if (!line) continue + const message = JSON.parse(line) + pending.get(message.id)?.(message) + } + } + })() + const write = (message: object) => (child.stdin as import("bun").FileSink).write(JSON.stringify(message) + "\n") + const request = (method: string, params: object = {}) => + new Promise((resolve, reject) => { + const n = ++id + pending.set(n, (message) => (message.error ? reject(new Error(JSON.stringify(message.error))) : resolve(message.result))) + write({ jsonrpc: "2.0", id: n, method, params }) + }) + return { + async connect() { + await request("initialize", { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "stdio-test", version: "1" } }) + write({ jsonrpc: "2.0", method: "notifications/initialized" }) + }, + listTools: () => request("tools/list"), + callTool: (params: { name: string; arguments: object }) => request("tools/call", params), + async close() { + ;(child.stdin as import("bun").FileSink).end() + await child.exited + }, + } +} + for (const allowWrite of [false, true]) { test(`CLI stdio handshake, read and write policy (allow-write=${allowWrite})`, async () => { const dir = await mkdtemp(path.join(os.tmpdir(), "mcp-stdio-test-")) @@ -21,9 +58,9 @@ for (const allowWrite of [false, true]) { guard, 'globalThis.fetch = (() => { process.stderr.write("UNEXPECTED_NETWORK_CALL\\n"); throw new Error("No network allowed") }) as typeof fetch\n', ) - const transport = new StdioClientTransport({ - command: process.execPath, - args: [ + const child = Bun.spawn( + [ + process.execPath, "run", "--conditions=browser", "--preload", @@ -35,18 +72,22 @@ for (const allowWrite of [false, true]) { connections, ...(allowWrite ? ["--allow-write"] : []), ], - cwd: path.resolve(import.meta.dir, "../../.."), - stderr: "pipe", - env: { ...process.env, ALTIMATE_TELEMETRY_DISABLED: "1" } as Record, - }) + { + cwd: path.resolve(import.meta.dir, "../../.."), + env: { ...process.env, ALTIMATE_TELEMETRY_DISABLED: "1" }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }, + ) let stderr = "" - transport.stderr?.on("data", (data) => { - stderr += data.toString() - }) - const client = new Client({ name: "stdio-test", version: "1" }) + void (async () => { + for await (const chunk of child.stderr as unknown as AsyncIterable) stderr += Buffer.from(chunk).toString() + })() + const client = rpcClient(child) let spill: string | undefined try { - await client.connect(transport) + await client.connect() expect((await client.listTools()).tools).toHaveLength(6) const write = await client.callTool({ name: "sql_execute", From 03615291d54dbbb63d1000fba7f552643b6699c2 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 18 Oct 2026 09:11:39 -0700 Subject: [PATCH 19/39] feat(mcp): keep the basis off the FINAL ANSWER line `answer_commit` over MCP now returns the basis as context above an answer-only FINAL ANSWER line (rendered as `answer_only` would), so a client that copies the FINAL ANSWER text never includes the explanation. Callers that already set `answer_only` get the answer line alone. Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/mcp-server/adapter.ts | 24 ++++++++-- .../altimate/mcp-server/final-answer.test.ts | 44 +++++++++++++++++++ .../test/altimate/mcp-server/server.test.ts | 10 +++-- 3 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/test/altimate/mcp-server/final-answer.test.ts diff --git a/packages/opencode/src/altimate/mcp-server/adapter.ts b/packages/opencode/src/altimate/mcp-server/adapter.ts index cc6a7b406a..8bc3c9225d 100644 --- a/packages/opencode/src/altimate/mcp-server/adapter.ts +++ b/packages/opencode/src/altimate/mcp-server/adapter.ts @@ -14,7 +14,7 @@ import { SchemaInspectTool } from "../tools/schema-inspect" import { SqlExecuteTool } from "../tools/sql-execute" import { SqlCheckTool } from "../tools/sql-check" import { SqlMaterializeTool } from "../tools/sql-materialize" -import { AnswerCommitTool } from "../tools/answer-commit" +import { AnswerCommitTool, renderAnswer } from "../tools/answer-commit" import * as Precedence from "../workspace/precedence" import { closeScratch } from "../native/connections/scratch" import type { SessionID, MessageID } from "@/session/schema" @@ -36,6 +36,22 @@ const MCP_ONLY_TOOLS = new Set(["answer_commit"]) /** Session-level record the native commit checks read: tool outputs and rejections of the current turn. */ type TurnMessage = Tool.Context["messages"][number] +/** + * The FINAL ANSWER line carries only the answer, rendered as answer_only would, so the + * explanation never becomes part of the answer text a client copies. The basis is kept, + * above that line, as context for the user. + */ +export function finalAnswerText(args: Record, committed: string): string { + let answer = committed + try { + answer = renderAnswer({ ...args, answer_only: true }) + } catch { + // Rendering already succeeded once inside the tool; keep its text if this variant fails. + } + const basis = typeof args.basis === "string" && args.answer_only !== true ? args.basis.trim() : "" + return basis ? `Basis: ${basis}\n\nFINAL ANSWER: ${answer}` : `FINAL ANSWER: ${answer}` +} + function userMessage(): TurnMessage { return { info: { role: "user" }, parts: [] } as unknown as TurnMessage } @@ -111,7 +127,7 @@ export async function createAdapter(options: { allowWrite?: boolean } = {}) { const shutdown = new AbortController() const active = new Set>() // Feed the native commit checks: completed tool output and rejections, with a new turn after an accepted commit. - const record = (name: string, result: Tool.ExecuteResult) => { + const record = (name: string, result: Tool.ExecuteResult, args: Record) => { if (name !== "answer_commit") { if (result.status !== "error") messages.push(toolMessage(name, { status: "completed", output: result.output })) return result @@ -121,7 +137,7 @@ export async function createAdapter(options: { allowWrite?: boolean } = {}) { return result } messages.push(userMessage()) - return { ...result, output: `FINAL ANSWER: ${result.metadata.committed_text ?? result.output}` } + return { ...result, output: finalAnswerText(args, result.metadata.committed_text ?? result.output) } } return { tools, @@ -152,7 +168,7 @@ export async function createAdapter(options: { allowWrite?: boolean } = {}) { active.add(pending) try { const result = await pending - return record(name, result) + return record(name, result, args) } catch (error) { if (name === "answer_commit") messages.push(toolMessage(name, { status: "error", error: String(error) })) throw error diff --git a/packages/opencode/test/altimate/mcp-server/final-answer.test.ts b/packages/opencode/test/altimate/mcp-server/final-answer.test.ts new file mode 100644 index 0000000000..e56c0ce4ca --- /dev/null +++ b/packages/opencode/test/altimate/mcp-server/final-answer.test.ts @@ -0,0 +1,44 @@ +// altimate_change start — the MCP FINAL ANSWER line carries only the answer +import { describe, expect, test } from "bun:test" +import { finalAnswerText } from "../../../src/altimate/mcp-server/adapter" + +const basis = "Counted distinct orders in the sales table for 2024; refunds excluded." + +describe("finalAnswerText", () => { + test("puts the basis above an answer-only FINAL ANSWER line for fields", () => { + const out = finalAnswerText( + { fields: [{ name: "orders", value: 42 }], asked: ["orders"], basis }, + `${basis}\n\n**Answer:** 42`, + ) + expect(out).toBe(`Basis: ${basis}\n\nFINAL ANSWER: 42`) + }) + + test("renders rows without the basis or answer label on the FINAL ANSWER line", () => { + const out = finalAnswerText( + { + rows: { columns: ["city", "n"], values: [["Oslo", 3], ["Lima", 2]] }, + asked: ["city", "n"], + basis, + }, + "unused", + ) + const answer = out.slice(out.indexOf("FINAL ANSWER: ")) + expect(out.startsWith(`Basis: ${basis}\n\n`)).toBe(true) + expect(answer).toContain("Oslo") + expect(answer).not.toContain("**Answer:**") + expect(answer).not.toContain("refunds excluded") + }) + + test("omits the basis block when the caller already asked for answer_only", () => { + const out = finalAnswerText( + { fields: [{ name: "orders", value: 42 }], asked: ["orders"], basis, answer_only: true }, + "42", + ) + expect(out).toBe("FINAL ANSWER: 42") + }) + + test("falls back to the committed text when the args cannot be re-rendered", () => { + expect(finalAnswerText({ basis: "" }, "committed")).toBe("FINAL ANSWER: committed") + }) +}) +// altimate_change end diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index b07bdf2215..e92a9d7cef 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -469,8 +469,12 @@ mcpTest("answer_commit returns the canonical FINAL ANSWER text and trims a long const result = await call("answer_commit", commit([{ name: "", value: "apple" }], ["item"], "x".repeat(900))) expect(result.isError).toBeFalsy() const out = (result.content as { text: string }[])[0]!.text - expect(out.startsWith("FINAL ANSWER: ")).toBe(true) - expect(out).toContain("apple") + // altimate_change start — the FINAL ANSWER line is answer-only; the trimmed basis sits above it + expect(out).toMatch(/(^|\n\n)FINAL ANSWER: /) + const answer = out.slice(out.indexOf("FINAL ANSWER: ")) + expect(answer).toContain("apple") + expect(answer).not.toContain("xxx") + // altimate_change end }) mcpTest("answer_commit rejects a missing asked item at most twice, then accepts", async () => { @@ -530,7 +534,7 @@ mcpTest("answer_commit returns the FINAL ANSWER text without structured output", expect(tool?.outputSchema).toBeUndefined() const result = await call("answer_commit", commit([{ name: "", value: "apple" }], ["item"])) expect(result.structuredContent).toBeUndefined() - expect((result.content as { text: string }[])[0]!.text).toStartWith("FINAL ANSWER: ") + expect((result.content as { text: string }[])[0]!.text).toMatch(/(^|\n\n)FINAL ANSWER: /) }) // altimate_change end From 259401cf39187e623596e8257f72aa55c238209a Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 18 Oct 2026 10:29:53 -0700 Subject: [PATCH 20/39] Revert "feat(mcp): keep the basis off the FINAL ANSWER line" This reverts commit 03615291d54dbbb63d1000fba7f552643b6699c2. --- .../src/altimate/mcp-server/adapter.ts | 24 ++-------- .../altimate/mcp-server/final-answer.test.ts | 44 ------------------- .../test/altimate/mcp-server/server.test.ts | 10 ++--- 3 files changed, 7 insertions(+), 71 deletions(-) delete mode 100644 packages/opencode/test/altimate/mcp-server/final-answer.test.ts diff --git a/packages/opencode/src/altimate/mcp-server/adapter.ts b/packages/opencode/src/altimate/mcp-server/adapter.ts index 8bc3c9225d..cc6a7b406a 100644 --- a/packages/opencode/src/altimate/mcp-server/adapter.ts +++ b/packages/opencode/src/altimate/mcp-server/adapter.ts @@ -14,7 +14,7 @@ import { SchemaInspectTool } from "../tools/schema-inspect" import { SqlExecuteTool } from "../tools/sql-execute" import { SqlCheckTool } from "../tools/sql-check" import { SqlMaterializeTool } from "../tools/sql-materialize" -import { AnswerCommitTool, renderAnswer } from "../tools/answer-commit" +import { AnswerCommitTool } from "../tools/answer-commit" import * as Precedence from "../workspace/precedence" import { closeScratch } from "../native/connections/scratch" import type { SessionID, MessageID } from "@/session/schema" @@ -36,22 +36,6 @@ const MCP_ONLY_TOOLS = new Set(["answer_commit"]) /** Session-level record the native commit checks read: tool outputs and rejections of the current turn. */ type TurnMessage = Tool.Context["messages"][number] -/** - * The FINAL ANSWER line carries only the answer, rendered as answer_only would, so the - * explanation never becomes part of the answer text a client copies. The basis is kept, - * above that line, as context for the user. - */ -export function finalAnswerText(args: Record, committed: string): string { - let answer = committed - try { - answer = renderAnswer({ ...args, answer_only: true }) - } catch { - // Rendering already succeeded once inside the tool; keep its text if this variant fails. - } - const basis = typeof args.basis === "string" && args.answer_only !== true ? args.basis.trim() : "" - return basis ? `Basis: ${basis}\n\nFINAL ANSWER: ${answer}` : `FINAL ANSWER: ${answer}` -} - function userMessage(): TurnMessage { return { info: { role: "user" }, parts: [] } as unknown as TurnMessage } @@ -127,7 +111,7 @@ export async function createAdapter(options: { allowWrite?: boolean } = {}) { const shutdown = new AbortController() const active = new Set>() // Feed the native commit checks: completed tool output and rejections, with a new turn after an accepted commit. - const record = (name: string, result: Tool.ExecuteResult, args: Record) => { + const record = (name: string, result: Tool.ExecuteResult) => { if (name !== "answer_commit") { if (result.status !== "error") messages.push(toolMessage(name, { status: "completed", output: result.output })) return result @@ -137,7 +121,7 @@ export async function createAdapter(options: { allowWrite?: boolean } = {}) { return result } messages.push(userMessage()) - return { ...result, output: finalAnswerText(args, result.metadata.committed_text ?? result.output) } + return { ...result, output: `FINAL ANSWER: ${result.metadata.committed_text ?? result.output}` } } return { tools, @@ -168,7 +152,7 @@ export async function createAdapter(options: { allowWrite?: boolean } = {}) { active.add(pending) try { const result = await pending - return record(name, result, args) + return record(name, result) } catch (error) { if (name === "answer_commit") messages.push(toolMessage(name, { status: "error", error: String(error) })) throw error diff --git a/packages/opencode/test/altimate/mcp-server/final-answer.test.ts b/packages/opencode/test/altimate/mcp-server/final-answer.test.ts deleted file mode 100644 index e56c0ce4ca..0000000000 --- a/packages/opencode/test/altimate/mcp-server/final-answer.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -// altimate_change start — the MCP FINAL ANSWER line carries only the answer -import { describe, expect, test } from "bun:test" -import { finalAnswerText } from "../../../src/altimate/mcp-server/adapter" - -const basis = "Counted distinct orders in the sales table for 2024; refunds excluded." - -describe("finalAnswerText", () => { - test("puts the basis above an answer-only FINAL ANSWER line for fields", () => { - const out = finalAnswerText( - { fields: [{ name: "orders", value: 42 }], asked: ["orders"], basis }, - `${basis}\n\n**Answer:** 42`, - ) - expect(out).toBe(`Basis: ${basis}\n\nFINAL ANSWER: 42`) - }) - - test("renders rows without the basis or answer label on the FINAL ANSWER line", () => { - const out = finalAnswerText( - { - rows: { columns: ["city", "n"], values: [["Oslo", 3], ["Lima", 2]] }, - asked: ["city", "n"], - basis, - }, - "unused", - ) - const answer = out.slice(out.indexOf("FINAL ANSWER: ")) - expect(out.startsWith(`Basis: ${basis}\n\n`)).toBe(true) - expect(answer).toContain("Oslo") - expect(answer).not.toContain("**Answer:**") - expect(answer).not.toContain("refunds excluded") - }) - - test("omits the basis block when the caller already asked for answer_only", () => { - const out = finalAnswerText( - { fields: [{ name: "orders", value: 42 }], asked: ["orders"], basis, answer_only: true }, - "42", - ) - expect(out).toBe("FINAL ANSWER: 42") - }) - - test("falls back to the committed text when the args cannot be re-rendered", () => { - expect(finalAnswerText({ basis: "" }, "committed")).toBe("FINAL ANSWER: committed") - }) -}) -// altimate_change end diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index e92a9d7cef..b07bdf2215 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -469,12 +469,8 @@ mcpTest("answer_commit returns the canonical FINAL ANSWER text and trims a long const result = await call("answer_commit", commit([{ name: "", value: "apple" }], ["item"], "x".repeat(900))) expect(result.isError).toBeFalsy() const out = (result.content as { text: string }[])[0]!.text - // altimate_change start — the FINAL ANSWER line is answer-only; the trimmed basis sits above it - expect(out).toMatch(/(^|\n\n)FINAL ANSWER: /) - const answer = out.slice(out.indexOf("FINAL ANSWER: ")) - expect(answer).toContain("apple") - expect(answer).not.toContain("xxx") - // altimate_change end + expect(out.startsWith("FINAL ANSWER: ")).toBe(true) + expect(out).toContain("apple") }) mcpTest("answer_commit rejects a missing asked item at most twice, then accepts", async () => { @@ -534,7 +530,7 @@ mcpTest("answer_commit returns the FINAL ANSWER text without structured output", expect(tool?.outputSchema).toBeUndefined() const result = await call("answer_commit", commit([{ name: "", value: "apple" }], ["item"])) expect(result.structuredContent).toBeUndefined() - expect((result.content as { text: string }[])[0]!.text).toMatch(/(^|\n\n)FINAL ANSWER: /) + expect((result.content as { text: string }[])[0]!.text).toStartWith("FINAL ANSWER: ") }) // altimate_change end From 8b612b07da6a6996381b2b458f3c31d6e9dfab78 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sun, 18 Oct 2026 13:36:23 -0700 Subject: [PATCH 21/39] feat(mcp): answer-only FINAL ANSWER line for complete commits, plus a one-time requested-items check - `answer_commit` over MCP takes the user's question verbatim as an MCP-only `request` argument (the server never sees the user message); the adapter strips it before the strict parser. - A one-time conjunction check rejects a `fields` commit whose field count is below the number of items the request asks for ("the X and its Y"); an unchanged recommit is accepted. - The FINAL ANSWER line drops the basis (basis rendered above it) only for a complete commit: a `rows` commit, or a `fields` commit covering every requested item. Under-asked commits keep the previous rendering, so a second item named only in the basis is not lost. - Tests: final-answer rendering branches, MCP server conjunction path, detector unit tests. Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/mcp-server/adapter.ts | 65 ++++++++++- .../tools/answer-commit-conjunction.ts | 105 +++++++++++++++++ .../src/altimate/tools/answer-commit.ts | 8 +- .../altimate/mcp-server/final-answer.test.ts | 73 ++++++++++++ .../test/altimate/mcp-server/server.test.ts | 56 ++++++++- .../tools/answer-commit-conjunction.test.ts | 109 ++++++++++++++++++ 6 files changed, 407 insertions(+), 9 deletions(-) create mode 100644 packages/opencode/src/altimate/tools/answer-commit-conjunction.ts create mode 100644 packages/opencode/test/altimate/mcp-server/final-answer.test.ts create mode 100644 packages/opencode/test/altimate/tools/answer-commit-conjunction.test.ts diff --git a/packages/opencode/src/altimate/mcp-server/adapter.ts b/packages/opencode/src/altimate/mcp-server/adapter.ts index cc6a7b406a..7f88f5df22 100644 --- a/packages/opencode/src/altimate/mcp-server/adapter.ts +++ b/packages/opencode/src/altimate/mcp-server/adapter.ts @@ -14,7 +14,8 @@ import { SchemaInspectTool } from "../tools/schema-inspect" import { SqlExecuteTool } from "../tools/sql-execute" import { SqlCheckTool } from "../tools/sql-check" import { SqlMaterializeTool } from "../tools/sql-materialize" -import { AnswerCommitTool } from "../tools/answer-commit" +import { AnswerCommitTool, renderAnswer } from "../tools/answer-commit" +import { requestedItemCount } from "../tools/answer-commit-conjunction" import * as Precedence from "../workspace/precedence" import { closeScratch } from "../native/connections/scratch" import type { SessionID, MessageID } from "@/session/schema" @@ -36,6 +37,29 @@ const MCP_ONLY_TOOLS = new Set(["answer_commit"]) /** Session-level record the native commit checks read: tool outputs and rejections of the current turn. */ type TurnMessage = Tool.Context["messages"][number] +/** + * The FINAL ANSWER line carries only the answer, rendered as answer_only would, so the + * explanation never becomes part of the answer text a client copies. The basis is kept, + * above that line, as context for the user. + */ +export function finalAnswerText(args: Record, committed: string, request?: unknown): string { + // Answer-only only for a complete commit: rows, or fields covering every item the request names. + // Otherwise keep the committed text (basis on the line) so an item kept only in the basis is not lost. + const fields = Array.isArray(args.fields) ? args.fields.length : 0 + const complete = + Boolean(args.rows) || + (fields > 0 && fields >= requestedItemCount(typeof request === "string" ? request : "")) + if (!complete) return `FINAL ANSWER: ${committed}` + let answer = committed + try { + answer = renderAnswer({ ...args, answer_only: true }) + } catch { + // Rendering already succeeded once inside the tool; keep its text if this variant fails. + } + const basis = typeof args.basis === "string" && args.answer_only !== true ? args.basis.trim() : "" + return basis ? `Basis: ${basis}\n\nFINAL ANSWER: ${answer}` : `FINAL ANSWER: ${answer}` +} + function userMessage(): TurnMessage { return { info: { role: "user" }, parts: [] } as unknown as TurnMessage } @@ -66,6 +90,30 @@ function services(directory: string, limits: Awaited(schema: T): T { + const copy = JSON.parse(JSON.stringify(schema)) as { properties?: Record; required?: string[] } + copy.properties = { + ...copy.properties, + request: { + type: "string", + description: + "The user's question, copied verbatim: only the question itself, without database descriptions or other context.", + }, + } + copy.required = [...(copy.required ?? []), "request"] + return copy as T +} + +function recordRequest(messages: TurnMessage[], request: unknown) { + if (typeof request !== "string" || !request.trim()) return + const turn = messages.findLast((message) => message.info.role === "user") + if (turn) (turn as { parts: unknown[] }).parts = [{ type: "text", text: request.trim() }] +} + function relaxBasis(schema: T): T { const copy = JSON.parse(JSON.stringify(schema).replaceAll(`at most ${BASIS_LIMIT} characters`, "kept brief")) as { properties?: { basis?: { maxLength?: number } } @@ -95,7 +143,7 @@ export async function createAdapter(options: { allowWrite?: boolean } = {}) { ), ) } - if (tool.jsonSchema && info.id === "answer_commit") tool.jsonSchema = relaxBasis(tool.jsonSchema) + if (tool.jsonSchema && info.id === "answer_commit") tool.jsonSchema = withRequest(relaxBasis(tool.jsonSchema)) result.set(info.id, tool) } return result @@ -111,7 +159,7 @@ export async function createAdapter(options: { allowWrite?: boolean } = {}) { const shutdown = new AbortController() const active = new Set>() // Feed the native commit checks: completed tool output and rejections, with a new turn after an accepted commit. - const record = (name: string, result: Tool.ExecuteResult) => { + const record = (name: string, result: Tool.ExecuteResult, args: Record, request?: unknown) => { if (name !== "answer_commit") { if (result.status !== "error") messages.push(toolMessage(name, { status: "completed", output: result.output })) return result @@ -121,7 +169,7 @@ export async function createAdapter(options: { allowWrite?: boolean } = {}) { return result } messages.push(userMessage()) - return { ...result, output: `FINAL ANSWER: ${result.metadata.committed_text ?? result.output}` } + return { ...result, output: finalAnswerText(args, result.metadata.committed_text ?? result.output, request) } } return { tools, @@ -148,11 +196,18 @@ export async function createAdapter(options: { allowWrite?: boolean } = {}) { } if (MCP_ONLY_TOOLS.has(name) && typeof args.basis === "string" && args.basis.length > BASIS_LIMIT) args = { ...args, basis: args.basis.slice(0, BASIS_LIMIT) } + let request: unknown + if (name === "answer_commit") { + const { request: text, ...rest } = args + request = text + recordRequest(messages, request) + args = rest + } const pending = Effect.runPromise(tool.execute(args, ctx)) active.add(pending) try { const result = await pending - return record(name, result) + return record(name, result, args, request) } catch (error) { if (name === "answer_commit") messages.push(toolMessage(name, { status: "error", error: String(error) })) throw error diff --git a/packages/opencode/src/altimate/tools/answer-commit-conjunction.ts b/packages/opencode/src/altimate/tools/answer-commit-conjunction.ts new file mode 100644 index 0000000000..0c1b7fd41e --- /dev/null +++ b/packages/opencode/src/altimate/tools/answer-commit-conjunction.ts @@ -0,0 +1,105 @@ +import type { Tool } from "../../tool/tool" + +/** Identifies this check's rejections in the current turn's history. */ +export const CONJUNCTION_REJECTION_MARKER ="answer_commit conjunction check:" + +// Narrow connectives strongly suggest another requested item. +const NARROW = [ + /\band (?:its|their|his|her|the corresponding|the associated)\b/gi, + /\balong with\b/gi, + /\bas well as\b/gi, + /\btogether with\b/gi, + /\band also\b/gi, + /\bin addition to\b/gi, + /\band (?:report|list|show|include|provide|return|give|compute|calculate|identify|find)\b/gi, +] +// Articles and comma-separated conjunctions catch less explicit item lists. +const BROAD_EXTRA = [/,\s+and\s+(?!then\b)/gi, /\band (?:the|a|an|which|what|how|whether)\b/gi] +// Ranges and paired expressions do not introduce requested items. +const EXCL = /(?:between|both|either|from)\s+[^.,;]{0,60}?\band\b/gi +// Constraints qualify an item rather than request another one. +const CONSTRAINT = + /\band (?:only|also consider|consider|excluding|exclude|limit|using|ensure|ensuring|where|for|in|on|at|from|with|by|to|if)\b/gi + +/** + * Lexical estimate of how many items the request asks for: 1 plus the most item connectives in any + * sentence of its first paragraph, ignoring ranges, constraint clauses and rounding instructions. + */ +export function requestedItemCount(request: string): number { + const para = request + .trim() + .split(/\n\s*\n|\n#/)[0]! + .replace(EXCL, " ") + let best = 0 + for (const sentence of para.split(/(?<=[.?!])\s+/)) { + if (/\b(decimal|precision|round(?:ed|ing)?)\b/i.test(sentence) && !/\band\b/i.test(sentence)) continue + const text = sentence.replace(CONSTRAINT, " ") + const spans: number[] = [] + for (const pattern of [...NARROW, ...BROAD_EXTRA]) { + for (const match of text.matchAll(pattern)) { + if (!spans.some((start) => Math.abs(match.index - start) < 4)) spans.push(match.index) + } + } + best = Math.max(best, spans.length) + } + return 1 + best +} + +type Part = { + type?: string + tool?: string + text?: string + synthetic?: boolean + state?: { status?: string; error?: unknown } +} + +/** Bounds the in-process record of advised requests; the oldest entries are dropped first. */ +const MAX_ADVISED_REQUESTS = 1_000 +/** + * Session and request text already advised in this process. Reserved synchronously before the rejection + * returns, so parallel commits in one step and a compaction replay of the same request are not rejected again. + */ +const advised = new Set() + +/** + * Advisory for a commit that names one item when the latest user request joins two. Fires at most once + * per session and request, so the next commit, changed or not, is accepted. Purely lexical: no queries or + * model calls. + */ +export function conjunctionRejection( + args: { fields?: { name: string }[]; rows?: unknown; unresolved?: unknown; asked?: string[] }, + messages: Tool.Context["messages"], + sessionID: string, +): string | undefined { + if (args.unresolved) return undefined + const asked = args.asked?.filter((item) => item.trim()) + const count = asked?.length ? asked.length : args.fields?.length + if (count !== 1) return undefined + const latest = messages.findLastIndex((message) => message.info.role === "user") + if (latest < 0) return undefined + const text = (messages[latest]!.parts as Part[]) + .filter((part) => part.type === "text" && !part.synthetic) + .map((part) => part.text ?? "") + .join("\n\n") + if (requestedItemCount(text) < 2) return undefined + const key = `${sessionID}\u0000${text}` + if (advised.has(key)) return undefined + // History covers a resumed session whose in-process record is gone. + if ( + messages + .slice(latest) + .some((message) => + (message.parts as Part[]).some( + (part) => + part.type === "tool" && + part.tool === "answer_commit" && + part.state?.status === "error" && + String(part.state.error).includes(CONJUNCTION_REJECTION_MARKER), + ), + ) + ) + return undefined + advised.add(key) + if (advised.size > MAX_ADVISED_REQUESTS) advised.delete(advised.values().next().value!) + return `${CONJUNCTION_REJECTION_MARKER} the request appears to ask for more than one item (it joins items with "and" or a similar connective), but this commit names one${asked?.length ? ` (${JSON.stringify(asked)})` : ""}. Call answer_commit again with each requested item as its own field (or rows column), named after it, and list them all in \`asked\`. If the request asks for a single item, recommit unchanged; the next commit is accepted.` +} diff --git a/packages/opencode/src/altimate/tools/answer-commit.ts b/packages/opencode/src/altimate/tools/answer-commit.ts index 542b34a435..08131aa655 100644 --- a/packages/opencode/src/altimate/tools/answer-commit.ts +++ b/packages/opencode/src/altimate/tools/answer-commit.ts @@ -19,6 +19,9 @@ import { sizeBasedRejections, unsourcedNumbers, } from "./answer-commit-checks" +// altimate_change start — one-time conjunction review +import { conjunctionRejection } from "./answer-commit-conjunction" +// altimate_change end /** Maximum rendered answer length, including Markdown and basis, before the model echoes it. */ export const MAX_RENDERED_ANSWER_CHARACTERS = 60_000 @@ -362,7 +365,10 @@ export const AnswerCommitTool = Tool.define( return yield* Effect.die(new Error("answer_commit is only available to the analyst agent")) yield* ctx.ask({ permission: "answer_commit", patterns: ["*"], always: ["*"], metadata: {} }) const output = renderAnswer(args) - const rejection = commitRejection(args, ctx.messages ?? []) + // altimate_change start — one-time conjunction review of under-asked commits + const rejection = + commitRejection(args, ctx.messages ?? []) ?? conjunctionRejection(args, ctx.messages ?? [], ctx.sessionID) + // altimate_change end if (rejection) { const rejected: Tool.ExecuteResult = { title: "Answer not committed", diff --git a/packages/opencode/test/altimate/mcp-server/final-answer.test.ts b/packages/opencode/test/altimate/mcp-server/final-answer.test.ts new file mode 100644 index 0000000000..eaaa79f7b5 --- /dev/null +++ b/packages/opencode/test/altimate/mcp-server/final-answer.test.ts @@ -0,0 +1,73 @@ +// altimate_change start — the MCP FINAL ANSWER line carries only the answer +import { describe, expect, test } from "bun:test" +import { finalAnswerText } from "../../../src/altimate/mcp-server/adapter" + +const basis = "Counted distinct orders in the sales table for 2024; refunds excluded." + +describe("finalAnswerText", () => { + test("puts the basis above an answer-only FINAL ANSWER line for fields", () => { + const out = finalAnswerText( + { fields: [{ name: "orders", value: 42 }], asked: ["orders"], basis }, + `${basis}\n\n**Answer:** 42`, + ) + expect(out).toBe(`Basis: ${basis}\n\nFINAL ANSWER: 42`) + }) + + test("renders rows without the basis or answer label on the FINAL ANSWER line", () => { + const out = finalAnswerText( + { + rows: { columns: ["city", "n"], values: [["Oslo", 3], ["Lima", 2]] }, + asked: ["city", "n"], + basis, + }, + "unused", + ) + const answer = out.slice(out.indexOf("FINAL ANSWER: ")) + expect(out.startsWith(`Basis: ${basis}\n\n`)).toBe(true) + expect(answer).toContain("Oslo") + expect(answer).not.toContain("**Answer:**") + expect(answer).not.toContain("refunds excluded") + }) + + test("omits the basis block when the caller already asked for answer_only", () => { + const out = finalAnswerText( + { fields: [{ name: "orders", value: 42 }], asked: ["orders"], basis, answer_only: true }, + "42", + ) + expect(out).toBe("FINAL ANSWER: 42") + }) + + test("a complete fields commit for a two-item request gets the answer-only line", () => { + const args = { + fields: [ + { name: "repository", value: "repo-a" }, + { name: "most copied file", value: "main.swift" }, + ], + asked: ["repository", "most copied file"], + basis, + } + const out = finalAnswerText(args, "unused", "Which repository and its most copied file?") + expect(out.startsWith(`Basis: ${basis}\n\nFINAL ANSWER: `)).toBe(true) + expect(out.slice(out.indexOf("FINAL ANSWER: "))).not.toContain("**Answer:**") + }) + + test("an under-asked fields commit for a two-item request keeps the committed text on the line", () => { + const committed = `${basis} The top file is main.swift.\n\n**Answer:** repository: repo-a` + const out = finalAnswerText( + { fields: [{ name: "repository", value: "repo-a" }], asked: ["repository"], basis }, + committed, + "Which repository and its most copied file?", + ) + expect(out).toBe(`FINAL ANSWER: ${committed}`) + }) + + test("unresolved commits keep the committed text on the line", () => { + const out = finalAnswerText({ unresolved: { reason: "No rows." }, basis }, "committed", "How many orders?") + expect(out).toBe("FINAL ANSWER: committed") + }) + + test("falls back to the committed text when the args cannot be re-rendered", () => { + expect(finalAnswerText({ basis: "" }, "committed")).toBe("FINAL ANSWER: committed") + }) +}) +// altimate_change end diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index b07bdf2215..f0dcac2776 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -469,8 +469,12 @@ mcpTest("answer_commit returns the canonical FINAL ANSWER text and trims a long const result = await call("answer_commit", commit([{ name: "", value: "apple" }], ["item"], "x".repeat(900))) expect(result.isError).toBeFalsy() const out = (result.content as { text: string }[])[0]!.text - expect(out.startsWith("FINAL ANSWER: ")).toBe(true) - expect(out).toContain("apple") + // altimate_change start — the FINAL ANSWER line is answer-only; the trimmed basis sits above it + expect(out).toMatch(/(^|\n\n)FINAL ANSWER: /) + const answer = out.slice(out.indexOf("FINAL ANSWER: ")) + expect(answer).toContain("apple") + expect(answer).not.toContain("xxx") + // altimate_change end }) mcpTest("answer_commit rejects a missing asked item at most twice, then accepts", async () => { @@ -501,6 +505,52 @@ mcpTest("answer_commit accepts a number that appeared in query output", async () }) // altimate_change end +// altimate_change start — conjunction check over MCP: the request arrives as an MCP-only argument +mcpTest("answer_commit exposes a required request argument over MCP", async () => { + const { client } = await fixture() + const tool = (await client.listTools()).tools.find((item) => item.name === "answer_commit") + expect(Object.keys(tool!.inputSchema.properties ?? {})).toContain("request") + expect(tool!.inputSchema.required).toContain("request") +}) + +mcpTest("answer_commit rejects an under-asked commit on a compound request once, then accepts", async () => { + const { call } = await fixture() + const args = { ...commit([{ name: "item", value: "apple" }], ["item"]), request: "Which item and its id?" } + const rejected = await call("answer_commit", args) + expect(rejected.isError).toBe(true) + expect(text(rejected)).toContain("answer_commit conjunction check:") + const accepted = await call("answer_commit", args) + expect(accepted.isError).toBeFalsy() + // An under-asked commit keeps the basis on the FINAL ANSWER line. + const out = (accepted.content as { text: string }[])[0]!.text + expect(out.startsWith("FINAL ANSWER: Counted items.")).toBe(true) + expect(out).toContain("**Answer:**") + expect(out).toContain("apple") +}) + +mcpTest("answer_commit accepts a single-item request and a commit without request", async () => { + const { call } = await fixture() + const single = { ...commit([{ name: "item", value: "apple" }], ["item"]), request: "Which item sorts first?" } + expect((await call("answer_commit", single)).isError).toBeFalsy() + expect((await call("answer_commit", commit([{ name: "item", value: "apple" }], ["item"]))).isError).toBeFalsy() +}) + +mcpTest("answer_commit accepts a compound request committed with both items", async () => { + const { call } = await fixture() + const both = { + ...commit( + [ + { name: "item", value: "apple" }, + { name: "id", value: "one" }, + ], + ["item", "id"], + ), + request: "Which item and its id?", + } + expect((await call("answer_commit", both)).isError).toBeFalsy() +}) +// altimate_change end + // altimate_change start — spilled sql_execute results carry a resource_link beside the text fallback mcpTest("sql_execute spill returns text with the path plus a resource_link to the saved rows", async () => { const { call } = await fixture() @@ -530,7 +580,7 @@ mcpTest("answer_commit returns the FINAL ANSWER text without structured output", expect(tool?.outputSchema).toBeUndefined() const result = await call("answer_commit", commit([{ name: "", value: "apple" }], ["item"])) expect(result.structuredContent).toBeUndefined() - expect((result.content as { text: string }[])[0]!.text).toStartWith("FINAL ANSWER: ") + expect((result.content as { text: string }[])[0]!.text).toMatch(/(^|\n\n)FINAL ANSWER: /) }) // altimate_change end diff --git a/packages/opencode/test/altimate/tools/answer-commit-conjunction.test.ts b/packages/opencode/test/altimate/tools/answer-commit-conjunction.test.ts new file mode 100644 index 0000000000..918d8d8ee4 --- /dev/null +++ b/packages/opencode/test/altimate/tools/answer-commit-conjunction.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from "bun:test" +import { AnswerCommitTool, renderAnswer } from "../../../src/altimate/tools/answer-commit" +import { CONJUNCTION_REJECTION_MARKER, requestedItemCount } from "../../../src/altimate/tools/answer-commit-conjunction" +import type { Tool } from "../../../src/tool/tool" +import { initTool } from "../tool-fixture" + +const singles = [ + "Which repository has the most stars?", + "How many films were released between 2010 and 2015?", + "Which artist sold the most Rock and Roll albums?", + "Which artist sold the most rock and roll albums?", + "Which customer from Trinidad and Tobago spent the most?", + "List the top product, and only consider active stores.", +] +const compound = "Which repository and its most copied file?" +const payload = { + basis: "Selected repositories at repository grain from repositories.name.", + asked: ["repository"], + fields: [{ name: "repository", value: "repo-a" }], +} +function user(text: string) { + return { info: { role: "user" }, parts: [{ type: "text", text }] } +} +// The advisory is recorded per session, so each call gets a fresh session unless one is shared. +function context(messages: unknown[] = [], sessionID: string = crypto.randomUUID()) { + return { + sessionID, + messageID: "test-message", + agent: "analyst", + abort: new AbortController().signal, + messages: messages as Tool.Context["messages"], + ask: () => {}, + } +} + +test.each(singles)("counts one item: %s", (request) => { + expect(requestedItemCount(request)).toBe(1) +}) +test("counts related requested items", () => { + expect(requestedItemCount("Which repository contains Swift code and its most copied file?")).toBe(2) +}) +test.each([true, false])("rejects once with asked present: %s", async (withAsked) => { + const tool = await initTool(AnswerCommitTool) + const input = { ...payload, asked: withAsked ? payload.asked : undefined } + const first = await tool.execute(input, context([user(compound)])) + expect(first.status).toBe("error") + expect(first.output.startsWith(CONJUNCTION_REJECTION_MARKER)).toBe(true) + const prior = { + info: { role: "assistant" }, + parts: [{ type: "tool", tool: "answer_commit", state: { status: "error", error: first.output } }], + } + const second = await tool.execute(input, context([user(compound), prior])) + expect(second.status).not.toBe("error") + expect(second.output).toBe(renderAnswer(input)) + const next = "Which author and their most cited paper?" + expect((await tool.execute(input, context([user(compound), prior, user(next)]))).status).toBe("error") +}) +test("rejects once per session and request without history of the rejection", async () => { + const tool = await initTool(AnswerCommitTool) + const session = crypto.randomUUID() + // Parallel commits in one step share the same history snapshot. + const [first, second] = await Promise.all([ + tool.execute(payload, context([user(compound)], session)), + tool.execute(payload, context([user(compound)], session)), + ]) + expect([first.status, second.status].filter((status) => status === "error")).toHaveLength(1) + // A compaction replay repeats the request as a new user message without the earlier rejection. + const replay = await tool.execute(payload, context([user(compound)], session)) + expect(replay.output).toBe(renderAnswer(payload)) + expect((await tool.execute(payload, context([user(compound)]))).status).toBe("error") +}) +test("answer_only still receives the advisory", async () => { + const tool = await initTool(AnswerCommitTool) + const result = await tool.execute( + { ...payload, answer_only: true }, + context([user("Identify the repository and the most copied file.")]), + ) + expect(result.status).toBe("error") + expect(result.output.startsWith(CONJUNCTION_REJECTION_MARKER)).toBe(true) +}) +test.each(singles)("accepts single-item request: %s", async (request) => { + const tool = await initTool(AnswerCommitTool) + expect((await tool.execute(payload, context([user(request)]))).output).toBe(renderAnswer(payload)) +}) +test("accepts both requested items without rendering asked", async () => { + const tool = await initTool(AnswerCommitTool) + const input = { + ...payload, + asked: ["repository", "most copied file"], + fields: [...payload.fields, { name: "most copied file", value: "main.swift" }], + } + const result = await tool.execute(input, context([user(compound)])) + expect(result.status).not.toBe("error") + expect(result.metadata.asked).toEqual(input.asked) + expect(result.output).toBe(renderAnswer({ ...input, asked: undefined })) +}) +test("accepts unresolved and missing user messages", async () => { + const tool = await initTool(AnswerCommitTool) + const unresolved = { basis: payload.basis, unresolved: { reason: "No matching records." }, asked: payload.asked } + expect((await tool.execute(unresolved, context([user(compound)]))).output).toBe(renderAnswer(unresolved)) + expect((await tool.execute(payload, context())).output).toBe(renderAnswer(payload)) +}) +test("rows need asked and blank asked entries fall back to fields", async () => { + const tool = await initTool(AnswerCommitTool) + const rows = { basis: payload.basis, rows: { columns: ["repository"], values: [["repo-a"]] } } + expect((await tool.execute(rows, context([user(compound)]))).output).toBe(renderAnswer(rows)) + expect((await tool.execute({ ...rows, asked: ["repository"] }, context([user(compound)]))).status).toBe("error") + expect((await tool.execute({ ...payload, asked: [" "] }, context([user(compound)]))).status).toBe("error") +}) From 9540e83fbcf8a95f9a83d6ffab14e4fed5f7fa30 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 19 Oct 2026 12:39:18 -0700 Subject: [PATCH 22/39] feat(mcp): batch independent `sql_execute` queries and list local tables in `warehouse_list` - `sql_execute` `queries`: up to 8 read-only queries per call, one labeled block each; a failing item does not stop the rest; one shared byte/line budget. - MCP `warehouse_list` adds local tables with columns and a one-row sample (SQLite/local DuckDB base tables), bounded by table budgets and a 4 s deadline; remote connections run no queries. Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/mcp-server/adapter.ts | 5 +- .../src/altimate/mcp-server/server.ts | 26 +- .../src/altimate/tools/sql-execute.ts | 142 ++++++++++- .../src/altimate/tools/warehouse-discovery.ts | 223 ++++++++++++++++++ .../src/altimate/tools/warehouse-list.ts | 10 + .../test/altimate/mcp-server/server.test.ts | 176 +++++++++++++- .../tools/warehouse-discovery.test.ts | 29 +++ 7 files changed, 588 insertions(+), 23 deletions(-) create mode 100644 packages/opencode/src/altimate/tools/warehouse-discovery.ts create mode 100644 packages/opencode/test/altimate/tools/warehouse-discovery.test.ts diff --git a/packages/opencode/src/altimate/mcp-server/adapter.ts b/packages/opencode/src/altimate/mcp-server/adapter.ts index 7f88f5df22..9f0c435312 100644 --- a/packages/opencode/src/altimate/mcp-server/adapter.ts +++ b/packages/opencode/src/altimate/mcp-server/adapter.ts @@ -21,10 +21,11 @@ import { closeScratch } from "../native/connections/scratch" import type { SessionID, MessageID } from "@/session/schema" export const descriptions: Record = { - warehouse_list: "List configured warehouse connections and their database types.", + warehouse_list: + "List warehouse connections; for local databases also their tables, columns and a sample row (small catalogs).", schema_inspect: "List tables/collections when table is omitted or empty; otherwise inspect one table, with optional sample values.", sql_execute: - "Execute SQL against a connected warehouse and return rows. Source writes require the server's --allow-write option.", + "Execute SQL against a connected warehouse and return rows. Run independent queries together in one call with `queries`. Source writes require the server's --allow-write option.", sql_check: "Run a read-only SQL data quality check against a connected warehouse.", sql_materialize: "Copy a read-only source query into a session-local scratch table for further analysis.", answer_commit: diff --git a/packages/opencode/src/altimate/mcp-server/server.ts b/packages/opencode/src/altimate/mcp-server/server.ts index 79d7e9fbd6..0e4289f782 100644 --- a/packages/opencode/src/altimate/mcp-server/server.ts +++ b/packages/opencode/src/altimate/mcp-server/server.ts @@ -16,9 +16,9 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version" export const SERVER_INSTRUCTIONS = [ "Analyst method for data questions:", - "1. Start with warehouse_list, then schema_inspect without `table` to see the tables.", + "1. Start with warehouse_list; it also shows local tables with columns and a sample row.", "2. Read schema_inspect's value profile (ranked values, distinct/null counts, unique flag, value shapes) before choosing columns or join keys; formats often differ across databases.", - "3. Compute in the database.", + "3. Compute in the database; batch independent queries in one sql_execute call (`queries`).", "4. For joins across connections, sql_materialize the smaller filtered side into `scratch` and join there.", "5. Run sql_check when keys, fan-out, grain or nulls could change the answer.", "6. Check that every condition in the question actually filters something.", @@ -27,16 +27,18 @@ export const SERVER_INSTRUCTIONS = [ /** Spilled results: a resource_link next to the text, which keeps the preview, row count and path. */ function resultLinks(metadata: Record) { - const file = [metadata.result_path, metadata.outputPath].find((value) => typeof value === "string" && value) - if (typeof file !== "string") return [] - return [ - { - type: "resource_link" as const, - uri: pathToFileURL(file).href, - name: path.basename(file), - mimeType: file.endsWith(".jsonl") ? "application/x-ndjson" : "text/plain", - }, - ] + // A batched sql_execute call reports one saved file per spilled query. + const batch = Array.isArray(metadata.result_paths) ? metadata.result_paths : [] + const single = [metadata.result_path, metadata.outputPath].find((value) => typeof value === "string" && value) + const files = [...new Set([...batch, ...(batch.length ? [] : [single])])].filter( + (file): file is string => typeof file === "string" && !!file, + ) + return files.map((file) => ({ + type: "resource_link" as const, + uri: pathToFileURL(file).href, + name: path.basename(file), + mimeType: file.endsWith(".jsonl") ? "application/x-ndjson" : file.endsWith(".csv") ? "text/csv" : "text/plain", + })) } // Surfaces as a slash command in interactive clients. diff --git a/packages/opencode/src/altimate/tools/sql-execute.ts b/packages/opencode/src/altimate/tools/sql-execute.ts index 81c6f58244..81c4738de6 100644 --- a/packages/opencode/src/altimate/tools/sql-execute.ts +++ b/packages/opencode/src/altimate/tools/sql-execute.ts @@ -2,7 +2,7 @@ import z from "zod" import { Effect } from "effect" import { EffectBridge } from "../../effect/bridge" import { Truncate } from "../../tool/truncate" -import { legacyToInit, type LegacyToolDef } from "../tool-zod-compat" +import { legacyToInit, type LegacyContext, type LegacyResult, type LegacyToolDef } from "../tool-zod-compat" import { formatSqlResult, SQL_MAX_PREVIEW_BYTES } from "./sql-result-format" import { lintSqlResult } from "./sql-result-lint" import { Tool } from "../../tool/tool" @@ -41,6 +41,15 @@ const LIMIT_ERROR = "limit must be a finite positive integer" const MAX_GENERATED_SQL_BYTES = 1024 * 1024 // Below the default tool-output cap, so a redirect never spills the SQL into a truncated file. const MAX_REDIRECT_SQL_BYTES = 40 * 1024 +// A batch shares one output budget; each query gets an equal share of what is left, so small results donate the rest. +const MAX_BATCH_QUERIES = 8 +const BATCH_MAX_PREVIEW_BYTES = 24 * 1024 + +/** Per-query overrides a batch applies to one item. */ +interface ItemBudget { + maxBytes: number + maxLines: number +} function generatedRead(sql: string, warehouse: string, stage: "source" | "result"): string { try { @@ -52,7 +61,20 @@ function generatedRead(sql: string, warehouse: string, stage: "source" | "result const parameters = z .object({ - query: z.string().nullish().describe("SQL query to execute; omit when using query_from"), + query: z.string().nullish().describe("SQL query to execute; omit when using query_from or queries"), + queries: z + .array( + z.object({ + label: z.string().nullish().describe("Short name for this result (default q1, q2, ...)"), + query: z.string().describe("Read-only SQL (or MongoDB JSON command)"), + warehouse: z.string().nullish().describe("Warehouse for this query (default: warehouse)"), + }), + ) + .max(MAX_BATCH_QUERIES, `queries accepts at most ${MAX_BATCH_QUERIES} items`) + .nullish() + .describe( + `Up to ${MAX_BATCH_QUERIES} independent read-only queries run in one call, each returning its own labeled result; one failing query does not stop the others. Use instead of query when no query depends on another's result.`, + ), query_from: z .object({ warehouse: z.string().describe("Warehouse containing the SQL generator or saved SQL"), @@ -95,14 +117,19 @@ export const SqlExecuteTool = Tool.define( return () => Effect.gen(function* () { const bridge = yield* EffectBridge.make() - return yield* legacyToInit({ + const def = { description: "Execute SQL against a connected data warehouse. Returns all selected columns as a formatted table. full_text_columns prioritizes named cells within the ordinary output budget and always saves returned rows to result_path; read with char_offset/max_chars recovers clipped text. [] or null, or omitting the field, turns the mode off. A non-empty list caps preview rows at 20: omit limit to use the default; larger limits clamp to 20 with a result note. Missing column names produce a bounded note listing available columns while still returning rows. " + - "Provide exactly one of query or query_from. query_from runs a read-only generator SELECT and executes its single SQL string result (up to 1 MiB) as a read-only SELECT on warehouse, without previewing or retyping the generated SQL. " + + "Provide exactly one of query, query_from or queries. query_from runs a read-only generator SELECT and executes its single SQL string result (up to 1 MiB) as a read-only SELECT on warehouse, without previewing or retyping the generated SQL. " + + `queries runs up to ${MAX_BATCH_QUERIES} independent read-only queries in one call and returns one labeled result per query; batch queries that do not depend on each other. ` + "For DuckDB table sets, generate query_table([...], true) with escaped table-name string literals. For thousands of tables, UNION ALL BY NAME batches of at most 128 names to stay within the default expression depth.", parameters, - formatValidationError: (error) => error.issues?.map((issue) => issue.message).join("; ") || LIMIT_ERROR, - async execute(args, ctx) { + formatValidationError: (error: z.ZodError) => error.issues?.map((issue) => issue.message).join("; ") || LIMIT_ERROR, + async execute(args: z.infer, ctx: LegacyContext, budget?: ItemBudget): Promise { + if (!budget && args.queries?.length) { + const limits = await bridge.promise(truncate.limits()) + return executeBatch(args, ctx, limits, (item, itemBudget) => def.execute(item, ctx, itemBudget)) + } const fullText = Boolean(args.full_text_columns?.length) const limit = fullText ? Math.min(args.limit ?? 20, 20) : Math.min(args.limit ?? 100, MAX_SQL_ROWS) const limitNote = @@ -200,6 +227,9 @@ export const SqlExecuteTool = Tool.define( "DROP DATABASE, DROP SCHEMA, TRUNCATE, and executable SQL comments are blocked for safety. This cannot be overridden.", ) } + if (budget && queryType !== "read") { + throw new Error("Batched queries must be read-only; run a write as its own sql_execute call.") + } const parseError = "parseError" in classification ? classification.parseError : undefined if (!mongo && queryType === "read") { // classifyAndCheck verifies complete lexical/parser agreement and every @@ -337,7 +367,10 @@ export const SqlExecuteTool = Tool.define( } // altimate_change end - const limits = await bridge.promise(truncate.limits()) + const configured = await bridge.promise(truncate.limits()) + const limits = budget + ? { maxBytes: Math.min(configured.maxBytes, budget.maxBytes), maxLines: Math.min(configured.maxLines, budget.maxLines) } + : configured const maxBytes = Math.min(limits.maxBytes, SQL_MAX_PREVIEW_BYTES) // Reserve the routing explanation before the formatter budgets optional notes. const routingNotice = fullText ? Precedence.annotate(precedence, { output: "" }).output : "" @@ -413,11 +446,104 @@ export const SqlExecuteTool = Tool.define( }) } }, - } satisfies LegacyToolDef) + } satisfies LegacyToolDef + return yield* legacyToInit(def) }) }), ) +type BatchItem = { label?: string | null; query: string; warehouse?: string | null } + +/** + * Runs independent read queries one after another (one connection per warehouse) and renders each + * result under its label. A failing query becomes its own error block; the others still run. + * Every block, error or not, is clipped to its share of one shared byte/line budget. + */ +async function executeBatch( + args: z.infer, + ctx: LegacyContext, + configured: { maxBytes: number; maxLines: number }, + run: (item: z.infer, budget: ItemBudget) => Promise, +): Promise { + const items = args.queries as BatchItem[] + if (args.query?.trim() || args.query_from?.warehouse?.trim() || args.query_from?.sql?.trim()) { + throw new Error("Provide either queries or query/query_from, not both.") + } + let bytesLeft = Math.min(configured.maxBytes, BATCH_MAX_PREVIEW_BYTES) + let linesLeft = configured.maxLines + const blocks: string[] = [] + const summary: { label: string; rowCount: number; error?: string; result_path?: string }[] = [] + for (const [index, item] of items.entries()) { + ctx.abort.throwIfAborted() + const share = Math.floor(bytesLeft / (items.length - index)) + const lineShare = Math.max(1, Math.floor(linesLeft / (items.length - index))) + const label = (item.label ?? "").replace(/\s+/g, " ").trim().slice(0, 60) || `q${index + 1}` + const header = `[${label}]\n` + const separator = blocks.length ? "\n\n" : "" + const reserved = Buffer.byteLength(separator + header, "utf8") + Buffer.byteLength(BATCH_CLIPPED, "utf8") + const budget = { maxBytes: Math.max(0, share - reserved), maxLines: Math.max(1, lineShare - 2) } + let result: LegacyResult + try { + result = await run( + { + query: item.query, + warehouse: item.warehouse?.trim() || args.warehouse, + limit: args.limit, + query_timeout_ms: args.query_timeout_ms, + }, + budget, + ) + } catch (error) { + if (ctx.abort.aborted) throw error + const message = error instanceof Error ? error.message : String(error) + result = { title: "SQL: ERROR", metadata: { error: message }, output: `Failed to execute SQL: ${message}` } + } + const body = clipToBudget(result.output, budget.maxBytes, budget.maxLines) + const block = separator + header + body + blocks.push(block) + bytesLeft -= Buffer.byteLength(block, "utf8") + linesLeft -= block.split("\n").length - (separator ? 1 : 0) + const metadata = (result.metadata ?? {}) as Record + summary.push({ + label, + rowCount: Number(metadata.rowCount ?? 0), + ...(typeof metadata.error === "string" && metadata.success !== true ? { error: metadata.error } : {}), + ...(typeof metadata.result_path === "string" ? { result_path: metadata.result_path } : {}), + }) + } + const failed = summary.filter((item) => item.error !== undefined) + return { + title: `SQL: ${items.length} queries${failed.length ? `, ${failed.length} failed` : ""}`, + metadata: { + rowCount: summary.reduce((total, item) => total + item.rowCount, 0), + queries: summary, + result_paths: summary.flatMap((item) => (item.result_path ? [item.result_path] : [])), + // This tool owns clipping; every block is already bounded. + truncated: false, + ...(failed.length === items.length ? { error: failed.map((item) => `${item.label}: ${item.error}`).join("\n") } : {}), + }, + output: blocks.join(""), + } +} + +const BATCH_CLIPPED = "…[output clipped]" + +/** Clip text to a byte and line budget on code-point boundaries, marking any clip. */ +function clipToBudget(text: string, maxBytes: number, maxLines: number): string { + if (Buffer.byteLength(text, "utf8") <= maxBytes && text.split("\n").length <= maxLines) return text + let bytes = 0 + let lines = 1 + let end = 0 + for (const char of text) { + const size = Buffer.byteLength(char, "utf8") + if (bytes + size > maxBytes || (char === "\n" && lines >= maxLines)) break + bytes += size + if (char === "\n") lines++ + end += char.length + } + return text.slice(0, end) + BATCH_CLIPPED +} + // altimate_change start — pre-execution SQL validation via cached schema const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours // High ceiling so large warehouses aren't arbitrarily truncated; we emit diff --git a/packages/opencode/src/altimate/tools/warehouse-discovery.ts b/packages/opencode/src/altimate/tools/warehouse-discovery.ts new file mode 100644 index 0000000000..4f5413c21b --- /dev/null +++ b/packages/opencode/src/altimate/tools/warehouse-discovery.ts @@ -0,0 +1,223 @@ +import { absoluteFileUriPath, isLocalFilePath } from "@altimateai/drivers/file-store" +import type { ConnectionConfig } from "@altimateai/drivers/types" +import { Dispatcher } from "../native" +import * as Registry from "../native/connections/registry" +import { isScratch } from "../native/connections/scratch" +import { executeQuery } from "../native/query-execution" + +/** + * One-call catalog discovery for warehouse_list: tables with columns and a one-row sample, so a + * client can start querying without separate schema_inspect round trips. + * + * Bounded by the data before any work: local engines only (remote warehouses run no queries here), + * table/column/sample budgets shared across connections, one overall deadline checked before every + * call (each call also has its own query timeout), and a byte cap on the rendered text. Samples run + * only on SQLite and local DuckDB base tables, whose drivers enforce read-only at the database. + */ +const MAX_CONNECTIONS = 8 +const MAX_SCHEMAS = 10 +const TABLE_BUDGET = 40 +const COLUMN_BUDGET = 30 +const SAMPLE_BUDGET = 15 +const MAX_COLUMNS_SHOWN = 40 +const SAMPLE_CELL_CHARS = 40 +const OUTPUT_BYTES = 8 * 1024 +const DEADLINE_MS = 4_000 +const SAMPLE_TIMEOUT_MS = 1_000 +const MONGO_SYSTEM_DATABASES = new Set(["admin", "config", "local"]) +const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]) +const SAMPLED_ENGINES = new Set(["sqlite", "duckdb"]) + +interface DiscoveryContext { + signal: AbortSignal + sessionID: string +} + +type Table = { schema: string; name: string; type: string; row_count?: number; row_count_estimated?: boolean } + +/** Data on this machine: a local SQLite/DuckDB file (or memory), or a server on a loopback host or socket. */ +export function isLocalConnection(config: ConnectionConfig | undefined): boolean { + if (!config) return false + const type = Registry.canonicalType(config.type) ?? "" + if (type === "sqlite" || type === "duckdb") { + const store = typeof config.path === "string" ? config.path : "" + return store === ":memory:" || (store !== "" && (isLocalFilePath(store) || absoluteFileUriPath(store) !== undefined)) + } + if (!["postgres", "mysql", "mongodb"].includes(type)) return false + const url = [config.connection_string, config.uri, config.url].find((value) => typeof value === "string" && value) + if (typeof url === "string") { + try { + const host = new URL(url).hostname + return LOCAL_HOSTS.has(host.toLowerCase()) + } catch { + return false + } + } + const host = typeof config.host === "string" ? config.host.trim().toLowerCase() : "" + // An absolute host path is a Unix socket on this machine. + return LOCAL_HOSTS.has(host) || host.startsWith("/") +} + +function rowsLabel(table: Table): string { + if (table.row_count === undefined) return "" + return ` (${table.row_count_estimated ? "~" : ""}${table.row_count} rows)` +} + +function sampleCell(value: unknown): string { + const text = + value === null || value === undefined + ? "NULL" + : value instanceof Date + ? value.toISOString() + : typeof value === "object" + ? (JSON.stringify(value, (_key, item) => (typeof item === "bigint" ? item.toString() : item)) ?? String(value)) + : String(value) + const flat = text.replace(/\s+/g, " ") + return flat.length > SAMPLE_CELL_CHARS ? `${flat.slice(0, SAMPLE_CELL_CHARS)}…` : flat +} + +const quoteIdent = (id: string) => `"${id.replace(/"/g, '""')}"` + +/** Renders local connections' tables; returns "" when nothing is discoverable. */ +export async function discoverCatalog(warehouses: { name: string; type: string }[], context: DiscoveryContext): Promise { + const deadline = Date.now() + DEADLINE_MS + const budget = { tables: TABLE_BUDGET, columns: COLUMN_BUDGET, samples: SAMPLE_BUDGET } + const lines: string[] = [] + let bytes = 0 + let full = false + let skipped = 0 + const push = (line: string) => { + const size = Buffer.byteLength(line, "utf8") + 1 + if (full || bytes + size > OUTPUT_BYTES) { + full = true + return false + } + lines.push(line) + bytes += size + return true + } + const timeLeft = () => deadline - Date.now() + let remote = 0 + for (const warehouse of warehouses.slice(0, MAX_CONNECTIONS)) { + if (isScratch(warehouse.name)) continue + const config = Registry.getConfig(warehouse.name) + if (!isLocalConnection(config)) { + remote++ + continue + } + if (full || timeLeft() <= 0) { + skipped++ + continue + } + context.signal.throwIfAborted() + const type = Registry.canonicalType(config?.type) ?? "" + try { + const tables = await listTables(warehouse.name, type, budget, context, timeLeft) + if (!tables) { + push(`${warehouse.name}: catalog too large to list here; use schema_inspect.`) + continue + } + if (!tables.length) { + push(`${warehouse.name}: no tables.`) + continue + } + for (const table of tables) { + if (full) break + let line = `${warehouse.name}: ${table.schema}.${table.name}${rowsLabel(table)}` + let names: string[] | undefined + if (budget.columns > 0 && timeLeft() > 0) { + budget.columns-- + context.signal.throwIfAborted() + const inspected = await Dispatcher.call("schema.inspect", { + table: table.name, + schema_name: table.schema, + warehouse: warehouse.name, + }).catch(() => { + context.signal.throwIfAborted() + return undefined + }) + const columns = inspected?.columns ?? [] + if (columns.length) { + names = columns.map((column) => column.name) + const shown = columns.slice(0, MAX_COLUMNS_SHOWN).map((column) => `${column.name} ${column.data_type}`) + line += `: ${shown.join(", ")}${columns.length > shown.length ? `, +${columns.length - shown.length} more` : ""}` + } + } + if (!push(line)) break + if (names && SAMPLED_ENGINES.has(type) && table.type === "table" && budget.samples > 0 && timeLeft() > 0) { + budget.samples-- + const sample = await sampleRow(warehouse.name, config!, table, names.length, context, timeLeft).catch(() => { + context.signal.throwIfAborted() + return undefined + }) + if (sample) push(` e.g. ${sample}`) + } + } + } catch { + context.signal.throwIfAborted() + push(`${warehouse.name}: catalog unavailable here; use schema_inspect.`) + } + } + const omitted = Math.max(0, warehouses.length - MAX_CONNECTIONS) + if (full || skipped || omitted || timeLeft() <= 0) { + lines.push("More tables or connections exist than shown; use schema_inspect for the rest.") + } + if (remote && lines.length) lines.push("Remote connections are not listed here; use schema_inspect.") + return lines.join("\n") +} + +/** The connection's tables in name order, or undefined when the catalog exceeds the remaining table budget. */ +async function listTables( + warehouse: string, + type: string, + budget: { tables: number }, + context: DiscoveryContext, + timeLeft: () => number, +): Promise { + const options = { signal: context.signal, sessionID: context.sessionID, forceReadOnly: true } + let schemas: string[] + if (type === "mongodb") { + // Database names come from one call; per-database counts would cost a command each. + const connector = await Registry.get(warehouse) + schemas = (await connector.listSchemas()).filter((name) => !MONGO_SYSTEM_DATABASES.has(name)).sort() + } else { + const listing = await Dispatcher.call("schema.list", { warehouse }, options) + const counted = listing.schemas ?? [] + if (listing.truncated || counted.some((schema) => schema.table_count === undefined)) return undefined + if (counted.reduce((total, schema) => total + schema.table_count!, 0) > budget.tables) return undefined + schemas = counted.filter((schema) => schema.table_count! > 0).map((schema) => schema.name) + } + if (schemas.length > MAX_SCHEMAS) return undefined + const tables: Table[] = [] + for (const schema of schemas) { + if (timeLeft() <= 0) return undefined + context.signal.throwIfAborted() + const listing = await Dispatcher.call("schema.list", { warehouse, schema_name: schema }, options) + if (listing.truncated || tables.length + listing.tables.length > budget.tables) return undefined + tables.push(...listing.tables) + } + budget.tables -= tables.length + return tables +} + +async function sampleRow( + warehouse: string, + config: ConnectionConfig, + table: Table, + width: number, + context: DiscoveryContext, + timeLeft: () => number, +): Promise { + const connector = await Registry.get(warehouse) + const timeout = Math.max(1, Math.min(SAMPLE_TIMEOUT_MS, timeLeft())) + const result = await executeQuery( + config, + { readOnly: true, queryTimeoutMs: timeout }, + { signal: context.signal, sessionID: context.sessionID, forceReadOnly: true }, + (options) => + connector.execute(`SELECT * FROM ${quoteIdent(table.schema)}.${quoteIdent(table.name)} LIMIT 1`, 1, undefined, options), + ) + const row = result.rows[0] + if (!row) return undefined + return row.slice(0, Math.min(width, MAX_COLUMNS_SHOWN)).map(sampleCell).join(" | ") +} diff --git a/packages/opencode/src/altimate/tools/warehouse-list.ts b/packages/opencode/src/altimate/tools/warehouse-list.ts index 71c3105e3a..aefc7048f2 100644 --- a/packages/opencode/src/altimate/tools/warehouse-list.ts +++ b/packages/opencode/src/altimate/tools/warehouse-list.ts @@ -5,6 +5,7 @@ import { Dispatcher } from "../native" // altimate_change start — workspace precedence import * as Precedence from "../workspace/precedence" // altimate_change end +import { discoverCatalog } from "./warehouse-discovery" export const WarehouseListTool = Tool.define("warehouse_list", { description: "List all configured warehouse connections. Shows connection name, type, and database.", @@ -30,6 +31,8 @@ export const WarehouseListTool = Tool.define("warehouse_list", { // re-linked project is not told its rows are served by the old workspace. const notes = await Precedence.warehouseListNotes(ctx.sessionID, warehouses) const shadowedCount = notes.size + // altimate_change start — MCP: one-call catalog discovery + const mcp = ctx.extra?.mcpClient === true const lines: string[] = shadowedCount ? ["Name | Type | Database | Served by", "-----|------|----------|----------"] @@ -44,6 +47,13 @@ export const WarehouseListTool = Tool.define("warehouse_list", { if (caveats.length) lines.push("", ...caveats) } + // Workspace-routed connections are served elsewhere; their local catalog is not the one queried. + if (mcp && !shadowedCount) { + const catalog = await discoverCatalog(warehouses, { signal: ctx.abort, sessionID: ctx.sessionID }) + if (catalog) lines.push("", "Tables (name: columns; e.g. = one sample row):", catalog) + } + // altimate_change end + return { title: `Warehouses: ${warehouses.length} configured`, metadata: { count: warehouses.length, shadowed: shadowedCount }, diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index f0dcac2776..28136ed45e 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -26,13 +26,19 @@ afterEach(async () => { Registry.reset() }) -async function fixture(allowWrite = false, capabilities: Record = {}, elicitTimeoutMs?: number) { +async function fixture( + allowWrite = false, + capabilities: Record = {}, + elicitTimeoutMs?: number, + setup?: (db: Database) => void, +) { const dir = await mkdtemp(path.join(os.tmpdir(), "mcp-tools-test-")) cleanups.push(() => rm(dir, { recursive: true, force: true })) const dbPath = path.join(dir, "source.sqlite") const db = new Database(dbPath) db.run("CREATE TABLE items (id INTEGER, name TEXT)") db.run("INSERT INTO items VALUES (1, 'apple'), (2, 'pear')") + setup?.(db) db.close() const config = path.join(dir, "connections.json") await Bun.write(config, JSON.stringify({ source: { type: "sqlite", path: "source.sqlite" } })) @@ -291,6 +297,9 @@ mcpTest("MCP forces database protection for every SQLite source query path", asy // altimate_change start — inventory counts also enforce database read-only ["schema_inspect", { warehouse: "source" }], // altimate_change end + // Discovery's counts and sample rows go through the same protection. + ["warehouse_list", {}], + ["sql_execute", { warehouse: "source", queries: [{ query: "SELECT * FROM items" }, { query: "SELECT 1 AS one" }] }], ["sql_check", { warehouse: "source", check: "nulls", sql: "SELECT * FROM items", columns: ["id"] }], ["sql_materialize", { source_warehouse: "source", sql: "SELECT * FROM items", target_table: "protected_copy" }], ] as const) { @@ -640,3 +649,168 @@ mcpTest("answer_commit without elicitation support or with one reading never ask expect((await plain.call("answer_commit", twoReadings)).isError).toBeFalsy() }) // altimate_change end + +// altimate_change start — batched sql_execute: one call, one labeled block per independent query +mcpTest("sql_execute queries runs every item and keeps going past a failing one", async () => { + const { call } = await fixture() + const result = await call("sql_execute", { + warehouse: "source", + queries: [ + { label: "count", query: "SELECT COUNT(*) AS n FROM items" }, + { label: "broken", query: "SELECT nope FROM missing_table" }, + { query: "SELECT name FROM items WHERE id = 2" }, + ], + }) + expect(result.isError).toBe(false) + const out = body(result) + const count = out.indexOf("[count]\n") + const broken = out.indexOf("[broken]\n") + const third = out.indexOf("[q3]\n") + expect(count).toBe(0) + expect(broken).toBeGreaterThan(count) + expect(third).toBeGreaterThan(broken) + expect(out.slice(count, broken)).toContain("2") + expect(out.slice(broken, third)).toContain("Failed to execute SQL") + expect(out.slice(third)).toContain("pear") +}) + +mcpTest("sql_execute queries refuses writes per item, even with --allow-write", async () => { + const { call } = await fixture(true) + const result = await call("sql_execute", { + warehouse: "source", + queries: [{ query: "INSERT INTO items VALUES (3, 'plum')" }, { query: "SELECT COUNT(*) AS n FROM items" }], + }) + expect(result.isError).toBe(false) + expect(body(result)).toContain("Batched queries must be read-only") + const rows = await call("sql_execute", { warehouse: "source", query: "SELECT COUNT(*) AS n FROM items" }) + expect(body(rows)).toMatch(/\b2\b/) + expect(body(rows)).not.toContain("plum") +}) + +mcpTest("sql_execute queries is an error only when every item fails", async () => { + const { call } = await fixture() + const result = await call("sql_execute", { + warehouse: "source", + queries: [{ query: "SELECT a FROM nowhere" }, { query: "SELECT b FROM nowhere_else" }], + }) + expect(result.isError).toBe(true) + expect(body(result)).toContain("[q1]") + expect(body(result)).toContain("[q2]") +}) + +mcpTest("sql_execute queries is exclusive with query and capped at eight items", async () => { + const { call } = await fixture() + const both = await call("sql_execute", { + warehouse: "source", + query: "SELECT 1", + queries: [{ query: "SELECT 2" }], + }) + expect(both.isError).toBe(true) + expect(body(both)).toContain("not both") + const many = await call("sql_execute", { + warehouse: "source", + queries: Array.from({ length: 9 }, (_, i) => ({ query: `SELECT ${i}` })), + }) + expect(many.isError).toBe(true) + expect(body(many)).toContain("at most 8") +}) + +mcpTest("sql_execute queries keeps the whole envelope inside one shared budget", async () => { + const { call } = await fixture(false, {}, undefined, (db) => { + db.run("CREATE TABLE wide (id INTEGER, payload TEXT)") + const insert = db.prepare("INSERT INTO wide VALUES (?, ?)") + for (let i = 0; i < 400; i++) insert.run(i, `${"é".repeat(60)}-${i}`) + }) + const label = "L".repeat(500) + const result = await call("sql_execute", { + warehouse: "source", + limit: 400, + queries: [ + { label, query: "SELECT * FROM wide" }, + { query: "SELECT * FROM wide ORDER BY id DESC" }, + { query: `SELECT '${"x".repeat(30_000)}' AS huge FROM nowhere` }, + ], + }) + const out = body(result) + expect(Buffer.byteLength((result.content as { type: string; text?: string }[])[0]!.text!, "utf8")).toBeLessThanOrEqual( + 24 * 1024, + ) + expect(out).toContain(`[${"L".repeat(60)}]`) + expect(out).not.toContain("L".repeat(61)) + expect(out).toContain("[q2]") + expect(out).toContain("[q3]") + // Both large results were saved; each gets its own resource link. + const links = (result.content as { type: string; uri?: string }[]).filter((item) => item.type === "resource_link") + expect(links).toHaveLength(2) + expect(new Set(links.map((link) => link.uri)).size).toBe(2) +}) +// altimate_change end + +// altimate_change start — warehouse_list discovery: local tables with columns and a sample in one call +mcpTest("warehouse_list lists local tables with columns and one sample row", async () => { + const { call } = await fixture(false, {}, undefined, (db) => { + db.run("CREATE VIEW item_names AS SELECT name FROM items") + }) + const out = body(await call("warehouse_list", {})) + expect(out).toContain("source: main.items (2 rows): id INTEGER, name TEXT") + expect(out).toMatch(/source: main\.items[^\n]*\n {2}e\.g\. 1 \| apple/) + // Views are listed but never sampled: a view may run arbitrary work. + expect(out).toMatch(/source: main\.item_names: name/) + expect(out).not.toMatch(/item_names[^\n]*\n {2}e\.g\./) +}) + +mcpTest("warehouse_list runs no discovery queries on remote connections", async () => { + const { call } = await fixture() + Registry.setConfigs({ remote: { type: "postgres", host: "db.example.com", database: "app" } }) + const original = Dispatcher.call + const methods: string[] = [] + const dispatch = spyOn(Dispatcher, "call").mockImplementation(async (method, args, context) => { + methods.push(method) + return original(method, args, context) + }) + const get = spyOn(Registry, "get") + try { + const out = body(await call("warehouse_list", {})) + expect(out).toContain("remote | postgres") + expect(out).not.toContain("remote:") + expect(methods.filter((method) => method !== "warehouse.list")).toEqual([]) + expect(get).not.toHaveBeenCalled() + } finally { + dispatch.mockRestore() + get.mockRestore() + } +}) + +mcpTest("warehouse_list skips table detail for a catalog over the table budget", async () => { + const { call } = await fixture(false, {}, undefined, (db) => { + for (let i = 0; i < 45; i++) db.run(`CREATE TABLE t${i} (id INTEGER)`) + }) + const out = body(await call("warehouse_list", {})) + expect(out).toContain("source: catalog too large to list here; use schema_inspect.") + expect(out).not.toContain("main.t1") +}) + +mcpTest("warehouse_list discovery stops starting calls once its deadline passes", async () => { + const { call } = await fixture(false, {}, undefined, (db) => { + for (let i = 0; i < 6; i++) db.run(`CREATE TABLE slow${i} (id INTEGER)`) + }) + const original = Dispatcher.call + let inspections = 0 + const dispatch = spyOn(Dispatcher, "call").mockImplementation(async (method, args, context) => { + if (method === "schema.inspect") { + inspections++ + await Bun.sleep(1_500) + } + return original(method, args, context) + }) + try { + const started = Date.now() + const out = body(await call("warehouse_list", {})) + expect(Date.now() - started).toBeLessThan(7_000) + expect(inspections).toBeLessThanOrEqual(3) + expect(out).toContain("More tables or connections exist than shown") + } finally { + dispatch.mockRestore() + } +}, 15_000) +// altimate_change end diff --git a/packages/opencode/test/altimate/tools/warehouse-discovery.test.ts b/packages/opencode/test/altimate/tools/warehouse-discovery.test.ts new file mode 100644 index 0000000000..739e8c652e --- /dev/null +++ b/packages/opencode/test/altimate/tools/warehouse-discovery.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import { isLocalConnection } from "../../../src/altimate/tools/warehouse-discovery" + +describe("isLocalConnection", () => { + test("local SQLite/DuckDB stores are local; remote store schemes are not", () => { + expect(isLocalConnection({ type: "sqlite", path: "/data/app.sqlite" })).toBe(true) + expect(isLocalConnection({ type: "duckdb", path: ":memory:" })).toBe(true) + expect(isLocalConnection({ type: "duckdb", path: "file:///data/w.duckdb" })).toBe(true) + expect(isLocalConnection({ type: "duckdb", path: "md:my_db" })).toBe(false) + expect(isLocalConnection({ type: "duckdb", path: "s3://bucket/w.duckdb" })).toBe(false) + expect(isLocalConnection({ type: "sqlite" })).toBe(false) + }) + + test("servers count as local only on a loopback host or a socket", () => { + expect(isLocalConnection({ type: "postgres", host: "localhost" })).toBe(true) + expect(isLocalConnection({ type: "postgresql", host: "127.0.0.1" })).toBe(true) + expect(isLocalConnection({ type: "postgres", host: "/var/run/postgresql" })).toBe(true) + expect(isLocalConnection({ type: "mongodb", connection_string: "mongodb://localhost:27017" })).toBe(true) + expect(isLocalConnection({ type: "postgres", host: "db.example.com" })).toBe(false) + expect(isLocalConnection({ type: "mongodb", connection_string: "mongodb+srv://cluster.example.net" })).toBe(false) + expect(isLocalConnection({ type: "postgres" })).toBe(false) + }) + + test("other engines are never local", () => { + expect(isLocalConnection({ type: "snowflake", host: "localhost" })).toBe(false) + expect(isLocalConnection({ type: "bigquery" })).toBe(false) + expect(isLocalConnection(undefined)).toBe(false) + }) +}) From 26af16993dbe23f0f45655e0e3c791214c7575ce Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 19 Oct 2026 12:41:58 -0700 Subject: [PATCH 23/39] fix(mcp): keep discovery notices inside its byte cap; forward `full_text_columns` to batched queries Review follow-ups for the batch and discovery commit. Co-Authored-By: Claude Opus 5.5 --- packages/opencode/src/altimate/tools/sql-execute.ts | 1 + packages/opencode/src/altimate/tools/warehouse-discovery.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/tools/sql-execute.ts b/packages/opencode/src/altimate/tools/sql-execute.ts index 81c4738de6..f98957361f 100644 --- a/packages/opencode/src/altimate/tools/sql-execute.ts +++ b/packages/opencode/src/altimate/tools/sql-execute.ts @@ -489,6 +489,7 @@ async function executeBatch( query: item.query, warehouse: item.warehouse?.trim() || args.warehouse, limit: args.limit, + full_text_columns: args.full_text_columns, query_timeout_ms: args.query_timeout_ms, }, budget, diff --git a/packages/opencode/src/altimate/tools/warehouse-discovery.ts b/packages/opencode/src/altimate/tools/warehouse-discovery.ts index 4f5413c21b..148456d9ab 100644 --- a/packages/opencode/src/altimate/tools/warehouse-discovery.ts +++ b/packages/opencode/src/altimate/tools/warehouse-discovery.ts @@ -22,6 +22,7 @@ const SAMPLE_BUDGET = 15 const MAX_COLUMNS_SHOWN = 40 const SAMPLE_CELL_CHARS = 40 const OUTPUT_BYTES = 8 * 1024 +const NOTICE_RESERVE_BYTES = 256 const DEADLINE_MS = 4_000 const SAMPLE_TIMEOUT_MS = 1_000 const MONGO_SYSTEM_DATABASES = new Set(["admin", "config", "local"]) @@ -88,7 +89,8 @@ export async function discoverCatalog(warehouses: { name: string; type: string } let skipped = 0 const push = (line: string) => { const size = Buffer.byteLength(line, "utf8") + 1 - if (full || bytes + size > OUTPUT_BYTES) { + // The two trailing notices are pushed unconditionally; keep room for them inside the cap. + if (full || bytes + size > OUTPUT_BYTES - NOTICE_RESERVE_BYTES) { full = true return false } From 1602aea3bc67941e03d64ab951bcede835405d2b Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 19 Oct 2026 12:41:59 -0700 Subject: [PATCH 24/39] feat(mcp): accept client-computed numbers in `answer_commit` Over MCP the server sees only its own tool output, so the one-time number-provenance rejection fired on correct values computed in the client's shell or code. MCP commits now skip it; the requested-items, reading and one-time conjunction checks and the answer-only FINAL ANSWER rendering are unchanged. Native sessions keep the number check. Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/mcp-server/adapter.ts | 2 +- .../src/altimate/tools/answer-commit.ts | 8 ++++++- .../test/altimate/mcp-server/server.test.ts | 21 +++++++++++++------ .../test/altimate/tools/answer-commit.test.ts | 16 ++++++++++++++ 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/altimate/mcp-server/adapter.ts b/packages/opencode/src/altimate/mcp-server/adapter.ts index 9f0c435312..0459135df1 100644 --- a/packages/opencode/src/altimate/mcp-server/adapter.ts +++ b/packages/opencode/src/altimate/mcp-server/adapter.ts @@ -29,7 +29,7 @@ export const descriptions: Record = { sql_check: "Run a read-only SQL data quality check against a connected warehouse.", sql_materialize: "Copy a read-only source query into a session-local scratch table for further analysis.", answer_commit: - "Finish a data question. Call this last, with one field (or rows column) per item the request asks for and `asked` listing those items in order; the call is rejected when an asked item has no value of its own or a number was not seen in query output. Reply with exactly the returned FINAL ANSWER text.", + "Finish a data question. Call this last, with one field (or rows column) per item the request asks for and `asked` listing those items in order; the call is rejected when an asked item has no value of its own. Numbers may come from SQL or from your own computation. Reply with exactly the returned FINAL ANSWER text.", } // The native tool caps `basis` at 400 characters; over the MCP boundary the text is trimmed instead of rejected. diff --git a/packages/opencode/src/altimate/tools/answer-commit.ts b/packages/opencode/src/altimate/tools/answer-commit.ts index 08131aa655..b266afcdab 100644 --- a/packages/opencode/src/altimate/tools/answer-commit.ts +++ b/packages/opencode/src/altimate/tools/answer-commit.ts @@ -324,6 +324,7 @@ type CommitMetadata = { function commitRejection( args: z.output, messages: Tool.Context["messages"], + checkNumbers: boolean, ): string | undefined { if (args.unresolved) return undefined const missing = missingAskedItems(args) @@ -334,6 +335,7 @@ function commitRejection( if (sizeBased.length && priorRejections(messages, READING_REJECTION_MARKER) < MAX_READING_REJECTIONS) return readingRejection(sizeBased) // altimate_change end + if (!checkNumbers) return undefined if (priorRejections(messages, NUMBER_REJECTION_MARKER) >= MAX_NUMBER_REJECTIONS) return undefined const seen = sessionNumbers(messages) const unsourced = seen ? unsourcedNumbers(args, seen) : [] @@ -366,8 +368,12 @@ export const AnswerCommitTool = Tool.define( yield* ctx.ask({ permission: "answer_commit", patterns: ["*"], always: ["*"], metadata: {} }) const output = renderAnswer(args) // altimate_change start — one-time conjunction review of under-asked commits + // An MCP server sees only its own tool output, never the numbers a client computed in its own + // shell or code, so the number-provenance check would reject correct derived values there. + const checkNumbers = ctx.extra?.mcpClient !== true const rejection = - commitRejection(args, ctx.messages ?? []) ?? conjunctionRejection(args, ctx.messages ?? [], ctx.sessionID) + commitRejection(args, ctx.messages ?? [], checkNumbers) ?? + conjunctionRejection(args, ctx.messages ?? [], ctx.sessionID) // altimate_change end if (rejection) { const rejected: Tool.ExecuteResult = { diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index 28136ed45e..71bcea231c 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -497,14 +497,23 @@ mcpTest("answer_commit rejects a missing asked item at most twice, then accepts" expect((await call("answer_commit", args)).isError).toBeFalsy() }) -mcpTest("answer_commit warns once about a number absent from query output", async () => { +// The server cannot see numbers the client computed in its own shell or code, so over MCP a number +// absent from this server's query output is accepted on the first commit (native sessions still check). +mcpTest("answer_commit accepts a client-computed number absent from query output on the first commit", async () => { const { call } = await fixture() await call("sql_execute", { warehouse: "source", query: "SELECT COUNT(*) AS n FROM items" }) - const args = commit([{ name: "total", value: 98765 }], ["total"]) - const warned = await call("answer_commit", args) - expect(warned.isError).toBe(true) - expect(text(warned)).toContain("number check") - expect((await call("answer_commit", args)).isError).toBeFalsy() + const accepted = await call("answer_commit", commit([{ name: "total", value: 98765 }], ["total"])) + expect(accepted.isError).toBeFalsy() + expect(text(accepted)).not.toContain("number check") + expect(text(accepted)).toContain("98765") +}) + +mcpTest("answer_commit over MCP still runs the requested-items check before accepting a computed number", async () => { + const { call } = await fixture() + await call("sql_execute", { warehouse: "source", query: "SELECT COUNT(*) AS n FROM items" }) + const rejected = await call("answer_commit", commit([{ name: "total", value: 98765 }], ["total", "item"])) + expect(rejected.isError).toBe(true) + expect(text(rejected)).toContain("requested-items check") }) mcpTest("answer_commit accepts a number that appeared in query output", async () => { diff --git a/packages/opencode/test/altimate/tools/answer-commit.test.ts b/packages/opencode/test/altimate/tools/answer-commit.test.ts index b9c946d904..6bcffbeaa8 100644 --- a/packages/opencode/test/altimate/tools/answer-commit.test.ts +++ b/packages/opencode/test/altimate/tools/answer-commit.test.ts @@ -1357,6 +1357,22 @@ describe("answer_commit commit-time checks", () => { expect(accepted.output).toBe("81") }) + test("an MCP client's computed number is accepted without the number check", async () => { + const history = [user("Fastest average cycle?"), assistant(sql("owner | days\nA | 79\nB | 84\nC | 95"))] + const payload = { basis: recordBasis, answer_only: true, fields: [{ name: "days", value: 81 }] } + const tool = await initTool(AnswerCommit.AnswerCommitTool) + const accepted = await tool.execute(payload, { ...context, messages: history, extra: { mcpClient: true } }) + expect(accepted.status).toBeUndefined() + expect(accepted.output).toBe("81") + // The requested-items check still applies over MCP. + const missing = await tool.execute( + { ...payload, asked: ["days", "owner"] }, + { ...context, messages: history, extra: { mcpClient: true } }, + ) + expect(missing.status).toBe("error") + expect(missing.output).toContain("answer_commit requested-items check:") + }) + test.each([ ["rounded", 0.16, "ratio\n0.1621"], ["truncated", 12.3, "avg 12.39"], From 3f7402897355a839bb05b64898e4e7f88317d2d6 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 19 Oct 2026 12:48:13 -0700 Subject: [PATCH 25/39] feat(mcp): advertise compact tool schemas and shorter instructions Tool definitions are re-sent with every model request. The adapter now collapses optional-property filler unions, drops keys the parsers already enforce and uses short property descriptions; parsing is unchanged. Declarations total 5,757 chars (was 12,012), `answer_commit` 1,988 (was 5,755), instructions 681 (was 723). Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/mcp-server/adapter.ts | 158 +++++++++++++++++- .../src/altimate/mcp-server/server.ts | 11 +- .../mcp-server/compact-schema.test.ts | 44 +++++ .../test/altimate/mcp-server/server.test.ts | 46 +++++ 4 files changed, 246 insertions(+), 13 deletions(-) create mode 100644 packages/opencode/test/altimate/mcp-server/compact-schema.test.ts diff --git a/packages/opencode/src/altimate/mcp-server/adapter.ts b/packages/opencode/src/altimate/mcp-server/adapter.ts index 0459135df1..ccd8fd50d8 100644 --- a/packages/opencode/src/altimate/mcp-server/adapter.ts +++ b/packages/opencode/src/altimate/mcp-server/adapter.ts @@ -21,15 +21,158 @@ import { closeScratch } from "../native/connections/scratch" import type { SessionID, MessageID } from "@/session/schema" export const descriptions: Record = { - warehouse_list: - "List warehouse connections; for local databases also their tables, columns and a sample row (small catalogs).", - schema_inspect: "List tables/collections when table is omitted or empty; otherwise inspect one table, with optional sample values.", + warehouse_list: "List connections; for local databases also tables, columns and a sample row.", + schema_inspect: "List tables when table is omitted; otherwise one table's columns and value profile.", sql_execute: - "Execute SQL against a connected warehouse and return rows. Run independent queries together in one call with `queries`. Source writes require the server's --allow-write option.", - sql_check: "Run a read-only SQL data quality check against a connected warehouse.", - sql_materialize: "Copy a read-only source query into a session-local scratch table for further analysis.", + "Run SQL and return rows. Put independent queries in one call with `queries`. Source writes need --allow-write.", + sql_check: "Read-only data check: fanout, keys, grain, nulls or coverage.", + sql_materialize: "Store a read-only query's rows as a scratch table, to join across connections.", answer_commit: - "Finish a data question. Call this last, with one field (or rows column) per item the request asks for and `asked` listing those items in order; the call is rejected when an asked item has no value of its own. Numbers may come from SQL or from your own computation. Reply with exactly the returned FINAL ANSWER text.", + "Finish a data question; call last, then reply with exactly its FINAL ANSWER text. Use one of fields, rows or unresolved, with a field (or rows column) per asked item, named after it. Numbers may be your own computation; exact decimals as strings.", +} + +/** + * Short MCP property descriptions, keyed by property path (`a.b` nests, `a[]` is an array's items). + * Every tool definition is re-sent on each model request, so the advertised schema keeps only what a + * caller needs to choose and fill a property; `null` drops the description, `DROP` the property. + */ +const DROP = Symbol("drop") +const PROPERTY_DESCRIPTIONS: Record> = { + schema_inspect: { + table: "Table, optionally schema-qualified; omit to list tables", + schema_name: null, + warehouse: null, + values: "Include the value profile (default true)", + }, + sql_execute: { + query: "SQL, or a MongoDB JSON command", + queries: "Up to 8 independent read-only queries; each gets its own labeled result", + "queries[].label": "Default q1, q2, ...", + "queries[].query": null, + "queries[].warehouse": "Default: warehouse", + query_from: "Run the SQL string this read-only SELECT returns, without copying it", + "query_from.warehouse": null, + "query_from.sql": "SELECT returning one row with one SQL string column", + warehouse: null, + limit: "Max rows (default 100, max 10000)", + full_text_columns: "Columns whose full text to prioritize (at most 20 rows; all rows are saved)", + query_timeout_ms: null, + }, + sql_check: { + sql: "SELECT for grain, nulls or coverage", + columns: "Columns for grain or nulls", + column: "Column for coverage", + pattern: "Regex (SQLite: LIKE pattern)", + left_sql: "SELECT for fanout or keys", + right_sql: "SELECT for fanout or keys", + on: "fanout: [left, right] column pairs", + join: null, + left_col: "keys: left column", + right_col: "keys: right column", + left_warehouse: DROP, + right_warehouse: DROP, + query_timeout_ms: null, + }, + sql_materialize: { + source_warehouse: "Warehouse to read, or scratch", + sql: "Read-only SELECT; every returned row is stored", + target_table: "New scratch table: letters, digits, underscores", + mode: "create (default), replace or append", + max_rows: "Row cap (default 2000000)", + max_bytes: null, + query_timeout_ms: null, + }, + answer_commit: { + fields: null, + "fields[].name": null, + // Units can be part of a string value; the field stays accepted. + "fields[].units": DROP, + "fields[].precision": "Decimals", + asked: "Requested items, in order", + "rows.format": "lines: no header", + "rows.values": "N rows if N is asked, even with ties", + "rows.precision": "Decimals by column", + "rows.note": "Tie or caveat", + basis: "1-2 sentences: population, grain, source column, caveats", + answer_only: "Reply is only the value(s)/table", + readings: "If two readings fit: the chosen one; decided_by: the rejected one and why", + request: "The question, verbatim", + }, +} + +type JsonNode = Record +const isNode = (value: unknown): value is JsonNode => typeof value === "object" && value !== null && !Array.isArray(value) + +/** Strict-schema fillers the parsers accept for unused optional properties: null, "", [] and {}. */ +function isFiller(node: unknown): boolean { + if (!isNode(node)) return false + if (node.type === "null") return true + if (node.const === "" && node.type === "string") return true + if (node.type === "array" && node.maxItems === 0) return true + return node.type === "object" && isNode(node.properties) && Object.keys(node.properties).length === 0 && node.additionalProperties === false +} + +/** + * Collapses an optional property's filler union to its value schema (omitting the property means the + * same to the parsers) and drops keys that only restate what the parsers enforce. Unions elsewhere, + * such as nullable table cells, keep every member. + */ +function collapse(node: unknown, property = false): unknown { + if (Array.isArray(node)) return node.map((item) => collapse(item)) + if (!isNode(node)) return node + let copy: JsonNode = { ...node } + if (property && Array.isArray(copy.anyOf)) { + const kept = copy.anyOf.filter((member) => !isFiller(member)) + if (kept.length === 1 && isNode(kept[0])) { + const { anyOf: _, ...rest } = copy + copy = { ...(kept[0] as JsonNode), ...rest } + } else copy.anyOf = kept + } + for (const key of ["$schema", "default", "propertyNames", "minItems", "additionalProperties"]) { + if (key !== "additionalProperties" || copy[key] === false) delete copy[key] + } + if (copy.maximum === Number.MAX_SAFE_INTEGER) delete copy.maximum + for (const [key, value] of Object.entries(copy)) { + if (key === "properties" && isNode(value)) { + copy.properties = Object.fromEntries(Object.entries(value).map(([name, child]) => [name, collapse(child, true)])) + } else if (key === "items" || key === "prefixItems" || key === "anyOf" || (key === "additionalProperties" && isNode(value))) { + copy[key] = collapse(value) + } + } + return copy +} + +/** The property schema at a description path, or undefined when the path does not exist. */ +function propertyAt(schema: JsonNode, pathText: string): { owner: JsonNode; parent: JsonNode; name: string } | undefined { + let node: unknown = schema + const segments = pathText.split(".") + for (const [index, segment] of segments.entries()) { + const array = segment.endsWith("[]") + const name = array ? segment.slice(0, -2) : segment + const properties = isNode(node) ? node.properties : undefined + if (!isNode(node) || !isNode(properties) || !isNode(properties[name])) return undefined + if (index === segments.length - 1 && !array) return { owner: node, parent: properties, name } + node = array ? (properties[name] as JsonNode).items : properties[name] + } + return undefined +} + +/** The advertised MCP input schema: fillers collapsed and descriptions shortened. Parsing is unchanged. */ +export function compactSchema(name: string, schema: T): T { + const copy = collapse(schema) as JsonNode + for (const [pathText, description] of Object.entries(PROPERTY_DESCRIPTIONS[name] ?? {})) { + const target = propertyAt(copy, pathText) + if (!target) throw new Error(`MCP schema for ${name} has no property ${pathText}`) + const property = target.parent[target.name] as JsonNode + if (description === DROP) { + // Only optional properties are dropped; a required one would make every call fail. + if (Array.isArray(target.owner.required) && target.owner.required.includes(target.name)) + throw new Error(`MCP schema for ${name} cannot drop required property ${pathText}`) + delete target.parent[target.name] + } else if (description === null) delete property.description + else property.description = description + } + return copy as T } // The native tool caps `basis` at 400 characters; over the MCP boundary the text is trimmed instead of rejected. @@ -145,6 +288,7 @@ export async function createAdapter(options: { allowWrite?: boolean } = {}) { ) } if (tool.jsonSchema && info.id === "answer_commit") tool.jsonSchema = withRequest(relaxBasis(tool.jsonSchema)) + if (tool.jsonSchema) tool.jsonSchema = compactSchema(info.id, tool.jsonSchema) result.set(info.id, tool) } return result diff --git a/packages/opencode/src/altimate/mcp-server/server.ts b/packages/opencode/src/altimate/mcp-server/server.ts index 0e4289f782..5034ec106a 100644 --- a/packages/opencode/src/altimate/mcp-server/server.ts +++ b/packages/opencode/src/altimate/mcp-server/server.ts @@ -16,13 +16,12 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version" export const SERVER_INSTRUCTIONS = [ "Analyst method for data questions:", - "1. Start with warehouse_list; it also shows local tables with columns and a sample row.", - "2. Read schema_inspect's value profile (ranked values, distinct/null counts, unique flag, value shapes) before choosing columns or join keys; formats often differ across databases.", + "1. Start with warehouse_list; it shows local tables, columns and a sample row.", + "2. Check stored value formats (schema_inspect value profile) before filtering or joining on a column; formats differ across databases.", "3. Compute in the database; batch independent queries in one sql_execute call (`queries`).", - "4. For joins across connections, sql_materialize the smaller filtered side into `scratch` and join there.", - "5. Run sql_check when keys, fan-out, grain or nulls could change the answer.", - "6. Check that every condition in the question actually filters something.", - "7. Finish with answer_commit: `asked` lists the requested items, one field per item; reply with exactly the FINAL ANSWER text it returns.", + "4. Cross-connection joins: sql_materialize the smaller filtered side into `scratch` and join there.", + "5. Use sql_check when keys, fan-out, grain or nulls could change the answer; make sure every condition filters something.", + "6. Finish with answer_commit (`asked` = the requested items, one field each); reply with exactly its FINAL ANSWER text.", ].join("\n") /** Spilled results: a resource_link next to the text, which keeps the preview, row count and path. */ diff --git a/packages/opencode/test/altimate/mcp-server/compact-schema.test.ts b/packages/opencode/test/altimate/mcp-server/compact-schema.test.ts new file mode 100644 index 0000000000..001c92f31c --- /dev/null +++ b/packages/opencode/test/altimate/mcp-server/compact-schema.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from "bun:test" +import { compactSchema } from "../../../src/altimate/mcp-server/adapter" + +const fillers = [ + { type: "null" }, + { type: "string", const: "" }, + { minItems: 0, maxItems: 0, type: "array", items: { not: {} } }, + { type: "object", properties: {}, additionalProperties: false }, +] + +test("collapses optional-property filler unions but keeps nullable array members", () => { + const schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + note: { description: "kept", anyOf: [{ type: "string" }, ...fillers] }, + cells: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }] } }, + size: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, + }, + required: ["cells"], + additionalProperties: false, + } + expect(compactSchema("none", schema) as unknown).toEqual({ + type: "object", + properties: { + note: { type: "string", description: "kept" }, + cells: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }] } }, + size: { type: "integer", minimum: 0 }, + }, + required: ["cells"], + }) + // The input is not mutated. + expect(schema.properties.note.anyOf).toHaveLength(5) +}) + +test("a description table naming a missing property or a required one to drop fails loudly", () => { + const schema = { + type: "object", + properties: { warehouse: { type: "string" }, check: { type: "string" } }, + required: ["warehouse", "check"], + } + // sql_check's table lists properties this schema lacks. + expect(() => compactSchema("sql_check", schema)).toThrow(/has no property/) +}) diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index 71bcea231c..da4929e8af 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -755,6 +755,52 @@ mcpTest("sql_execute queries keeps the whole envelope inside one shared budget", }) // altimate_change end +// altimate_change start — compact tool definitions: re-sent with every model request +mcpTest("tool definitions stay compact and keep their required properties", async () => { + const { client } = await fixture() + const { tools } = await client.listTools() + const size = (tool: (typeof tools)[number]) => + JSON.stringify({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema }).length + const byName = Object.fromEntries(tools.map((tool) => [tool.name, tool])) + expect(size(byName.answer_commit!)).toBeLessThanOrEqual(2_000) + expect(tools.reduce((total, tool) => total + size(tool), 0)).toBeLessThan(6_000) + expect(client.getInstructions()!.length).toBeLessThanOrEqual(723) + const declared = JSON.stringify(tools) + expect(declared).not.toContain("$schema") + expect(declared).not.toContain('"const":""') + expect(byName.answer_commit!.inputSchema.required).toEqual(["basis", "request"]) + expect(byName.sql_check!.inputSchema.required).toEqual(["warehouse", "check"]) + expect(byName.sql_materialize!.inputSchema.required).toEqual(["source_warehouse", "sql", "target_table"]) + // A nullable table cell keeps its null member; only optional-property fillers collapse. + expect(JSON.stringify(byName.answer_commit!.inputSchema.properties!.rows)).toContain('{"type":"null"}') +}) + +mcpTest("strict-schema filler values are still accepted after the schema is compacted", async () => { + const { call } = await fixture() + const executed = await call("sql_execute", { + warehouse: "source", + query: "SELECT name FROM items WHERE id = 1", + queries: null, + query_from: null, + full_text_columns: [], + }) + expect(executed.isError).toBe(false) + expect(body(executed)).toContain("apple") + const committed = await call("answer_commit", { + fields: [{ name: "", value: "apple", units: "", precision: null }], + rows: null, + unresolved: {}, + asked: [], + readings: [], + answer_only: "", + basis: "Read items.name for id 1.", + request: "Which item has id 1?", + }) + expect(committed.isError).toBeFalsy() + expect(body(committed)).toContain("FINAL ANSWER: apple") +}) +// altimate_change end + // altimate_change start — warehouse_list discovery: local tables with columns and a sample in one call mcpTest("warehouse_list lists local tables with columns and one sample row", async () => { const { call } = await fixture(false, {}, undefined, (db) => { From 303265bca57a4f0b2dc42f93191ab4a84be4f55b Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 19 Oct 2026 12:55:55 -0700 Subject: [PATCH 26/39] fix(mcp): keep `sql_check.join` values and the `query_from` target rule in the compact schema Review follow-up: the short descriptions dropped the only allowed-value guidance for `join` and the requirement that `query_from` needs a top-level `warehouse`. Co-Authored-By: Claude Opus 5.5 --- packages/opencode/src/altimate/mcp-server/adapter.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/altimate/mcp-server/adapter.ts b/packages/opencode/src/altimate/mcp-server/adapter.ts index ccd8fd50d8..47581f65dc 100644 --- a/packages/opencode/src/altimate/mcp-server/adapter.ts +++ b/packages/opencode/src/altimate/mcp-server/adapter.ts @@ -50,7 +50,7 @@ const PROPERTY_DESCRIPTIONS: Record Date: Mon, 19 Oct 2026 12:55:55 -0700 Subject: [PATCH 27/39] feat(mcp): store spilled `sql_execute` rows as a scratch table and show a short preview Over MCP a result too large to show inline now becomes scratch table `result_N` (typed like `sql_materialize`, sequential names), with a 10-row preview, 160-byte cells and a `SELECT ... FROM result_N` hint instead of a file to read whole. The file stays as a copy and the fallback. Native output is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/tools/sql-execute.ts | 33 ++++++++ .../src/altimate/tools/sql-materialize.ts | 79 ++++++++++++++----- .../src/altimate/tools/sql-result-format.ts | 70 +++++++++++----- .../test/altimate/mcp-server/server.test.ts | 68 +++++++++++++++- 4 files changed, 208 insertions(+), 42 deletions(-) diff --git a/packages/opencode/src/altimate/tools/sql-execute.ts b/packages/opencode/src/altimate/tools/sql-execute.ts index f98957361f..ecff642d06 100644 --- a/packages/opencode/src/altimate/tools/sql-execute.ts +++ b/packages/opencode/src/altimate/tools/sql-execute.ts @@ -35,6 +35,7 @@ import * as Precedence from "../workspace/precedence" import { normalizeError } from "./response-normalization" // altimate_change end import { readSql } from "./sql-check" +import { MaterializeError, storeRows } from "./sql-materialize" const MAX_SQL_ROWS = 10_000 const LIMIT_ERROR = "limit must be a finite positive integer" @@ -408,6 +409,10 @@ export const SqlExecuteTool = Tool.define( maxBytes: routingClipped ? 0 : maxBytes - routingBytes, maxLines: limits.maxLines - routingLines, write: (text) => bridge.promise(truncate.write(text)), + store: + ctx.extra?.mcpClient === true + ? () => storeSpill(result, { signal: ctx.abort, sessionID: ctx.sessionID }) + : undefined, }) // altimate_change start — progressive disclosure suggestions if (suggestion && formatted.output.includes(suggestion)) { @@ -452,6 +457,34 @@ export const SqlExecuteTool = Tool.define( }), ) +/** Next spill table number per session; names are sequential, so outputs stay reproducible. */ +const spillCounters = new Map() +const MAX_SPILL_SESSIONS = 1_000 +const SPILL_NAME_ATTEMPTS = 5 + +/** + * Stores a spilled result's returned rows as scratch table result_N, so the client queries them + * instead of reading the file. Undefined when storing fails; the file remains the fallback. + */ +async function storeSpill(result: SqlExecuteResult, context: { signal: AbortSignal; sessionID: string }) { + for (let attempt = 0; attempt < SPILL_NAME_ATTEMPTS; attempt++) { + const next = (spillCounters.get(context.sessionID) ?? 0) + 1 + spillCounters.delete(context.sessionID) + spillCounters.set(context.sessionID, next) + if (spillCounters.size > MAX_SPILL_SESSIONS) spillCounters.delete(spillCounters.keys().next().value!) + const table = `result_${next}` + try { + await storeRows(table, result, context) + return table + } catch (error) { + context.signal.throwIfAborted() + // A table the client created under this name: try the next number. + if (!(error instanceof MaterializeError && error.message.includes("already exists"))) return undefined + } + } + return undefined +} + type BatchItem = { label?: string | null; query: string; warehouse?: string | null } /** diff --git a/packages/opencode/src/altimate/tools/sql-materialize.ts b/packages/opencode/src/altimate/tools/sql-materialize.ts index 698a9d3674..3470b6705a 100644 --- a/packages/opencode/src/altimate/tools/sql-materialize.ts +++ b/packages/opencode/src/altimate/tools/sql-materialize.ts @@ -315,34 +315,73 @@ async function materializeWithinDeadline(input: MaterializeInput, context: Query } } - // Type inference (the drivers expose no column metadata, only names, so the type comes from the values: - // integers stay BIGINT, any fractional value widens to DOUBLE, mixed kinds fall back to VARCHAR, an - // all-NULL column is VARCHAR). - const types: ScratchType[] = [] - for (let c = 0; c < names.length; c++) { - let t: ScratchType | null = null - for (const row of result.rows) { - const v = row[c] - if (t !== "VARCHAR") { - const k = kindOf(v) - if (k !== null) t = widen(t, k === "JSON" ? "VARCHAR" : k) - } - } - types.push(t ?? "VARCHAR") - } - return { result, names, types } + return { result, names, types: inferTypes(names.length, result.rows) } } // Remote sources are read and buffered before taking the destination's transaction lock. A scratch // source is read inside the lock so a derivation sees and replaces consistent state. // The buffer reservation spans the destination write too: the buffered rows stay live until then. - const writeToScratch = async (prepared?: Awaited>) => { + const writeToScratch = (prepared?: Prepared) => + writeRows({ table, mode, query_timeout_ms: input.query_timeout_ms }, context, async (scratch) => prepared ?? prepare(scratch)) + if (isScratch(input.source_warehouse)) return writeToScratch() + return withBufferBudget(maxBytes, async () => writeToScratch(await prepare(source))) +} + +/** + * Type inference: the drivers expose no column metadata, only names, so the type comes from the values: + * integers stay BIGINT, any fractional value widens to DOUBLE, mixed kinds fall back to VARCHAR, an + * all-NULL column is VARCHAR. + */ +function inferTypes(width: number, rows: unknown[][]): ScratchType[] { + const types: ScratchType[] = [] + for (let c = 0; c < width; c++) { + let t: ScratchType | null = null + for (const row of rows) { + const v = row[c] + if (t !== "VARCHAR") { + const k = kindOf(v) + if (k !== null) t = widen(t, k === "JSON" ? "VARCHAR" : k) + } + } + types.push(t ?? "VARCHAR") + } + return types +} + +type Prepared = { result: { rows: unknown[][] }; names: string[]; types: ScratchType[] } + +/** + * Store rows already in memory (a query result) as a new scratch table, typed the same way as + * sql_materialize. Fails if the table exists. + */ +export async function storeRows( + table: string, + result: { columns: string[]; rows: unknown[][] }, + context: QueryContext, +): Promise { + if (!IDENTIFIER.test(table)) throw new MaterializeError(`Invalid scratch table name "${table}".`) + await Registry.get(SCRATCH_NAME) + const names = uniqueColumnNames(result.columns) + return writeRows({ table, mode: "create" }, context, async () => ({ + result, + names, + types: inferTypes(names.length, result.rows), + })) +} + +async function writeRows( + target: { table: string; mode: "create" | "replace" | "append"; query_timeout_ms?: number }, + context: QueryContext, + load: (scratch: Connector) => Promise, +): Promise { + const { table, mode } = target + { const warnings: string[] = [] return withScratchConnection(async (scratch, directory) => { - const { result, names, types } = prepared ?? (await prepare(scratch)) + const { result, names, types } = await load(scratch) const run = (sql: string) => executeQuery( Registry.getConfig(SCRATCH_NAME), - { noLimit: true, queryTimeoutMs: input.query_timeout_ms }, + { noLimit: true, queryTimeoutMs: target.query_timeout_ms }, context, (options) => scratch.execute(sql, undefined, undefined, options), ) @@ -439,8 +478,6 @@ async function materializeWithinDeadline(input: MaterializeInput, context: Query } }) } - if (isScratch(input.source_warehouse)) return writeToScratch() - return withBufferBudget(maxBytes, async () => writeToScratch(await prepare(source))) } export const SqlMaterializeTool = Tool.define("sql_materialize", { diff --git a/packages/opencode/src/altimate/tools/sql-result-format.ts b/packages/opencode/src/altimate/tools/sql-result-format.ts index 0f9b544caa..dc3c239ded 100644 --- a/packages/opencode/src/altimate/tools/sql-result-format.ts +++ b/packages/opencode/src/altimate/tools/sql-result-format.ts @@ -15,8 +15,14 @@ interface FormatOptions { /** The executed statement; enables the empty-result hint. */ sql?: string write: (text: string) => Promise + /** MCP: called when the result spills; stores every returned row as a scratch table and returns its name. */ + store?: () => Promise } +/** MCP spills show a short preview; the full rows are queryable in scratch. */ +const MCP_SPILL_PREVIEW_ROWS = 10 +const MCP_SPILL_CELL_BYTES = 160 + function lineCount(text: string): number { let lines = 1 for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) lines++ @@ -158,17 +164,25 @@ function displayCell(value: unknown, ancestors = new Set()): { text: st return { text: value === null ? "NULL" : String(value), clipped: false } } -function previewRows(rows: unknown[][], maxBytes: number, maxLines: number, columns: number) { +function previewRows( + rows: unknown[][], + maxBytes: number, + maxLines: number, + columns: number, + maxRows = SQL_MAX_PREVIEW_ROWS, + cellCap?: number, +) { if (!columns) return { body: "", rows: 0, clipped: rows.length > 0 } const shown: string[] = [] let bytes = 0 let lines = 0 let clipped = false - for (const row of rows.slice(0, SQL_MAX_PREVIEW_ROWS)) { + for (const row of rows.slice(0, maxRows)) { const values = row.slice(0, columns).map((v) => { const cell = displayCell(v) - clipped ||= cell.clipped - return cell.text + const text = cellCap === undefined ? cell.text : clipCell(cell.text, cellCap, 1)! + clipped ||= cell.clipped || text !== cell.text + return text }) const remainingBytes = maxBytes - bytes - (shown.length ? 1 : 0) const remainingLines = maxLines - lines @@ -467,6 +481,7 @@ export async function formatSqlResult(result: SqlExecuteResult, options: FormatO row_limited: result.truncated || excess, output_truncated: false, result_path: undefined as string | undefined, + scratch_table: undefined as string | undefined, // This tool owns clipping; the generic truncator must not clip away the envelope. truncated: false, } @@ -481,7 +496,7 @@ export async function formatSqlResult(result: SqlExecuteResult, options: FormatO return { output: bounded(withHint(footer + note)), metadata } } - const rowsFor = (bytes: number, lines: number, indices: number[]) => + const rowsFor = (bytes: number, lines: number, indices: number[], maxRows?: number, cellBytes?: number) => fullText ? fullTextRows( rows.map((row) => indices.map((index) => (index in row ? row[index] : null))), @@ -490,7 +505,7 @@ export async function formatSqlResult(result: SqlExecuteResult, options: FormatO bytes, lines, ) - : previewRows(rows, bytes, lines, indices.length) + : previewRows(rows, bytes, lines, indices.length, maxRows, cellBytes) // Ordinary, complete results retain their compact presentation. Full-text mode // always takes the spill path, even for empty results or unknown selected names. if (!fullText) { @@ -517,34 +532,49 @@ export async function formatSqlResult(result: SqlExecuteResult, options: FormatO } let spill: string + // MCP: a short preview, with every returned row queryable as a scratch table instead of a file to read. + const mcp = options.mcpClient === true + const previewLimit = mcp && !fullText ? MCP_SPILL_PREVIEW_ROWS : SQL_MAX_PREVIEW_ROWS try { const chunks: string[] = [] serializeRows(result, (chunk) => chunks.push(chunk)) metadata.result_path = await options.write(chunks.join("")) - const reader = options.mcpClient + const table = mcp && result.rows.length ? await options.store?.().catch(() => undefined) : undefined + metadata.scratch_table = table + const reader = mcp ? "Open this path with any file reader to recover clipped text." : "Use read with char_offset/max_chars to recover any clipped text." - spill = `All ${result.rows.length} returned rows saved to ${metadata.result_path}. JSONL text: columns/values per row; $bigint, $date (ISO), $bytes (base64) tags; strings unchanged. ${reader}` + spill = table + ? `All ${result.rows.length} returned rows are in scratch table ${table}: query it with sql_execute(warehouse: "scratch", query: "SELECT ... FROM ${table}") instead of reading the file. File copy: ${metadata.result_path}.` + : `All ${result.rows.length} returned rows saved to ${metadata.result_path}. JSONL text: columns/values per row; $bigint, $date (ISO), $bytes (base64) tags; strings unchanged. ${reader}` } catch { spill = `Spill failed (write/serialization error or ${SQL_MAX_SPILL_BYTES / 1024 / 1024} MiB size limit); returned rows were not saved.` } const envelope = (shown: number, clipped: boolean) => - `Preview: ${shown} of ${result.rows.length} returned rows shown. ` + - (excess - ? `Note: connector returned ${result.rows.length} rows; showing at most ${limit} (requested row limit). ` - : result.truncated - ? "Result may be row-limited (connector reported a limit; total unknown). More rows may exist. " - : "Row-limited: no. ") + - `Output truncated: ${clipped ? "yes" : "no"}. ${spill}` + - (clipped && !fullText ? " Prioritize specific cells with full_text_columns; omit limit to use the default." : "") + - "\n" + mcp + ? `Preview: ${shown} of ${result.rows.length} returned rows. ` + + (excess + ? `The connector returned more rows than the limit (${limit}). ` + : result.truncated + ? "Row limit reached; more rows may exist. " + : "") + + `${spill}\n` + : `Preview: ${shown} of ${result.rows.length} returned rows shown. ` + + (excess + ? `Note: connector returned ${result.rows.length} rows; showing at most ${limit} (requested row limit). ` + : result.truncated + ? "Result may be row-limited (connector reported a limit; total unknown). More rows may exist. " + : "Row-limited: no. ") + + `Output truncated: ${clipped ? "yes" : "no"}. ${spill}` + + (clipped && !fullText ? " Prioritize specific cells with full_text_columns; omit limit to use the default." : "") + + "\n" // Notes share the output budget, including missing names and tool annotations. // Keep room for the result contract even when a note is much larger than the cap. const shownNote = clipText(note, Math.floor(maxBytes / 4), Math.max(1, Math.floor(maxLines / 4)), "…[note clipped]") ?? "" const notesClipped = shownNote !== note - const essentials = envelope(Math.min(rows.length, SQL_MAX_PREVIEW_ROWS), true) + footer + shownNote + const essentials = envelope(Math.min(rows.length, previewLimit), true) + footer + shownNote if (!fits(essentials)) { metadata.schema_truncated = result.columns.length > 0 metadata.output_truncated = metadata.truncated = true @@ -558,11 +588,13 @@ export async function formatSqlResult(result: SqlExecuteResult, options: FormatO fullText ? fullTextColumns : undefined, ) : { text: "", indices: [], columns: 0, clipped: false } - const reserved = envelope(Math.min(rows.length, SQL_MAX_PREVIEW_ROWS), true) + schema.text + footer + shownNote + const reserved = envelope(Math.min(rows.length, previewLimit), true) + schema.text + footer + shownNote const preview = rowsFor( Math.max(0, maxBytes - Buffer.byteLength(reserved)), Math.max(0, maxLines - lineCount(reserved) + 1), schema.indices, + previewLimit, + mcp ? MCP_SPILL_CELL_BYTES : undefined, ) metadata.preview_rows = preview.rows metadata.preview_columns = schema.columns diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index da4929e8af..8398bcf601 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -4,6 +4,7 @@ import { Database } from "bun:sqlite" import { mkdtemp, rm, stat, readFile } from "node:fs/promises" import os from "node:os" import path from "node:path" +import { fileURLToPath } from "node:url" import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" @@ -207,9 +208,22 @@ mcpTest("spill output gives MCP-compatible file-reader instructions", async () = full_text_columns: ["name"], }) expect(result.isError).toBe(false) - expect(text(result)).toContain("any file reader") + expect(text(result)).toContain("scratch table result_1") expect(text(result)).not.toContain("char_offset") expect(text(result)).not.toContain("Task tool") + // When the scratch table cannot be stored, the file stays the documented fallback. + const original = Registry.get + const get = spyOn(Registry, "get").mockImplementation(async (name) => { + if (name === "scratch") throw new Error("scratch unavailable") + return original(name) + }) + try { + const fallback = await call("sql_execute", { warehouse: "source", query: "SELECT name FROM items", full_text_columns: ["name"] }) + expect(text(fallback)).toContain("any file reader") + expect(text(fallback)).not.toContain("scratch table") + } finally { + get.mockRestore() + } }) mcpTest("generic truncation saves output with client-neutral hints", async () => { @@ -581,7 +595,7 @@ mcpTest("sql_execute spill returns text with the path plus a resource_link to th const link = content.find((item) => item.type === "resource_link") expect(link?.uri?.startsWith("file://")).toBe(true) expect(content[0]!.type).toBe("text") - expect(content[0]!.text).toContain("saved to") + expect(content[0]!.text).toContain(`File copy: ${fileURLToPath(link!.uri!)}`) }) mcpTest("sql_execute without a spill returns text only", async () => { @@ -755,6 +769,56 @@ mcpTest("sql_execute queries keeps the whole envelope inside one shared budget", }) // altimate_change end +// altimate_change start — spilled results become a scratch table to query, with a short preview +const spillRows = (db: Database) => { + db.run("CREATE TABLE events (id INTEGER, code TEXT, note TEXT, ratio REAL)") + const insert = db.prepare("INSERT INTO events VALUES (?, ?, ?, ?)") + for (let i = 1; i <= 200; i++) { + insert.run(i, `00${i}`, i % 3 === 0 ? null : i % 3 === 1 ? "" : `a,"b"\nc ${"x".repeat(300)}`, i / 8) + } +} + +mcpTest("a spilled result is queryable as a scratch table with exact values", async () => { + const { call } = await fixture(false, {}, undefined, spillRows) + const result = await call("sql_execute", { warehouse: "source", query: "SELECT * FROM events ORDER BY id", limit: 500 }) + expect(result.isError).toBe(false) + const out = body(result) + expect(out).toContain("Preview: 10 of 200 returned rows.") + expect(out).toContain('scratch table result_1: query it with sql_execute(warehouse: "scratch"') + // Cells in the preview are clipped; the table keeps them whole. + expect(out).toContain("[cell clipped") + expect(out).not.toContain("x".repeat(300)) + const check = await call("sql_execute", { + warehouse: "scratch", + query: + "SELECT COUNT(*) AS n, SUM(id) AS total, COUNT(note) AS non_null, SUM(CASE WHEN note = '' THEN 1 ELSE 0 END) AS empty, " + + "MIN(code) AS first_code, MAX(length(note)) AS longest, SUM(ratio) AS ratios, typeof(ANY_VALUE(code)) AS code_type FROM result_1", + }) + // Text codes keep their leading zeros, NULL stays distinct from '', and long cells are whole. + expect(body(check)).toContain("200 | 20100 | 134 | 67 | 001 | 308 | 2512.5 | VARCHAR") + const longest = await call("sql_execute", { + warehouse: "scratch", + query: "SELECT note FROM result_1 WHERE id = 2", + }) + expect(body(longest)).toContain('a,\\"b\\"\\nc ') +}) + +mcpTest("spill tables are numbered per session and skip a name the client already used", async () => { + const { call } = await fixture(false, {}, undefined, spillRows) + const created = await call("sql_materialize", { + source_warehouse: "source", + sql: "SELECT 1 AS taken", + target_table: "result_2", + }) + expect(created.isError).toBe(false) + const first = body(await call("sql_execute", { warehouse: "source", query: "SELECT * FROM events", limit: 500 })) + const second = body(await call("sql_execute", { warehouse: "source", query: "SELECT id FROM events", limit: 500 })) + expect(first).toContain("scratch table result_1") + expect(second).toContain("scratch table result_3") + expect(body(await call("sql_execute", { warehouse: "scratch", query: "SELECT taken FROM result_2" }))).toContain("1") +}) +// altimate_change end + // altimate_change start — compact tool definitions: re-sent with every model request mcpTest("tool definitions stay compact and keep their required properties", async () => { const { client } = await fixture() From 68dcafe85e13a1da7e1473f9965e3c8e3fe73c41 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 19 Oct 2026 13:09:41 -0700 Subject: [PATCH 28/39] feat(mcp): compact MCP output: CSV spills, no rule lines, hints once per session - Spill files are CSV with one header row (NULL empty, "" kept), named result-N.csv in the private dir. - No `---|---` rule lines in sql_execute, schema_inspect, warehouse_list. - sql_materialize receipt omits the echoed SQL; usage hints appear once per session. - A cancelled spill store propagates. Native output unchanged. Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/mcp-server/adapter.ts | 11 ++- .../src/altimate/tools/schema-inspect.ts | 26 ++++-- .../src/altimate/tools/session-hints.ts | 20 +++++ .../src/altimate/tools/sql-execute.ts | 8 +- .../src/altimate/tools/sql-materialize.ts | 23 +++-- .../src/altimate/tools/sql-result-format.ts | 86 ++++++++++++++++--- .../src/altimate/tools/warehouse-list.ts | 4 +- .../test/altimate/mcp-server/server.test.ts | 58 +++++++++++-- .../test/altimate/mcp-server/stdio.test.ts | 2 +- .../tools/schema-inspect-list.test.ts | 14 ++- .../altimate/tools/sql-result-format.test.ts | 53 ++++++++++++ 11 files changed, 262 insertions(+), 43 deletions(-) create mode 100644 packages/opencode/src/altimate/tools/session-hints.ts diff --git a/packages/opencode/src/altimate/mcp-server/adapter.ts b/packages/opencode/src/altimate/mcp-server/adapter.ts index 47581f65dc..c590251701 100644 --- a/packages/opencode/src/altimate/mcp-server/adapter.ts +++ b/packages/opencode/src/altimate/mcp-server/adapter.ts @@ -1,7 +1,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { NodePath } from "@effect/platform-node" import { Effect, Layer } from "effect" -import { mkdtemp, chmod, rm } from "node:fs/promises" +import { mkdtemp, chmod, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import path from "node:path" import { randomUUID } from "node:crypto" @@ -301,6 +301,13 @@ export async function createAdapter(options: { allowWrite?: boolean } = {}) { }) const messages: TurnMessage[] = [userMessage()] + // Sequential spill names keep model-visible paths stable across runs, apart from the private directory. + let spills = 0 + const writeSpill = async (text: string, extension: string) => { + const file = path.join(directory, `result-${++spills}.${extension}`) + await writeFile(file, text, { mode: 0o600, flag: "wx" }) + return file + } const shutdown = new AbortController() const active = new Set>() // Feed the native commit checks: completed tool output and rejections, with a new turn after an accepted commit. @@ -328,7 +335,7 @@ export async function createAdapter(options: { allowWrite?: boolean } = {}) { messageID: `mcp-${randomUUID()}` as MessageID, agent: "analyst", abort: signal, - extra: { mcpClient: true, forceReadOnly: !options.allowWrite }, + extra: { mcpClient: true, forceReadOnly: !options.allowWrite, writeSpill }, messages, metadata: () => Effect.void, // Fail closed for new permission kinds. sql_execute is the validated source-read permission. diff --git a/packages/opencode/src/altimate/tools/schema-inspect.ts b/packages/opencode/src/altimate/tools/schema-inspect.ts index acccccec37..a99357a55c 100644 --- a/packages/opencode/src/altimate/tools/schema-inspect.ts +++ b/packages/opencode/src/altimate/tools/schema-inspect.ts @@ -10,6 +10,7 @@ import * as Registry from "../native/connections/registry" import { profileValues } from "./schema-values" import { isRecord, normalizeError } from "./response-normalization" import { validateTableName, validateWarehouseName } from "./input-validation" +import { firstTime } from "./session-hints" // altimate_change start — workspace precedence import * as Precedence from "../workspace/precedence" // altimate_change end @@ -78,19 +79,23 @@ export const SchemaInspectTool = Tool.define("schema_inspect", { { signal: ctx.abort, sessionID: ctx.sessionID, forceReadOnly: true }, ) const schemas = result.schemas + // altimate_change start — MCP: compact tables, usage hints once per session + const mcp = ctx.extra?.mcpClient === true + const rule = (text: string) => (mcp ? [] : [text]) + const hint = (key: string, text: string) => (!mcp || firstTime(ctx.sessionID, key) ? ["", text] : []) const lines = schemas ? [ `Schemas on warehouse "${result.warehouse}":`, "", "Schema | Tables/collections", - "-------|-------------------", + ...rule("-------|-------------------"), ...schemas.map((schema) => `${schema.name} | ${schema.table_count ?? "unavailable"}`), ] : [ `Tables on warehouse "${result.warehouse}":`, "", "Schema | Name | Type | Rows", - "-------|------|------|-----", + ...rule("-------|------|------|-----"), ...result.tables.map( (table) => `${table.schema} | ${table.name} | ${table.type} | ${table.row_count === undefined ? (table.row_count_status ?? "") : `${table.row_count}${table.row_count_estimated ? " (estimate)" : ""}`}`, @@ -107,11 +112,16 @@ export const SchemaInspectTool = Tool.define("schema_inspect", { ) if (result.fallback) lines.push("", "Driver fallback: table list may be truncated by the driver.") if (schemas) - lines.push("", `List tables with schema_inspect(warehouse="${result.warehouse}", schema_name="").`) + lines.push( + ...hint("list-tables", `List tables with schema_inspect(warehouse="${result.warehouse}", schema_name="").`), + ) lines.push( - "", - `Inspect one table with schema_inspect(warehouse="${result.warehouse}", schema_name="", table="").`, + ...hint( + "inspect-table", + `Inspect one table with schema_inspect(warehouse="${result.warehouse}", schema_name="", table="").`, + ), ) + // altimate_change end return Precedence.annotate(precedence, { title: `${schemas ? "Schemas" : "Tables"}: ${result.warehouse}`, metadata: { @@ -178,7 +188,7 @@ export const SchemaInspectTool = Tool.define("schema_inspect", { } // altimate_change start — progressive disclosure suggestions - let output = formatSchema(schemaResult) + let output = formatSchema(schemaResult, ctx.extra?.mcpClient !== true) if (schemaResult.resolution) output = `${schemaResult.resolution}\n\n${output}` if (args.values !== false) { const resolvedTable = schemaResult.table ?? args.table @@ -233,7 +243,7 @@ function schemaError(msg: string) { } } -function formatSchema(result: Partial): string { +function formatSchema(result: Partial, rule = true): string { const lines: string[] = [] const table = result.table ?? "unknown" const qualified = result.schema_name ? `${result.schema_name}.${table}` : table @@ -243,7 +253,7 @@ function formatSchema(result: Partial): string { } lines.push("") lines.push("Column | Type | Nullable | PK") - lines.push("-------|------|----------|---") + if (rule) lines.push("-------|------|----------|---") for (const col of result.columns ?? []) { lines.push( `${col.name} | ${col.data_type ?? "unknown"} | ${col.nullable ? "YES" : "NO"} | ${col.primary_key ? "YES" : ""}`, diff --git a/packages/opencode/src/altimate/tools/session-hints.ts b/packages/opencode/src/altimate/tools/session-hints.ts new file mode 100644 index 0000000000..383583295f --- /dev/null +++ b/packages/opencode/src/altimate/tools/session-hints.ts @@ -0,0 +1,20 @@ +/** + * Usage hints shown once per session. Tool output stays in the conversation and is re-read on every + * later model request, so a tutorial repeated on each call costs tokens without adding information. + */ +const shown = new Map>() +/** Bounds the in-process record; the oldest sessions are dropped first. */ +const MAX_SESSIONS = 1_000 + +/** True the first time `key` is asked for in this session, false afterwards. */ +export function firstTime(sessionID: string, key: string): boolean { + let keys = shown.get(sessionID) + if (!keys) { + keys = new Set() + shown.set(sessionID, keys) + if (shown.size > MAX_SESSIONS) shown.delete(shown.keys().next().value!) + } + if (keys.has(key)) return false + keys.add(key) + return true +} diff --git a/packages/opencode/src/altimate/tools/sql-execute.ts b/packages/opencode/src/altimate/tools/sql-execute.ts index ecff642d06..f30d2a9cf0 100644 --- a/packages/opencode/src/altimate/tools/sql-execute.ts +++ b/packages/opencode/src/altimate/tools/sql-execute.ts @@ -36,6 +36,7 @@ import { normalizeError } from "./response-normalization" // altimate_change end import { readSql } from "./sql-check" import { MaterializeError, storeRows } from "./sql-materialize" +import { firstTime } from "./session-hints" const MAX_SQL_ROWS = 10_000 const LIMIT_ERROR = "limit must be a finite positive integer" @@ -408,11 +409,16 @@ export const SqlExecuteTool = Tool.define( ].filter(Boolean), maxBytes: routingClipped ? 0 : maxBytes - routingBytes, maxLines: limits.maxLines - routingLines, - write: (text) => bridge.promise(truncate.write(text)), + // The MCP adapter names its spill files itself (sequential, private directory). + write: (text) => + typeof ctx.extra?.writeSpill === "function" + ? ctx.extra.writeSpill(text, "csv") + : bridge.promise(truncate.write(text)), store: ctx.extra?.mcpClient === true ? () => storeSpill(result, { signal: ctx.abort, sessionID: ctx.sessionID }) : undefined, + firstHint: ctx.extra?.mcpClient === true ? (key) => firstTime(ctx.sessionID, key) : undefined, }) // altimate_change start — progressive disclosure suggestions if (suggestion && formatted.output.includes(suggestion)) { diff --git a/packages/opencode/src/altimate/tools/sql-materialize.ts b/packages/opencode/src/altimate/tools/sql-materialize.ts index 3470b6705a..9a5c79b01a 100644 --- a/packages/opencode/src/altimate/tools/sql-materialize.ts +++ b/packages/opencode/src/altimate/tools/sql-materialize.ts @@ -11,6 +11,7 @@ import { readSql } from "./sql-check" import { lintSqlResult } from "./sql-result-lint" import * as Precedence from "../workspace/precedence" import { executeQuery, type QueryContext } from "../native/query-execution" +import { firstTime } from "./session-hints" /** Default and hard ceilings, see the tool description. */ export const DEFAULT_MAX_ROWS = 2_000_000 @@ -561,6 +562,19 @@ export const SqlMaterializeTool = Tool.define("sql_materialize", { } const cols = r.columns.map((c) => `${c.name} ${c.type}`).join(", ") const rowsLine = r.mode === "append" ? `${r.rows} rows appended (${r.total_rows} total)` : `${r.rows} rows` + // altimate_change start — MCP: the client already has its SQL; the join hint is shown once per session + const mcp = ctx.extra?.mcpClient === true + const joinHint = `Join it in scratch with sql_execute(warehouse: "${SCRATCH_NAME}", query: "SELECT ... FROM ${r.table} ...") ` + const source = isScratch(args.source_warehouse) ? SCRATCH_NAME : args.source_warehouse + const receipt = mcp + ? `${label}: ${rowsLine}, ${r.columns.length} columns (${r.mode}) from ${source}.\nColumns: ${cols}` + + (firstTime(ctx.sessionID, "materialize-join") ? `\n\n${joinHint.trimEnd()}.` : "") + : `${label}: ${rowsLine}, ${r.columns.length} columns (${r.mode === "append" ? "append" : r.mode}).\n` + + `Columns: ${cols}\n` + + `Source: ${source}. SQL run:\n${args.sql}\n\n` + + joinHint + + `(the table is also addressable as ${label}).` + // altimate_change end return Precedence.annotate(verdict, { title: `Materialize ${label}: ${r.rows} rows`, metadata: { @@ -571,14 +585,7 @@ export const SqlMaterializeTool = Tool.define("sql_materialize", { mode: r.mode, ...lintMetadata, }, - output: - warning + - `${label}: ${rowsLine}, ${r.columns.length} columns (${r.mode === "append" ? "append" : r.mode}).\n` + - `Columns: ${cols}\n` + - `Source: ${isScratch(args.source_warehouse) ? SCRATCH_NAME : args.source_warehouse}. SQL run:\n${args.sql}\n\n` + - `Join it in scratch with sql_execute(warehouse: "${SCRATCH_NAME}", query: "SELECT ... FROM ${r.table} ...") ` + - `(the table is also addressable as ${label}).` + - checks, + output: warning + receipt + checks, }) } catch (e) { const msg = e instanceof Error ? e.message : String(e) diff --git a/packages/opencode/src/altimate/tools/sql-result-format.ts b/packages/opencode/src/altimate/tools/sql-result-format.ts index dc3c239ded..0e3c5cff40 100644 --- a/packages/opencode/src/altimate/tools/sql-result-format.ts +++ b/packages/opencode/src/altimate/tools/sql-result-format.ts @@ -17,6 +17,8 @@ interface FormatOptions { write: (text: string) => Promise /** MCP: called when the result spills; stores every returned row as a scratch table and returns its name. */ store?: () => Promise + /** MCP: true the first time a usage hint is due in this session; later outputs use the short form. */ + firstHint?: (key: string) => boolean } /** MCP spills show a short preview; the full rows are queryable in scratch. */ @@ -53,7 +55,13 @@ function clipCell(text: string, maxBytes: number, maxLines: number): string | un return clipText(text, maxBytes, maxLines, `…[cell clipped, ${chars} chars]`) } -function previewSchema(columns: string[], maxBytes: number, maxLines: number, priority?: Set) { +function previewSchema( + columns: string[], + maxBytes: number, + maxLines: number, + priority?: Set, + separator = true, +) { const order = columns.map((_, index) => index) if (priority) order.sort((a, b) => Number(priority.has(columns[b]!)) - Number(priority.has(columns[a]!))) const render = (name: string) => (/[\n\r]| \| /.test(name) ? JSON.stringify(name) : name) @@ -70,8 +78,8 @@ function previewSchema(columns: string[], maxBytes: number, maxLines: number, pr const aliases = rendered.map((name) => clipText(name, aliasBytes, 1, "…[alias clipped]")) if (aliases.some((name) => name === undefined)) continue const names = aliases as string[] - const text = - names.join(" | ") + "\n" + names.map((name) => "-".repeat(Math.max(name.length, 4))).join("-+-") + "\n" + omitted + const rule = separator ? names.map((name) => "-".repeat(Math.max(name.length, 4))).join("-+-") + "\n" : "" + const text = names.join(" | ") + "\n" + rule + omitted if (Buffer.byteLength(text, "utf8") > maxBytes || lineCount(text) > maxLines) continue return { text, @@ -423,6 +431,42 @@ export function serializeRows(result: SqlExecuteResult, emit: (chunk: string) => } } +/** One CSV field (RFC 4180). NULL is an empty field and an empty string is `""`, so the two stay distinct. */ +function csvCell(value: unknown): string { + if (value === null || value === undefined) return "" + if (value === "") return '""' + const text = + typeof value === "string" + ? value + : value instanceof Uint8Array + ? "base64:" + Buffer.from(value).toString("base64") + : value instanceof Date + ? Number.isNaN(value.getTime()) + ? "Invalid Date" + : value.toISOString() + : typeof value === "object" + ? [...spillValue(value)].join("") + : String(value) + return /[",\r\n]/.test(text) || text !== text.trim() ? `"${text.replaceAll('"', '""')}"` : text +} + +/** + * CSV spill for MCP clients: column names once in a header row, then one line per row. Nested values + * are tagged JSON text, binary is `base64:`, dates are ISO 8601. + */ +export function serializeCsv(result: SqlExecuteResult, emit: (chunk: string) => void): void { + let bytes = 0 + const line = (cells: unknown[]) => { + checkSpillSize(cells, { left: SQL_MAX_SPILL_BYTES - bytes }) + const text = cells.map(csvCell).join(",") + "\n" + bytes += Buffer.byteLength(text, "utf8") + if (bytes > SQL_MAX_SPILL_BYTES) throw new Error("result exceeds the SQL spill size limit") + emit(text) + } + line(result.columns) + for (const values of result.rows) line(result.columns.map((_, index) => (index in values ? values[index] : null))) +} + const EMPTY_RESULT_HINT = "Hint: 0 rows. If a literal filter was used, verify the stored values first, e.g. SELECT DISTINCT FROM LIMIT 50 or a LIKE '%…%' probe — stored formats often differ (case, whitespace, prefixes)." @@ -452,6 +496,8 @@ function isScalar(result: SqlExecuteResult): boolean { } export async function formatSqlResult(result: SqlExecuteResult, options: FormatOptions) { + // MCP output is compact: no table rule line, CSV spills, short previews with a scratch table. + const mcp = options.mcpClient === true const fullTextColumns = new Set(options.fullTextColumns ?? []) const fullText = fullTextColumns.size > 0 const limit = fullText ? Math.min(options.limit, 20) : options.limit @@ -513,6 +559,8 @@ export async function formatSqlResult(result: SqlExecuteResult, options: FormatO result.columns, maxBytes - Buffer.byteLength(footer + note), maxLines - lineCount(footer + note) + 1, + undefined, + !mcp, ) const preview = rowsFor( Math.max(0, maxBytes - Buffer.byteLength(schema.text + footer + note)), @@ -533,23 +581,34 @@ export async function formatSqlResult(result: SqlExecuteResult, options: FormatO let spill: string // MCP: a short preview, with every returned row queryable as a scratch table instead of a file to read. - const mcp = options.mcpClient === true const previewLimit = mcp && !fullText ? MCP_SPILL_PREVIEW_ROWS : SQL_MAX_PREVIEW_ROWS + let saved = false try { const chunks: string[] = [] - serializeRows(result, (chunk) => chunks.push(chunk)) + if (mcp) serializeCsv(result, (chunk) => chunks.push(chunk)) + else serializeRows(result, (chunk) => chunks.push(chunk)) metadata.result_path = await options.write(chunks.join("")) - const table = mcp && result.rows.length ? await options.store?.().catch(() => undefined) : undefined - metadata.scratch_table = table - const reader = mcp - ? "Open this path with any file reader to recover clipped text." - : "Use read with char_offset/max_chars to recover any clipped text." - spill = table - ? `All ${result.rows.length} returned rows are in scratch table ${table}: query it with sql_execute(warehouse: "scratch", query: "SELECT ... FROM ${table}") instead of reading the file. File copy: ${metadata.result_path}.` - : `All ${result.rows.length} returned rows saved to ${metadata.result_path}. JSONL text: columns/values per row; $bigint, $date (ISO), $bytes (base64) tags; strings unchanged. ${reader}` + saved = true } catch { spill = `Spill failed (write/serialization error or ${SQL_MAX_SPILL_BYTES / 1024 / 1024} MiB size limit); returned rows were not saved.` } + if (saved) { + // Outside the write's try: a cancelled store must surface as cancellation, not as a spill failure. + const table = mcp && result.rows.length ? await options.store?.() : undefined + metadata.scratch_table = table + const first = (key: string) => options.firstHint?.(key) ?? true + if (table) { + spill = first("spill-table") + ? `All ${result.rows.length} returned rows are in scratch table ${table} (typed like sql_materialize): query it with sql_execute(warehouse: "scratch", query: "SELECT ... FROM ${table}") instead of reading the file. Exact-value CSV copy: ${metadata.result_path}.` + : `All ${result.rows.length} returned rows are in scratch table ${table} (CSV copy: ${metadata.result_path}).` + } else if (mcp) { + spill = first("spill-csv") + ? `All ${result.rows.length} returned rows saved to ${metadata.result_path}: CSV with a header row; an empty field is NULL, "" is an empty string. Open this path with any file reader to recover clipped text.` + : `All ${result.rows.length} returned rows saved to ${metadata.result_path} (CSV).` + } else { + spill = `All ${result.rows.length} returned rows saved to ${metadata.result_path}. JSONL text: columns/values per row; $bigint, $date (ISO), $bytes (base64) tags; strings unchanged. Use read with char_offset/max_chars to recover any clipped text.` + } + } const envelope = (shown: number, clipped: boolean) => mcp ? `Preview: ${shown} of ${result.rows.length} returned rows. ` + @@ -586,6 +645,7 @@ export async function formatSqlResult(result: SqlExecuteResult, options: FormatO maxBytes - Buffer.byteLength(essentials), maxLines - lineCount(essentials) + 1, fullText ? fullTextColumns : undefined, + !mcp, ) : { text: "", indices: [], columns: 0, clipped: false } const reserved = envelope(Math.min(rows.length, previewLimit), true) + schema.text + footer + shownNote diff --git a/packages/opencode/src/altimate/tools/warehouse-list.ts b/packages/opencode/src/altimate/tools/warehouse-list.ts index aefc7048f2..c0386f7945 100644 --- a/packages/opencode/src/altimate/tools/warehouse-list.ts +++ b/packages/opencode/src/altimate/tools/warehouse-list.ts @@ -31,12 +31,14 @@ export const WarehouseListTool = Tool.define("warehouse_list", { // re-linked project is not told its rows are served by the old workspace. const notes = await Precedence.warehouseListNotes(ctx.sessionID, warehouses) const shadowedCount = notes.size - // altimate_change start — MCP: one-call catalog discovery + // altimate_change start — MCP: compact table and one-call catalog discovery const mcp = ctx.extra?.mcpClient === true const lines: string[] = shadowedCount ? ["Name | Type | Database | Served by", "-----|------|----------|----------"] : ["Name | Type | Database", "-----|------|--------"] + // The rule line only decorates the header. + if (mcp) lines.pop() for (const wh of warehouses) { const row = `${wh.name} | ${wh.type} | ${wh.database ?? "-"}` lines.push(shadowedCount ? `${row} | ${notes.get(wh.name) ?? "local"}` : row) diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index 8398bcf601..05511e53da 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -101,7 +101,10 @@ mcpTest("lists exactly six tools and executes warehouse, schema, SQL and checks // altimate_change start — warehouse discovery accepts strict-schema empty table fillers mcpTest("schema_inspect lists schemas, then SQLite tables with protected counts", async () => { const { call } = await fixture() - for (const args of [{ warehouse: "source" }, { warehouse: "source", table: "", schema_name: "", values: false }]) { + for (const [index, args] of [ + { warehouse: "source" }, + { warehouse: "source", table: "", schema_name: "", values: false }, + ].entries()) { const result = await call("schema_inspect", args) expect(result.isError).toBe(false) // altimate_change start — discovery starts with schema counts @@ -111,7 +114,11 @@ mcpTest("schema_inspect lists schemas, then SQLite tables with protected counts" expect(tables.isError).toBe(false) expect(body(tables)).toContain("main | items | table | 2") // altimate_change end - expect(body(result)).toContain("Inspect one table with schema_inspect") + // The usage hint is shown once per session; the table rule line is gone over MCP. + if (index === 0) expect(body(result)).toContain("Inspect one table with schema_inspect") + else expect(body(result)).not.toContain("Inspect one table with schema_inspect") + expect(body(result)).not.toContain("-------|") + expect(body(tables)).not.toContain("-------|") } }) // altimate_change end @@ -381,7 +388,7 @@ mcpTest("private spill directory and its files are removed on server close", asy query: "SELECT name FROM items", full_text_columns: ["name"], }) - const saved = body(result).match(/(\/[^\s`]+\/tool_[A-Za-z0-9]+)/)?.[1] + const saved = body(result).match(/(\/[^\s`()]+\/result-\d+\.csv)/)?.[1] expect(saved).toBeDefined() expect((await stat(path.dirname(saved!))).mode & 0o777).toBe(0o700) expect(await readFile(saved!, "utf8")).toContain("apple") @@ -595,7 +602,7 @@ mcpTest("sql_execute spill returns text with the path plus a resource_link to th const link = content.find((item) => item.type === "resource_link") expect(link?.uri?.startsWith("file://")).toBe(true) expect(content[0]!.type).toBe("text") - expect(content[0]!.text).toContain(`File copy: ${fileURLToPath(link!.uri!)}`) + expect(content[0]!.text).toContain(`CSV copy: ${fileURLToPath(link!.uri!)}`) }) mcpTest("sql_execute without a spill returns text only", async () => { @@ -784,7 +791,7 @@ mcpTest("a spilled result is queryable as a scratch table with exact values", as expect(result.isError).toBe(false) const out = body(result) expect(out).toContain("Preview: 10 of 200 returned rows.") - expect(out).toContain('scratch table result_1: query it with sql_execute(warehouse: "scratch"') + expect(out).toContain('scratch table result_1 (typed like sql_materialize): query it with sql_execute(warehouse: "scratch"') // Cells in the preview are clipped; the table keeps them whole. expect(out).toContain("[cell clipped") expect(out).not.toContain("x".repeat(300)) @@ -933,3 +940,44 @@ mcpTest("warehouse_list discovery stops starting calls once its deadline passes" } }, 15_000) // altimate_change end + +// altimate_change start — compact MCP output: CSV spills, no rule lines, hints once, short receipts +mcpTest("an MCP spill file is CSV with the column names once and NULL distinct from an empty string", async () => { + const { call } = await fixture(false, {}, undefined, spillRows) + const result = await call("sql_execute", { + warehouse: "source", + query: "SELECT id, code, note FROM events WHERE id <= 3 ORDER BY id", + full_text_columns: ["note"], + }) + const file = body(result).match(/(\/[^\s`()]+\/result-1\.csv)/)?.[1] + expect(file).toBeDefined() + const csv = await readFile(file!, "utf8") + const lines = csv.split("\n") + expect(lines[0]).toBe("id,code,note") + expect(lines[1]).toBe('1,001,""') + expect(lines[2]).toBe(`2,002,"a,""b""`) + expect(csv).toContain(`\n3,003,\n`) + expect(csv.match(/id,code,note/g)).toHaveLength(1) +}) + +mcpTest("MCP inline results, listings and receipts drop the rule line and repeat no tutorial", async () => { + const { call } = await fixture(false, {}, undefined, spillRows) + const inline = body(await call("sql_execute", { warehouse: "source", query: "SELECT id, name FROM items ORDER BY id" })) + expect(inline).toBe("id | name\n1 | apple\n2 | pear\n\n(2 rows)") + expect(body(await call("warehouse_list", {}))).not.toMatch(/^-+\|/m) + const first = body(await call("sql_execute", { warehouse: "source", query: "SELECT * FROM events", limit: 500 })) + const second = body(await call("sql_execute", { warehouse: "source", query: "SELECT id FROM events", limit: 500 })) + expect(first).toContain("instead of reading the file") + expect(second).not.toContain("instead of reading the file") + expect(second).toContain("All 200 returned rows are in scratch table result_2 (CSV copy:") + const sql = "SELECT id, code FROM events WHERE id < 5" + const made = body(await call("sql_materialize", { source_warehouse: "source", sql, target_table: "small" })) + expect(made).toStartWith("scratch.small: 4 rows, 2 columns (create) from source.\nColumns: id BIGINT, code VARCHAR") + expect(made).not.toContain(sql) + expect(made).toContain("Join it in scratch") + const again = body( + await call("sql_materialize", { source_warehouse: "source", sql, target_table: "small", mode: "replace" }), + ) + expect(again).not.toContain("Join it in scratch") +}) +// altimate_change end diff --git a/packages/opencode/test/altimate/mcp-server/stdio.test.ts b/packages/opencode/test/altimate/mcp-server/stdio.test.ts index 8bda9b75e2..4b82f886ad 100644 --- a/packages/opencode/test/altimate/mcp-server/stdio.test.ts +++ b/packages/opencode/test/altimate/mcp-server/stdio.test.ts @@ -107,7 +107,7 @@ for (const allowWrite of [false, true]) { name: "sql_execute", arguments: { warehouse: "source", query: "SELECT 'private server output' AS value", full_text_columns: ["value"] }, }) - spill = JSON.stringify(output.content).match(/(\/[^\s`"\\]+\/tool_[A-Za-z0-9]+)/)?.[1] + spill = JSON.stringify(output.content).match(/(\/[^\s`"\\()]+\/result-\d+\.csv)/)?.[1] expect(spill).toBeDefined() expect(await Bun.file(spill!).exists()).toBe(true) } finally { diff --git a/packages/opencode/test/altimate/tools/schema-inspect-list.test.ts b/packages/opencode/test/altimate/tools/schema-inspect-list.test.ts index fe14f66d12..36a451556d 100644 --- a/packages/opencode/test/altimate/tools/schema-inspect-list.test.ts +++ b/packages/opencode/test/altimate/tools/schema-inspect-list.test.ts @@ -22,21 +22,26 @@ const ctx = { } let restore: (() => void) | undefined let directory: string | undefined +// MCP usage hints are shown once per session; each call gets its own session so outputs compare equal. +let calls = 0 +const sessions: string[] = [] afterEach(async () => { restore?.() restore = undefined await Registry.closeAll() Registry.reset() - Precedence.releaseLocalSession(ctx.sessionID) + for (const session of sessions.splice(0)) Precedence.releaseLocalSession(session) if (directory) await rm(directory, { recursive: true, force: true }) directory = undefined }) async function inspect(args: Record = {}, ask = async (_input: unknown) => {}) { - Precedence.bindLocalSession(ctx.sessionID) + const sessionID = `${ctx.sessionID}-${++calls}` + sessions.push(sessionID) + Precedence.bindLocalSession(sessionID) const tool = await initTool(SchemaInspectTool) - return tool.execute(args, { ...ctx, ask }) + return tool.execute(args, { ...ctx, sessionID, ask }) } for (const type of ["sqlite", "duckdb"] as const) { @@ -73,7 +78,8 @@ for (const type of ["sqlite", "duckdb"] as const) { title: "Schema: empty", metadata: { success: true, columnCount: 1, rowCount: undefined, truncated: false }, output: - "Table: main.empty\n\nColumn | Type | Nullable | PK\n-------|------|----------|---\nid | INTEGER | YES | ", + // MCP output drops the table rule line. + "Table: main.empty\n\nColumn | Type | Nullable | PK\nid | INTEGER | YES | ", }) }) } diff --git a/packages/opencode/test/altimate/tools/sql-result-format.test.ts b/packages/opencode/test/altimate/tools/sql-result-format.test.ts index da5349aadd..eb012e0cb7 100644 --- a/packages/opencode/test/altimate/tools/sql-result-format.test.ts +++ b/packages/opencode/test/altimate/tools/sql-result-format.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import type { SqlExecuteResult } from "../../../src/altimate/native/types" import { formatSqlResult, + serializeCsv, serializeRows, SQL_MAX_PREVIEW_BYTES, SQL_MAX_PREVIEW_COLUMNS, @@ -1105,3 +1106,55 @@ describe("SQL full text retrieval", () => { ]) }) }) + +describe("MCP compact output", () => { + test("serializeCsv writes the header once and keeps NULL, empty strings and special values apart", () => { + const chunks: string[] = [] + serializeCsv( + result( + [ + [null, "", " padded", 'say "hi", ok'], + [12345678901234567890n, new Date("2024-01-02T03:04:05.006Z"), new Uint8Array([1, 2, 3]), { a: [1, 2n] }], + ], + ["a", "", "c", "d"], + ), + (chunk) => chunks.push(chunk), + ) + expect(chunks.join("")).toBe( + 'a,"",c,d\n' + + ',""," padded","say ""hi"", ok"\n' + + '12345678901234567890,2024-01-02T03:04:05.006Z,base64:AQID,"{""a"":[1,{""$bigint"":""2""}]}"\n', + ) + }) + + test("serializeCsv enforces the spill size limit", () => { + expect(() => serializeCsv(result([["x".repeat(SQL_MAX_SPILL_BYTES)]]), () => {})).toThrow(/spill size limit/) + }) + + test("an MCP inline result has no rule line; the native one keeps it", async () => { + const rows = result([[1, "a"]], ["id", "name"]) + const write = async () => resultPath + expect((await formatSqlResult(rows, { limit: 100, write, mcpClient: true })).output).toBe("id | name\n1 | a\n\n(1 rows)") + expect((await formatSqlResult(rows, { limit: 100, write })).output).toBe("id | name\n-----+-----\n1 | a\n\n(1 rows)") + }) + + test("an MCP spill previews ten rows, names the scratch table once in full, then briefly", async () => { + const rows = result(Array.from({ length: 30 }, (_, i) => [i, "v".repeat(400)]), ["id", "text"], true) + const seen = new Set() + const options = { + limit: 100, + mcpClient: true, + write: async () => resultPath, + store: async () => "result_1", + firstHint: (key: string) => !seen.has(key) && Boolean(seen.add(key)), + } + const first = await formatSqlResult(rows, options) + expect(first.output).toStartWith("Preview: 10 of 30 returned rows. Row limit reached; more rows may exist.") + expect(first.output).toContain("instead of reading the file") + expect(first.output).not.toContain("v".repeat(161)) + expect(first.metadata.scratch_table).toBe("result_1") + const second = await formatSqlResult(rows, options) + expect(second.output).toContain(`scratch table result_1 (CSV copy: ${resultPath}).`) + expect(second.output).not.toContain("instead of reading the file") + }) +}) From d2d9edcd00f247ae9670e823795722e4226a44b1 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 19 Oct 2026 13:11:44 -0700 Subject: [PATCH 29/39] fix(mcp): do not call the CSV spill copy exact Review follow-up: CSV text cannot tell a `base64:` string from bytes or JSON text from an object, so the hint now says "CSV copy". The scratch table stays the typed source. Co-Authored-By: Claude Opus 5.5 --- packages/opencode/src/altimate/tools/sql-result-format.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/tools/sql-result-format.ts b/packages/opencode/src/altimate/tools/sql-result-format.ts index 0e3c5cff40..b050d4937f 100644 --- a/packages/opencode/src/altimate/tools/sql-result-format.ts +++ b/packages/opencode/src/altimate/tools/sql-result-format.ts @@ -599,7 +599,7 @@ export async function formatSqlResult(result: SqlExecuteResult, options: FormatO const first = (key: string) => options.firstHint?.(key) ?? true if (table) { spill = first("spill-table") - ? `All ${result.rows.length} returned rows are in scratch table ${table} (typed like sql_materialize): query it with sql_execute(warehouse: "scratch", query: "SELECT ... FROM ${table}") instead of reading the file. Exact-value CSV copy: ${metadata.result_path}.` + ? `All ${result.rows.length} returned rows are in scratch table ${table} (typed like sql_materialize): query it with sql_execute(warehouse: "scratch", query: "SELECT ... FROM ${table}") instead of reading the file. CSV copy: ${metadata.result_path}.` : `All ${result.rows.length} returned rows are in scratch table ${table} (CSV copy: ${metadata.result_path}).` } else if (mcp) { spill = first("spill-csv") From e544a5841d5798e7427bd535f461b4bcb777a438 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Mon, 19 Oct 2026 22:48:37 -0700 Subject: [PATCH 30/39] fix(mcp): restore the compound-request rule in the compact `answer_commit` description The compact description dropped the guidance and worked example for requests that name an entity and a related item, and clients then committed a single field. Restore it in short form (+221 chars): never collapse a compound request into one field ('the repository and the most copied file' needs two), and an item that only selects the answer is not asked. Tests: `server.test.ts` (ALTIMATE_MCP_E2E=1) asserts the rule is advertised; size caps raised to 2,250 (`answer_commit`) and 6,250 (all tools). Co-Authored-By: Claude Opus 5.5 --- packages/opencode/src/altimate/mcp-server/adapter.ts | 2 +- packages/opencode/test/altimate/mcp-server/server.test.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/altimate/mcp-server/adapter.ts b/packages/opencode/src/altimate/mcp-server/adapter.ts index c590251701..6da8828a88 100644 --- a/packages/opencode/src/altimate/mcp-server/adapter.ts +++ b/packages/opencode/src/altimate/mcp-server/adapter.ts @@ -28,7 +28,7 @@ export const descriptions: Record = { sql_check: "Read-only data check: fanout, keys, grain, nulls or coverage.", sql_materialize: "Store a read-only query's rows as a scratch table, to join across connections.", answer_commit: - "Finish a data question; call last, then reply with exactly its FINAL ANSWER text. Use one of fields, rows or unresolved, with a field (or rows column) per asked item, named after it. Numbers may be your own computation; exact decimals as strings.", + "Finish a data question; call last, then reply with exactly its FINAL ANSWER text. Use one of fields, rows or unresolved, with a field (or rows column) per asked item, named after it. Never collapse a compound request into one field: 'the repository and the most copied file' needs two. An item that only selects the answer ('the customer with the largest invoice') is not asked. Numbers may be your own computation; exact decimals as strings.", } /** diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index 05511e53da..15f4128669 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -833,8 +833,9 @@ mcpTest("tool definitions stay compact and keep their required properties", asyn const size = (tool: (typeof tools)[number]) => JSON.stringify({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema }).length const byName = Object.fromEntries(tools.map((tool) => [tool.name, tool])) - expect(size(byName.answer_commit!)).toBeLessThanOrEqual(2_000) - expect(tools.reduce((total, tool) => total + size(tool), 0)).toBeLessThan(6_000) + expect(size(byName.answer_commit!)).toBeLessThanOrEqual(2_250) + expect(byName.answer_commit!.description).toContain("'the repository and the most copied file' needs two") + expect(tools.reduce((total, tool) => total + size(tool), 0)).toBeLessThan(6_250) expect(client.getInstructions()!.length).toBeLessThanOrEqual(723) const declared = JSON.stringify(tools) expect(declared).not.toContain("$schema") From 0028a4b82393e23fa99a17c3dd9d47baee5e1ac5 Mon Sep 17 00:00:00 2001 From: anandgupta42 <93243293+anandgupta42@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:38:41 -0700 Subject: [PATCH 31/39] fix: point the `warehouse_list` no-connections hint at the real config locations (#1559) * fix: point the `warehouse_list` no-connections hint at the real config locations The hint told users to create `connections.json` in `.opencode/`, which the registry never reads. It now names the global and project `.altimate-code/connections.json` paths and the `ALTIMATE_CODE_CONN_*` variables, mentions `warehouse_add`, and keeps the example JSON. The paths come from `globalConfigPath()`, `localConfigPath()` and `CONN_ENV_PREFIX`, now exported from the registry, so the hint cannot drift. A repo sweep found no other reference to the old location. Closes #1558 Co-Authored-By: Claude Opus 5.5 * fix: reload connection config edited after the first load and lead the hint with `warehouse_add` The registry loaded once per process, so a `connections.json` created (or an `ALTIMATE_CODE_CONN_*` variable set) after an empty `warehouse_list` stayed invisible until restart. `ensureLoaded()` now compares a stat/env fingerprint of the sources against the last load and re-reads them when it changed, dropping cached connectors whose config changed. Connections added in-process keep their in-memory credentials; `setConfigs()` injections are never reloaded. The hint now leads with `warehouse_add`, notes the reload behaviour and that credentials can come from `ALTIMATE_CODE_CONN_*` instead of a plaintext password. Co-Authored-By: Claude Opus 5.5 * fix: keep a refreshed connection config authoritative over stale in-memory state Review of the reload change found two ways an old target could survive a config edit: a connection added in-process was restored over a later edit of its global entry, and a connect already in flight could cache a connector built from the previous config. - `add()` now remembers the sanitized form it wrote; the in-memory copy (with credentials) is restored only while the file still holds exactly that. - Refresh bumps a config generation; a connect that finished against an obsolete config is closed and fails with a retry message instead of being cached. Co-Authored-By: Claude Opus 5.5 * fix: reload connection config only while the registry is empty The fingerprint-based reload replaced the process-global config map when the project directory or a file changed, which could close another project's connectors and drop in-memory credentials of a keyring-less `warehouse_add`. Replace it with a structural rule: re-read the sources only while the registry holds zero connections (the case the `warehouse_list` hint addresses). Once any connection is loaded nothing is replaced or closed, so edits to an existing file take effect after a restart. `setConfigs()` injections are not reloaded. The hint says so. Co-Authored-By: Claude Opus 5.5 * fix: do not load another project's connections file on an empty-registry reload The registry is process-global. Record the project whose local file it was loaded with; the empty-registry reload re-reads the project-local file only when the current project is that one. The global file and `ALTIMATE_CODE_CONN_*` stay process-wide. The `warehouse_list` hint advertises the project path only when it would be picked up, and says environment variables are read at process start. Co-Authored-By: Claude Opus 5.5 * fix: clarify `warehouse_list` hint and clear the test-injection flag on load - Hint: the OS credential store is used only when available; `ALTIMATE_CODE_CONN_*` must be set before the process starts. - `load()` clears the `setConfigs()` injection flag, so a real load can still re-read when empty. - Hint tests isolate ambient `ALTIMATE_CODE_CONN_*` variables. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 (cherry picked from commit 49b5615073ca5ad5872936caa4005ca970356548) --- .../altimate/native/connections/registry.ts | 49 ++++- .../src/altimate/tools/warehouse-list.ts | 28 ++- .../test/altimate/warehouse-list-hint.test.ts | 176 ++++++++++++++++++ 3 files changed, 247 insertions(+), 6 deletions(-) create mode 100644 packages/opencode/test/altimate/warehouse-list-hint.test.ts diff --git a/packages/opencode/src/altimate/native/connections/registry.ts b/packages/opencode/src/altimate/native/connections/registry.ts index c71dab5d7e..53b6cb205c 100644 --- a/packages/opencode/src/altimate/native/connections/registry.ts +++ b/packages/opencode/src/altimate/native/connections/registry.ts @@ -58,7 +58,7 @@ const SCRATCH_NAME_CONFLICT = // Config file paths // --------------------------------------------------------------------------- -function globalConfigPath(): string { +export function globalConfigPath(): string { // altimate_change start — the same home Global.Path uses, so the test preload's OPENCODE_TEST_HOME keeps the // suite out of the developer's real connections (Bun caches os.homedir(), so changing HOME does not). return path.join(Global.Path.home, ".altimate-code", "connections.json") @@ -93,7 +93,7 @@ function projectRoot(): string { } // altimate_change end -function localConfigPath(): string { +export function localConfigPath(): string { // altimate_change start — resolve against the project, not the launch cwd return path.join(projectRoot(), ".altimate-code", "connections.json") // altimate_change end @@ -172,9 +172,13 @@ function loadFromFile(filePath: string): Record { } } +// altimate_change start — exported so user-facing hints name the variable the loader reads +export const CONN_ENV_PREFIX = "ALTIMATE_CODE_CONN_" +// altimate_change end + function loadFromEnv(): Record { const result: Record = {} - const prefix = "ALTIMATE_CODE_CONN_" + const prefix = CONN_ENV_PREFIX for (const [key, value] of Object.entries(process.env)) { if (!key.startsWith(prefix) || !value) continue @@ -193,14 +197,17 @@ function loadFromEnv(): Record { } /** Load all connection configs. Local overrides global; env overrides both. */ -export function load(): void { +export function load(includeProjectConfig = true): void { configs.clear() // altimate_change start — absolutize store paths against the directory that // declared them, so a later process.chdir() (--dir) cannot move the store. const base = projectRoot() const global = resolveStorePaths(loadFromFile(globalConfigPath()), path.dirname(globalConfigPath())) - const local = resolveStorePaths(loadFromFile(localConfigPath()), base) + // altimate_change start — see ensureLoaded(): a later reload must not pull in another project's file + const local = includeProjectConfig ? resolveStorePaths(loadFromFile(localConfigPath()), base) : {} + if (includeProjectConfig) loadedProjectDir = base + // altimate_change end const env = resolveStorePaths(loadFromEnv(), base) // altimate_change end @@ -217,6 +224,9 @@ export function load(): void { if (configs.has(SCRATCH_NAME)) Log.Default.warn(SCRATCH_NAME_CONFLICT) loaded = true + // altimate_change start — real sources were read, so the registry is no longer test-injected + injected = false + // altimate_change end } /** Load only an explicit connections file, resolving stores relative to that file. */ @@ -243,8 +253,30 @@ export async function loadFile(file: string): Promise { /** Ensure configs are loaded. */ function ensureLoaded(): void { if (!loaded) load() + // altimate_change start — the registry latches on first load, so a connections file created after an empty + // answer (the `warehouse_list` hint) was never seen. Re-read only while nothing is loaded: there is no state to + // replace or close, and a populated registry never changes under a running session. + else if (configs.size === 0 && !injected) load(projectConfigApplies()) + // altimate_change end } +// altimate_change start — the registry is process-global; this is the project whose local file it was loaded with +let loadedProjectDir: string | undefined + +/** + * Whether the current project's local connections file is the one this registry reads. A server serving several + * directories shares one registry (pre-existing: the first load's project-local file applies process-wide), so a + * later empty-registry reload must not load another project's file into it. + */ +export function projectConfigApplies(): boolean { + return loadedProjectDir === undefined || loadedProjectDir === projectRoot() +} +// altimate_change end + +// altimate_change start — set by setConfigs() (tests): an injected empty registry is not a stale one +let injected = false +// altimate_change end + // --------------------------------------------------------------------------- // Driver factory // --------------------------------------------------------------------------- @@ -882,6 +914,10 @@ export function reset(): void { pending.clear() loaded = false censusSent = false + // altimate_change start + injected = false + loadedProjectDir = undefined + // altimate_change end } /** @@ -893,4 +929,7 @@ export function setConfigs(newConfigs: Record): void { configs.set(name, config) } loaded = true + // altimate_change start + injected = true + // altimate_change end } diff --git a/packages/opencode/src/altimate/tools/warehouse-list.ts b/packages/opencode/src/altimate/tools/warehouse-list.ts index c0386f7945..5e12a4bdf9 100644 --- a/packages/opencode/src/altimate/tools/warehouse-list.ts +++ b/packages/opencode/src/altimate/tools/warehouse-list.ts @@ -7,6 +7,32 @@ import * as Precedence from "../workspace/precedence" // altimate_change end import { discoverCatalog } from "./warehouse-discovery" +// altimate_change start — point at the locations the registry actually reads +import { CONN_ENV_PREFIX, globalConfigPath, localConfigPath, projectConfigApplies } from "../native/connections/registry" + +/** The no-connections hint. Paths come from the registry's own helpers so it cannot drift from what is loaded. */ +export function noConnectionsMessage(): string { + return [ + "No warehouse connections configured.", + "", + `Preferred: use the warehouse_add tool. It stores passwords and keys in the OS credential store when one is available; otherwise it keeps them out of the file for this session only, so use ${CONN_ENV_PREFIX} to make them persistent.`, + "", + "Alternatively, connections are read from these sources (later ones override earlier ones):", + ` 1. ${globalConfigPath()} (global)`, + ...(projectConfigApplies() ? [` 2. ${localConfigPath()} (this project)`] : []), + ` ${projectConfigApplies() ? 3 : 2}. ${CONN_ENV_PREFIX} environment variables, each holding one connection's config as JSON (set them in the shell before starting altimate-code; a running process cannot see variables exported later)`, + "", + "After adding the first connection file, call warehouse_list again. Edits to an existing connections.json take effect after a restart. Example connections.json:", + "{", + ' "my-db": { "type": "postgres", "host": "localhost", "port": 5432, "database": "mydb", "user": "user", "password": "pass" }', + "}", + `To keep the password out of the file, omit it and set ${CONN_ENV_PREFIX} to the full config as JSON instead.`, + "", + "Do not put connections.json in .opencode/; it is not read from there.", + ].join("\n") +} +// altimate_change end + export const WarehouseListTool = Tool.define("warehouse_list", { description: "List all configured warehouse connections. Shows connection name, type, and database.", parameters: z.object({}), @@ -19,7 +45,7 @@ export const WarehouseListTool = Tool.define("warehouse_list", { return { title: "Warehouses: none configured", metadata: { count: 0 }, - output: "No warehouse connections configured.\n\nTo add a connection, create a connections.json file in .opencode/ with:\n{\n \"my-db\": { \"type\": \"postgres\", \"host\": \"localhost\", \"port\": 5432, \"database\": \"mydb\", \"user\": \"user\", \"password\": \"pass\" }\n}", + output: noConnectionsMessage(), } } diff --git a/packages/opencode/test/altimate/warehouse-list-hint.test.ts b/packages/opencode/test/altimate/warehouse-list-hint.test.ts new file mode 100644 index 0000000000..2128e69a24 --- /dev/null +++ b/packages/opencode/test/altimate/warehouse-list-hint.test.ts @@ -0,0 +1,176 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import * as fs from "fs" +import * as os from "os" +import * as path from "path" +import * as Registry from "../../src/altimate/native/connections/registry" +import { WarehouseListTool, noConnectionsMessage } from "../../src/altimate/tools/warehouse-list" +import { registerAll } from "../../src/altimate/native/connections/register" +import { SessionID, MessageID } from "../../src/session/schema" +import { initTool } from "./tool-fixture" + +function toolCtx() { + return { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + callID: "call_test", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, + } as any +} + +let project: string +let prevCwd: string +let ambientConnEnv: Record + +beforeEach(() => { + // Ambient connection env vars would make the empty-registry assertions depend on the caller's shell. + ambientConnEnv = {} + for (const [key, value] of Object.entries(process.env)) { + if (!key.startsWith(Registry.CONN_ENV_PREFIX) || value === undefined) continue + ambientConnEnv[key] = value + delete process.env[key] + } + prevCwd = process.cwd() + project = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "whl-hint-"))) + process.chdir(project) + Registry.reset() + registerAll() +}) + +afterEach(() => { + process.chdir(prevCwd) + Registry.reset() + fs.rmSync(project, { recursive: true, force: true }) + Object.assign(process.env, ambientConnEnv) +}) + +describe("warehouse_list no-connections hint", () => { + test("names the paths and env prefix the registry reads, not .opencode", () => { + const msg = noConnectionsMessage() + expect(msg).toContain(Registry.globalConfigPath()) + expect(msg).toContain(Registry.localConfigPath()) + expect(msg).toContain(Registry.CONN_ENV_PREFIX) + expect(msg).toContain("warehouse_add") + expect(msg).toContain('"type": "postgres"') + expect(msg).not.toContain(".opencode/connections.json") + expect(Registry.localConfigPath()).toBe(path.join(project, ".altimate-code", "connections.json")) + }) + + test("a connections.json written to the project path named by the hint is loaded", () => { + const named = Registry.localConfigPath() + expect(noConnectionsMessage()).toContain(named) + fs.mkdirSync(path.dirname(named), { recursive: true }) + fs.writeFileSync(named, JSON.stringify({ "hint-db": { type: "postgres", host: "localhost", database: "d" } })) + Registry.load() + expect(Registry.names()).toContain("hint-db") + }) + + test("a connections.json at the legacy .opencode location is not loaded", () => { + fs.mkdirSync(path.join(project, ".opencode")) + fs.writeFileSync( + path.join(project, ".opencode", "connections.json"), + JSON.stringify({ "wrong-db": { type: "postgres", host: "localhost" } }), + ) + Registry.load() + expect(Registry.names()).not.toContain("wrong-db") + }) + + test("the tool lists a connection created after an empty answer, in the same process", async () => { + const tool = await initTool(WarehouseListTool) + const ctx = toolCtx() + const first = await tool.execute({}, ctx) + expect(first.output).toContain(Registry.localConfigPath()) + + const named = Registry.localConfigPath() + fs.mkdirSync(path.dirname(named), { recursive: true }) + fs.writeFileSync(named, JSON.stringify({ "late-db": { type: "postgres", host: "localhost", database: "d" } })) + + const second = await tool.execute({}, ctx) + expect(second.output).toContain("late-db") + // the lookup sql tools use resolves it too + expect(Registry.getConfig("late-db")?.type).toBe("postgres") + }) + + test("an env var set after an empty answer is picked up", async () => { + const tool = await initTool(WarehouseListTool) + const ctx = toolCtx() + await tool.execute({}, ctx) + const previous = process.env.ALTIMATE_CODE_CONN_ENVLATE + process.env.ALTIMATE_CODE_CONN_ENVLATE = JSON.stringify({ type: "postgres", host: "h" }) + try { + expect((await tool.execute({}, ctx)).output).toContain("envlate") + } finally { + if (previous === undefined) delete process.env.ALTIMATE_CODE_CONN_ENVLATE + else process.env.ALTIMATE_CODE_CONN_ENVLATE = previous + } + }) + + test("with a connection loaded, editing the file does not replace the configs", () => { + const named = Registry.localConfigPath() + fs.mkdirSync(path.dirname(named), { recursive: true }) + fs.writeFileSync(named, JSON.stringify({ "db-a": { type: "postgres", host: "old" } })) + expect(Registry.getConfig("db-a")?.host).toBe("old") + fs.writeFileSync(named, JSON.stringify({ "db-a": { type: "postgres", host: "new" }, "db-b": { type: "postgres" } })) + expect(Registry.getConfig("db-a")?.host).toBe("old") + expect(Registry.names()).toEqual(["db-a"]) + }) + + test("a populated registry is not clobbered when the working project changes", () => { + const named = Registry.localConfigPath() + fs.mkdirSync(path.dirname(named), { recursive: true }) + fs.writeFileSync(named, JSON.stringify({ "proj-a": { type: "postgres" } })) + expect(Registry.names()).toEqual(["proj-a"]) + + const other = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "whl-hint-b-"))) + try { + fs.mkdirSync(path.join(other, ".altimate-code")) + fs.writeFileSync( + path.join(other, ".altimate-code", "connections.json"), + JSON.stringify({ "proj-b": { type: "postgres" } }), + ) + process.chdir(other) + expect(Registry.names()).toEqual(["proj-a"]) + } finally { + process.chdir(project) + fs.rmSync(other, { recursive: true, force: true }) + } + }) + + test("an empty registry first loaded for one project does not pick up another project's file", () => { + expect(Registry.names()).toEqual([]) + const dirA = project + const other = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "whl-hint-b-"))) + try { + fs.mkdirSync(path.join(other, ".altimate-code")) + fs.writeFileSync( + path.join(other, ".altimate-code", "connections.json"), + JSON.stringify({ "proj-b": { type: "postgres" } }), + ) + process.chdir(other) + expect(Registry.names()).toEqual([]) + expect(Registry.projectConfigApplies()).toBe(false) + expect(noConnectionsMessage()).not.toContain(Registry.localConfigPath()) + expect(noConnectionsMessage()).toContain(Registry.globalConfigPath()) + process.chdir(dirA) + expect(Registry.projectConfigApplies()).toBe(true) + // the same project still picks up its own file after an empty answer + fs.mkdirSync(path.join(dirA, ".altimate-code")) + fs.writeFileSync( + path.join(dirA, ".altimate-code", "connections.json"), + JSON.stringify({ "proj-a": { type: "postgres" } }), + ) + expect(Registry.names()).toEqual(["proj-a"]) + } finally { + process.chdir(dirA) + fs.rmSync(other, { recursive: true, force: true }) + } + }) + + test("setConfigs-injected connections are kept", () => { + Registry.setConfigs({ injected: { type: "postgres" } }) + expect(Registry.names()).toEqual(["injected"]) + }) +}) From 1ee2d31db64a97d4c1cf4e978746af8837f2c7e9 Mon Sep 17 00:00:00 2001 From: anandgupta42 <93243293+anandgupta42@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:13:50 -0700 Subject: [PATCH 32/39] fix: keep warehouse connection registry state per project directory (#1567) * fix: keep warehouse connection registry state per project directory - `registry.ts`: configs, connectors, in-flight connects and the load latch live in a `Map`; global file and env stay shared sources - SSH tunnel keys are project-scoped; stale in-flight connects are not cached after reload/remove/add - `setConfigs()` injects for all projects until `reset()`/`load()` - drop `projectConfigApplies()` Closes #1566 Co-Authored-By: Claude Opus 5.5 * fix: close the reload and reset windows for in-flight warehouse connects - `reload()` detaches and reloads before awaiting connector closes - `reset()` invalidates pending connects before dropping states Refs #1566 Co-Authored-By: Claude Opus 5.5 * fix: give each warehouse connect attempt its own SSH tunnel and dispose per project - `ssh-tunnel.ts`: unkeyed `openTunnel()` handles, closable while opening - registry: attempts close only their own tunnel; add/remove/reload/reset detach synchronously - instance disposal closes that project's connectors and tunnels - keep keychain secrets while a served project's local config names the connection Refs #1566 Co-Authored-By: Claude Opus 5.5 * fix: close remaining warehouse teardown gaps from review - `remove()` drops the global entry before awaiting cleanup; rewrites only a file holding it - `closeState()` closes all tunnels before awaiting connector closes - a clean SSH disconnect ends the tunnel - credential retention reads served projects after the awaits Refs #1566 Co-Authored-By: Claude Opus 5.5 * fix: keep session warehouse credentials across instance disposal - disposal closes a project's connectors and tunnels but keeps its configs - `remove()` writes the global file before touching state, so a failed write changes nothing - a dropped connect reports the superseded retry error, not its closed tunnel's error Refs #1566 Co-Authored-By: Claude Opus 5.5 * fix: keep shared credentials for untouched open projects and handle SSH forwarding errors - `remove()` also checks the local connections file of every open instance (`Instance.directories()`), not only projects that already loaded the registry. - SSH forwarding attaches `error` handlers to the local socket and remote stream and destroys both, so a reset no longer throws an uncaught exception. - Tests: `registry-per-project.test.ts` (untouched open project, socket error). Co-Authored-By: Claude Opus 5.5 * test: isolate `warehouse_list` hint tests from the shared global connections file `Registry.add()` in `telemetry-safety.test.ts` and `adversarial.test.ts` persists `test_add` and `evil` to the test-home global `connections.json`. When CI ran those files first in the same process, the empty-registry cases in `warehouse-list-hint.test.ts` listed them and failed. The hint tests now set that file aside in `beforeEach` and restore it in `afterEach`. Verified by seeding a global file before the suite: 5 failures before, 9/9 pass after. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 (cherry picked from commit 9137a1d8d15b4a2f7fff6f28e42b7327632c6612) Branch port: `loadFile` (`mcp serve --connections`) now installs the file through `setConfigs()`, so an explicit file is the one registry every project uses and is never re-read from project sources. Co-Authored-By: Claude Opus 5.5 --- .../native/connections/credential-store.ts | 4 +- .../altimate/native/connections/registry.ts | 434 +++++++++----- .../altimate/native/connections/ssh-tunnel.ts | 248 ++++---- .../src/altimate/tools/warehouse-list.ts | 6 +- packages/opencode/src/project/instance.ts | 5 + .../altimate/registry-per-project.test.ts | 536 ++++++++++++++++++ .../opencode/test/altimate/ssh-tunnel.test.ts | 15 +- .../test/altimate/warehouse-list-hint.test.ts | 12 +- 8 files changed, 984 insertions(+), 276 deletions(-) create mode 100644 packages/opencode/test/altimate/registry-per-project.test.ts diff --git a/packages/opencode/src/altimate/native/connections/credential-store.ts b/packages/opencode/src/altimate/native/connections/credential-store.ts index c70466cc23..2cf82ef872 100644 --- a/packages/opencode/src/altimate/native/connections/credential-store.ts +++ b/packages/opencode/src/altimate/native/connections/credential-store.ts @@ -167,7 +167,9 @@ export async function deleteCredential( * Entries are keyed by connection name only. Only `saveConnection` writes them, for the saved (global) connection * of that name; a project's own connections file never stores a secret here. A project file that defines a * connection of the same name without its secret was reading the saved connection's secret, and loses it when that - * saved connection is removed or re-saved without it. + * saved connection is removed or re-saved without it. The registry keeps the entries on removal while an env var or + * the local config of a project this process currently serves still names the connection; projects it does not + * serve (closed, never opened, or in another process) are not consulted. */ export async function forgetCredentials(name: string, keep?: ConnectionConfig): Promise { const store = await getBackend() diff --git a/packages/opencode/src/altimate/native/connections/registry.ts b/packages/opencode/src/altimate/native/connections/registry.ts index 53b6cb205c..486a55dec9 100644 --- a/packages/opencode/src/altimate/native/connections/registry.ts +++ b/packages/opencode/src/altimate/native/connections/registry.ts @@ -26,7 +26,10 @@ import { forgetCredentials, notRemovedWarning, resolveConfig, saveConnection } f // altimate_change start — the shared home, which honours the test-isolation override import { Global } from "@/global" // altimate_change end -import { startTunnel, extractSshConfig, closeTunnel } from "./ssh-tunnel" +// altimate_change start — tunnels are owned by the connect attempt that opened them; instance disposal hook +import { openTunnel, extractSshConfig, type TunnelHandle } from "./ssh-tunnel" +import { registerDisposer } from "@/effect/instance-registry" +// altimate_change end import type { WarehouseInfo } from "../types" import { Telemetry } from "../../../telemetry" import * as SignInNotice from "./sign-in-notice" @@ -36,17 +39,111 @@ import { fileLog } from "@/altimate/util/file-log" import { SCRATCH_CONFIG, SCRATCH_DESCRIPTION, SCRATCH_NAME, closeScratch, getScratch, isScratch } from "./scratch" // altimate_change end -/** In-memory config store. */ -let configs = new Map() +// altimate_change start — registry state per project directory +/** + * One project's registry. A server serves several project directories from one process (each request carries its + * own `Instance.directory`), so configs, connectors, in-flight connects and the load latch are kept per project: + * project A never sees project B's project-local entries, and switching projects never closes another project's + * connectors. The global file and `ALTIMATE_CODE_CONN_*` env are shared sources merged into each project's state. + */ +interface State { + /** The project directory this state was loaded for. */ + dir: string + /** In-memory config store. */ + configs: Map + /** Cached connector instances. */ + connectors: Map + /** SSH tunnels of cached connectors, owned by the attempt that created each connector. */ + tunnels: Map + /** In-flight connects. An attempt is current only while it occupies its name's slot. */ + pending: Map + /** Whether this state has been loaded from its sources. */ + loaded: boolean +} -/** Cached connector instances. */ -const connectors = new Map() +/** + * One connect attempt. Its SSH tunnel is its own handle, never looked up by name, so a superseded attempt can only + * close the tunnel it opened. Dropping the attempt from `pending` (reload, remove, add, dispose, reset) closes that + * tunnel and makes the attempt discard whatever it finishes creating. + */ +interface Attempt { + promise?: Promise + tunnel?: TunnelHandle +} -/** In-flight connector creation promises to prevent race conditions. */ -const pending = new Map>() +function createState(dir: string): State { + return { dir, configs: new Map(), connectors: new Map(), tunnels: new Map(), pending: new Map(), loaded: false } +} + +/** Registry state per resolved project directory. */ +const states = new Map() + +/** Invalidate the in-flight connect for `name`, closing the tunnel it opened. Synchronous. */ +function dropAttempt(s: State, name: string): void { + const attempt = s.pending.get(name) + s.pending.delete(name) + attempt?.tunnel?.close() +} -/** Whether the registry has been loaded. */ -let loaded = false +/** Synchronously detach the cached connector and tunnel for `name`; returns a task that closes them. */ +function detachCached(s: State, name: string): () => Promise { + const connector = s.connectors.get(name) + const tunnel = s.tunnels.get(name) + s.connectors.delete(name) + s.tunnels.delete(name) + return async () => { + // The tunnel is closed first so a hanging connector close cannot keep it open. + tunnel?.close() + await connector?.close().catch(() => {}) + } +} + +let disposerRegistered = false +/** + * Close a project's connectors, in-flight connects and tunnels when its instance is disposed (`/instance/dispose`, + * config updates, reload). Its configs stay: they may hold session-only credentials from `warehouse_add` (no OS + * credential store), which must last for the process, and a populated registry is never re-read anyway. + */ +function ensureDisposer(): void { + if (disposerRegistered) return + disposerRegistered = true + registerDisposer(async (directory) => { + const s = states.get(path.resolve(directory)) + if (s) await closeState(s) + }) +} + +/** + * Set by setConfigs() (tests, benchmarks): one injected registry that every project uses until reset() or load(), + * so a test that injects outside an instance still sees its configs from inside one. load() only deactivates it, + * so connectors it cached stay reachable by closeAll(). + */ +let injectedState: State | undefined +let injectionActive = false + +/** The registry state the current request uses: the injected one while active, else its project's. */ +function state(): State { + if (injectionActive && injectedState) return injectedState + return projectState() +} + +/** The registry state of the project the current request is working in. */ +function projectState(): State { + const dir = path.resolve(projectRoot()) + let s = states.get(dir) + if (!s) { + ensureDisposer() + s = createState(dir) + states.set(dir, s) + } + return s +} + +/** Every state that may hold connectors, for process-wide teardown. */ +function allStates(): State[] { + return injectedState ? [...states.values(), injectedState] : [...states.values()] +} +// altimate_change end const SCRATCH_NAME_CONFLICT = `Connection "${SCRATCH_NAME}" conflicts with the reserved built-in scratch warehouse name. ` + @@ -77,12 +174,7 @@ export function globalConfigPath(): string { * context — early CLI paths and unit tests — and the working directory is the * right answer in exactly those cases, since `run --dir` has already chdir'd. * - * The registry itself is still process-global: `loaded` latches on first - * access, so one server process serving two projects keeps the first project's - * configs for both. That predates this change — the base was simply the launch - * directory for everyone — and making the registry per-instance is a larger - * change than this fix. What this does guarantee is that whichever project - * loads resolves against itself, not against wherever the server was started. + * It is also the key of the per-project registry state (see `state()`). */ function projectRoot(): string { try { @@ -95,10 +187,16 @@ function projectRoot(): string { export function localConfigPath(): string { // altimate_change start — resolve against the project, not the launch cwd - return path.join(projectRoot(), ".altimate-code", "connections.json") + return localConfigPathFor(projectRoot()) // altimate_change end } +// altimate_change start — one definition of a project's connections file +function localConfigPathFor(projectDir: string): string { + return path.join(projectDir, ".altimate-code", "connections.json") +} +// altimate_change end + // --------------------------------------------------------------------------- // Store path resolution // --------------------------------------------------------------------------- @@ -196,20 +294,18 @@ function loadFromEnv(): Record { return result } -/** Load all connection configs. Local overrides global; env overrides both. */ -export function load(includeProjectConfig = true): void { +// altimate_change start — load into one project's state +/** Read the sources into `s`. Local overrides global; env overrides both. */ +function loadInto(s: State): void { + const configs = s.configs configs.clear() - // altimate_change start — absolutize store paths against the directory that - // declared them, so a later process.chdir() (--dir) cannot move the store. - const base = projectRoot() + // absolutize store paths against the directory that declared them, so a later process.chdir() (--dir) cannot + // move the store. `s.dir` is the project this state belongs to. + const base = s.dir const global = resolveStorePaths(loadFromFile(globalConfigPath()), path.dirname(globalConfigPath())) - // altimate_change start — see ensureLoaded(): a later reload must not pull in another project's file - const local = includeProjectConfig ? resolveStorePaths(loadFromFile(localConfigPath()), base) : {} - if (includeProjectConfig) loadedProjectDir = base - // altimate_change end + const local = resolveStorePaths(loadFromFile(localConfigPathFor(base)), base) const env = resolveStorePaths(loadFromEnv(), base) - // altimate_change end // Merge: global < local < env for (const [name, config] of Object.entries(global)) { @@ -223,13 +319,19 @@ export function load(includeProjectConfig = true): void { } if (configs.has(SCRATCH_NAME)) Log.Default.warn(SCRATCH_NAME_CONFLICT) - loaded = true - // altimate_change start — real sources were read, so the registry is no longer test-injected - injected = false - // altimate_change end + s.loaded = true } -/** Load only an explicit connections file, resolving stores relative to that file. */ +/** Load the current project's connection configs from their sources, ending any setConfigs() injection. */ +export function load(): void { + injectionActive = false + loadInto(projectState()) +} + +/** + * Load only an explicit connections file (`mcp serve --connections`), resolving stores relative to that file. Like + * setConfigs(), the file becomes the one registry every project uses and is never re-read from project sources. + */ export async function loadFile(file: string): Promise { await closeAll() const absolute = path.resolve(file) @@ -242,41 +344,23 @@ export async function loadFile(file: string): Promise { throw new Error(`Invalid connection: ${name}`) } } - configs = new Map( - Object.entries(resolveStorePaths(parsed as Record, path.dirname(absolute))), - ) - if (configs.has(SCRATCH_NAME)) Log.Default.warn(SCRATCH_NAME_CONFLICT) - loaded = true + setConfigs(resolveStorePaths(parsed as Record, path.dirname(absolute))) + if (state().configs.has(SCRATCH_NAME)) Log.Default.warn(SCRATCH_NAME_CONFLICT) censusSent = false } -/** Ensure configs are loaded. */ -function ensureLoaded(): void { - if (!loaded) load() - // altimate_change start — the registry latches on first load, so a connections file created after an empty - // answer (the `warehouse_list` hint) was never seen. Re-read only while nothing is loaded: there is no state to - // replace or close, and a populated registry never changes under a running session. - else if (configs.size === 0 && !injected) load(projectConfigApplies()) - // altimate_change end -} - -// altimate_change start — the registry is process-global; this is the project whose local file it was loaded with -let loadedProjectDir: string | undefined - -/** - * Whether the current project's local connections file is the one this registry reads. A server serving several - * directories shares one registry (pre-existing: the first load's project-local file applies process-wide), so a - * later empty-registry reload must not load another project's file into it. - */ -export function projectConfigApplies(): boolean { - return loadedProjectDir === undefined || loadedProjectDir === projectRoot() +/** Ensure the current project's configs are loaded; returns its state. */ +function ensureLoaded(): State { + const s = state() + if (!s.loaded) loadInto(s) + // A connections file created after an empty answer (the `warehouse_list` hint) must be seen. Re-read only while + // this project's registry is empty: there is nothing to replace or close, and a populated registry never changes + // under a running session. An injected registry (setConfigs) is never re-read. + else if (s.configs.size === 0 && s !== injectedState) loadInto(s) + return s } // altimate_change end -// altimate_change start — set by setConfigs() (tests): an injected empty registry is not a stale one -let injected = false -// altimate_change end - // --------------------------------------------------------------------------- // Driver factory // --------------------------------------------------------------------------- @@ -325,7 +409,17 @@ export function canonicalType(type: string | undefined | null): string | null { } // altimate_change end -async function createConnector(name: string, config: ConnectionConfig): Promise { +// altimate_change start — the attempt owns the SSH tunnel it opens +const SUPERSEDED = (name: string) => + new Error(`Connection "${name}" was reloaded or removed while it was connecting. Retry the request.`) + +async function createConnector( + name: string, + config: ConnectionConfig, + attempt: Attempt, + isCurrent: () => boolean, +): Promise { + // altimate_change end const driverPath = DRIVER_MAP[config.type.toLowerCase()] if (!driverPath) { // altimate_change start — friendlier error for known-but-unsupported databases @@ -381,12 +475,21 @@ async function createConnector(name: string, config: ConnectionConfig): Promise< // Handle SSH tunnel const sshConfig = extractSshConfig(resolvedConfig) if (sshConfig) { - const tunnel = await startTunnel(name, sshConfig) + // altimate_change start — a superseded attempt opens nothing; a tunnel that finishes opening after its attempt + // was dropped is closed by its own handle (dropAttempt closed it already) and never published + if (!isCurrent()) throw SUPERSEDED(name) + attempt.tunnel = openTunnel(sshConfig) + const tunnelInfo = await attempt.tunnel.ready + if (!isCurrent()) { + attempt.tunnel.close() + throw SUPERSEDED(name) + } + // altimate_change end // Rewrite host/port to use the local tunnel resolvedConfig = { ...resolvedConfig, host: "127.0.0.1", - port: tunnel.localPort, + port: tunnelInfo.localPort, } } @@ -440,9 +543,9 @@ async function createConnector(name: string, config: ConnectionConfig): Promise< connector = await mod.connect(resolvedConfig) } catch (e) { // Clean up SSH tunnel if driver creation fails - if (sshConfig) { - closeTunnel(name) - } + // altimate_change start — this attempt's own tunnel, never another attempt's + attempt.tunnel?.close() + // altimate_change end throw e } return connector @@ -549,14 +652,16 @@ function accountOf(config: ConnectionConfig | undefined): string { // altimate_change start — connection names without the census `list()` sends (used by `debug bundle`) export function names(): string[] { - ensureLoaded() - return [...configs.keys()] + return [...ensureLoaded().configs.keys()] } // altimate_change end /** Get a connector instance (creates lazily). */ export async function get(name: string): Promise { - ensureLoaded() + // altimate_change start — captured once, so a connect that finishes later lands in the project that started it + const s = ensureLoaded() + const { configs, connectors, tunnels, pending } = s + // altimate_change end if (isScratch(name)) { if (configs.has(name)) throw new Error(SCRATCH_NAME_CONFLICT) return getScratch() @@ -571,8 +676,10 @@ export async function get(name: string): Promise { } // If a connector is already being created, await the same Promise - const inflight = pending.get(name) + // altimate_change start — the slot holds the attempt + const inflight = pending.get(name)?.promise if (inflight) return inflight + // altimate_change end const config = configs.get(name) if (!config) { @@ -599,17 +706,33 @@ export async function get(name: string): Promise { }) } catch {} // altimate_change end + // altimate_change start — this attempt occupies the slot before any await; reload()/remove()/add()/dispose drop it + const attempt: Attempt = {} + pending.set(name, attempt) + const current = () => pending.get(name) === attempt + // altimate_change end const promise = (async () => { try { - const connector = await createConnector(name, config) + // altimate_change start — the attempt closes only the tunnel it opened + const connector = await createConnector(name, config, attempt, current) try { await connector.connect() } catch (connectErr) { - // If connect() fails after tunnel was started, clean up the tunnel - closeTunnel(name) + // If connect() fails after tunnel was started, clean up the tunnel (first, so a hanging close cannot hold it) + attempt.tunnel?.close() + await connector.close().catch(() => {}) throw connectErr } + // reload()/remove()/add() dropped this attempt while it was connecting: its config is gone or replaced, so + // it must not be cached over the current one. + if (!current()) { + attempt.tunnel?.close() + await connector.close().catch(() => {}) + throw SUPERSEDED(name) + } connectors.set(name, connector) + if (attempt.tunnel) tunnels.set(name, attempt.tunnel) + // altimate_change end fileLog("INFO", "warehouse-connect", "connected", { name, type: config.type, auth: authMethod, duration_ms: Date.now() - startTime }) try { Telemetry.track({ @@ -623,7 +746,11 @@ export async function get(name: string): Promise { }) } catch {} return connector - } catch (e) { + } catch (caught) { + // altimate_change start — an attempt dropped by reload/remove/add/dispose fails with whatever its closed tunnel + // or socket raised; report it as superseded (with the retry hint), not as a broken connection + const e = current() ? caught : SUPERSEDED(name) + // altimate_change end fileLog("WARN", "warehouse-connect", "connect failed", { name, type: config?.type, @@ -647,11 +774,15 @@ export async function get(name: string): Promise { } catch {} throw e } finally { - pending.delete(name) + // altimate_change start — a newer attempt for this name may already own the slot + if (current()) pending.delete(name) + // altimate_change end } })() - pending.set(name, promise) + // altimate_change start + attempt.promise = promise + // altimate_change end return promise } @@ -660,7 +791,9 @@ let censusSent = false /** List all configured connections. */ export function list(): { warehouses: (WarehouseInfo & { conflict?: string })[] } { - ensureLoaded() + // altimate_change start — the current project's registry + const { configs } = ensureLoaded() + // altimate_change end const warehouses: (WarehouseInfo & { conflict?: string })[] = [] for (const [name, config] of configs) { const type = typeof config.type === "string" ? config.type.toLowerCase() : "" @@ -721,8 +854,11 @@ export async function test( recoverable?: boolean }> { try { + // altimate_change start — the same project's state get() used + const s = state() const connector = await get(name) - const config = configs.get(name) + const config = s.configs.get(name) + // altimate_change end const dbType = config?.type?.toLowerCase() if (dbType === "mongodb" || dbType === "mongo") { // MongoDB doesn't support SQL — use the standard ping command @@ -763,7 +899,10 @@ export async function add( } // altimate_change end try { - ensureLoaded() + // altimate_change start — act on the current project's state, captured before any await + const s = ensureLoaded() + const { configs } = s + // altimate_change end // Normalize field names before saving so sensitive fields under alias // names (e.g., keyfileJson → credentials_json) are properly detected @@ -797,17 +936,12 @@ export async function add( // session can connect even when keytar is unavailable. Only the disk // file uses the sanitized version (credentials stripped). configs.set(name, normalized) - - // Clear cached connector - const cached = connectors.get(name) - if (cached) { - try { - await cached.close() - } catch { - // ignore - } - connectors.delete(name) - } + // altimate_change start — in the same synchronous step: a connect still in flight for the old config must not be + // cached over the new one, and the old connector's tunnel closes with it + dropAttempt(s, name) + const release = detachCached(s, name) + await release() + // altimate_change end const result: { success: boolean; name: string; type: string; warnings?: string[] } = { success: true, @@ -829,38 +963,38 @@ export async function remove(name: string): Promise<{ success: boolean; error?: if (isScratch(name)) return { success: false, error: `"${SCRATCH_NAME}" is built in and cannot be removed.` } // altimate_change end try { - ensureLoaded() - - // Close connector if cached - const cached = connectors.get(name) - if (cached) { - try { - await cached.close() - } catch { - // ignore - } - connectors.delete(name) - } - - // Close SSH tunnel if active - closeTunnel(name) + // altimate_change start — act on the current project's state, captured before any await + const s = ensureLoaded() // A connect still in flight for this name will finish and keep its name in use; forgetting it now would let a // later reconnect on that account be put down to another connection that shares it. - if (!pending.has(name)) ReconnectLog.forget(name) - - // Remove from global config file + const inflight = s.pending.has(name) + const projectConfigPath = localConfigPath() + // One synchronous step before any await. The global file is written first, so a failed write leaves this + // project's state untouched. Then the in-flight connect is invalidated (and its tunnel closed), the config is + // gone so no connect or empty-registry re-read can bring it back, and the cached connector and tunnel are + // detached. Only a file that holds the entry is rewritten (a missing global directory is not an error). const globalPath = globalConfigPath() const existing = loadFromFile(globalPath) - delete existing[name] - fs.writeFileSync(globalPath, JSON.stringify(existing, null, 2), "utf-8") - - // Remove from in-memory - configs.delete(name) + if (Object.hasOwn(existing, name)) { + delete existing[name] + fs.writeFileSync(globalPath, JSON.stringify(existing, null, 2), "utf-8") + } + dropAttempt(s, name) + s.configs.delete(name) + const release = detachCached(s, name) + await release() + if (!inflight) ReconnectLog.forget(name) + // altimate_change end // altimate_change start — remove its secrets from the OS credential store too. Entries are keyed by name - // only, so they are kept while this project's config or an env var still defines a connection by that name. - // (Own properties only: a connection named "constructor" must not count as defined.) - const stillDefined = Object.hasOwn(loadFromFile(localConfigPath()), name) || Object.hasOwn(loadFromEnv(), name) + // only, so they are kept while an env var or the local config of a project this process serves still defines a + // connection by that name (see forgetCredentials); the served set is read now, after the awaits above. (Own + // properties only: a connection named "constructor" must not count as defined.) + // Open instances may not have performed a warehouse operation yet, so states alone is incomplete. + const projectDirs = new Set([...states.keys(), ...Instance.directories()]) + const projectConfigPaths = [projectConfigPath, ...[...projectDirs].map(localConfigPathFor)] + const stillDefined = + projectConfigPaths.some((file) => Object.hasOwn(loadFromFile(file), name)) || Object.hasOwn(loadFromEnv(), name) const notRemoved = stillDefined ? [] : await forgetCredentials(name) if (notRemoved.length > 0) return { success: true, warnings: [notRemovedWarning(name, notRemoved)] } // altimate_change end @@ -872,64 +1006,78 @@ export async function remove(name: string): Promise<{ success: boolean; error?: } // altimate_change start — a way to release native handles without reloading +/** Close one project's cached connectors and their SSH tunnels, and drop its in-flight connects. */ +async function closeState(s: State): Promise { + // Detach everything synchronously, so nothing started during the awaits below is dropped or reuses a closing + // connector. In-flight attempts are invalidated and their tunnels closed by their own handles. + for (const name of [...s.pending.keys()]) dropAttempt(s, name) + const releases = [...s.connectors.keys()].map((name) => detachCached(s, name)) + // Started together: each release closes its tunnel synchronously, so one hanging connector close holds no tunnel. + await Promise.all(releases.map((release) => release())) +} + /** - * Close every cached connector and drop it from the cache. + * Close every project's cached connectors (and the scratch warehouse) and drop them from the cache — process-wide + * teardown. * * `reset()` cannot do this: it is synchronous, and `close()` is not. Callers - * that create real connectors (tests, and `reload()` below) must await this, + * that create real connectors (tests) must await this, * otherwise the native handle behind each connector stays open — on Windows * that also blocks deleting the file underneath it. */ export async function closeAll(): Promise { await closeScratch() - for (const [, connector] of connectors) { - try { - await connector.close() - } catch { - // ignore - } - } - connectors.clear() + for (const s of allStates()) await closeState(s) } // altimate_change end -/** Reload all configs and clear cached connectors. */ +/** Reload the current project's configs and clear its cached connectors; other projects are untouched. */ export async function reload(): Promise { - await closeAll() - loaded = false - load() + // altimate_change start — only this project's connectors. The new configs are in place before the first await, + // so no connect can start against the old ones while the old connectors close. + const s = state() + const target = projectState() + const closing = closeState(s) + injectionActive = false + loadInto(target) + await closing + // altimate_change end } /** Get the raw config for a connection (for testing). */ export function getConfig(name: string): ConnectionConfig | undefined { - ensureLoaded() + // altimate_change start — the current project's registry + const { configs } = ensureLoaded() + // altimate_change end if (isScratch(name) && !configs.has(name)) return { ...SCRATCH_CONFIG } return configs.get(name) } -/** Reset the registry state (for testing). */ +/** Reset the registry state of every project (for testing). */ export function reset(): void { - configs.clear() - connectors.clear() - pending.clear() - loaded = false - censusSent = false - // altimate_change start - injected = false - loadedProjectDir = undefined + // altimate_change start — invalidate in-flight connects first, so none is cached into a dropped state + for (const s of allStates()) for (const name of [...s.pending.keys()]) dropAttempt(s, name) + states.clear() + injectedState = undefined + injectionActive = false // altimate_change end + censusSent = false } /** - * Set configs directly (for testing without file system). + * Set configs directly (for testing without file system). Every project uses them until reset() or load(). */ export function setConfigs(newConfigs: Record): void { + // altimate_change start — one injected state shared by every project; its cached connectors are kept, as before + injectedState ??= createState("") + injectionActive = true + const configs = injectedState.configs + // altimate_change end configs.clear() for (const [name, config] of Object.entries(newConfigs)) { configs.set(name, config) } - loaded = true // altimate_change start - injected = true + injectedState.loaded = true // altimate_change end } diff --git a/packages/opencode/src/altimate/native/connections/ssh-tunnel.ts b/packages/opencode/src/altimate/native/connections/ssh-tunnel.ts index 4a837fb01c..27a4ffecd5 100644 --- a/packages/opencode/src/altimate/native/connections/ssh-tunnel.ts +++ b/packages/opencode/src/altimate/native/connections/ssh-tunnel.ts @@ -3,15 +3,29 @@ * * Uses the `ssh2` package (dynamic import). If ssh2 is not installed, * throws a clear error message. + * + * Tunnels are not keyed by connection name. Each `openTunnel()` call returns a + * handle owned by the caller (the connection registry's connect attempt), and + * only that handle closes it: an attempt that was superseded can never register + * over, or close, a newer attempt's tunnel. */ import type { ConnectionConfig } from "@altimateai/drivers" +import type { Server, Socket } from "net" +import type { Duplex } from "stream" export interface TunnelInfo { localPort: number close(): void } +/** A tunnel being opened. `close()` works before or after `ready` settles and is idempotent. */ +export interface TunnelHandle { + /** Resolves once the local forwarding port listens; rejects on failure or when closed first. */ + readonly ready: Promise + close(): void +} + export interface SshConfig { ssh_host: string ssh_port?: number @@ -22,161 +36,165 @@ export interface SshConfig { port: number } -/** Active tunnels keyed by connection name. */ -const activeTunnels = new Map() +/** Every tunnel opened and not yet closed, for process-exit cleanup and `closeAllTunnels()`. */ +const openTunnels = new Set() /** Clean up all tunnels on process exit. */ let cleanupRegistered = false function ensureCleanup(): void { if (cleanupRegistered) return cleanupRegistered = true - const cleanup = () => { - for (const [, tunnel] of activeTunnels) { - try { - tunnel.close() - } catch { - // best-effort cleanup - } - } - activeTunnels.clear() - } - process.once("exit", cleanup) + process.once("exit", closeAllTunnels) process.once("SIGINT", () => { - cleanup() + closeAllTunnels() process.exit(0) }) process.once("SIGTERM", () => { - cleanup() + closeAllTunnels() process.exit(0) }) } /** - * Start an SSH tunnel for a connection. - * Returns a TunnelInfo with the local port to connect to. + * Open an SSH tunnel that forwards a local port to `config.host:config.port`. + * + * One terminal state covers every path: an error, a close before `ready`, or a + * late `ready`/listen callback after either closes the client, the local server + * and forwarded sockets, and never publishes a tunnel. */ -export async function startTunnel( - name: string, - config: SshConfig, -): Promise { - // Close existing tunnel for this name - const existing = activeTunnels.get(name) - if (existing) { - existing.close() - activeTunnels.delete(name) - } - - let ssh2: any - try { - // @ts-expect-error — optional dependency - ssh2 = await import("ssh2") - } catch { - throw new Error( - "SSH tunnel requires the ssh2 package. Run: bun add ssh2 @types/ssh2", - ) +export function openTunnel(config: SshConfig): TunnelHandle { + let closed = false + let client: any + let server: Server | undefined + const sockets = new Set() + let rejectReady: ((e: Error) => void) | undefined + + const close = () => { + if (closed) return + closed = true + openTunnels.delete(handle) + for (const socket of sockets) socket.destroy() + try { + server?.close() + } catch { + // ignore + } + try { + client?.end() + } catch { + // ignore + } + rejectReady?.(new Error("SSH tunnel was closed before it was ready")) } - ensureCleanup() - - const net = await import("net") - - return new Promise((resolve, reject) => { - const client = new ssh2.Client() - - client.on("ready", () => { - // Create a local TCP server that forwards to remote host:port via SSH - const server = net.createServer((localSocket) => { - client.forwardOut( - "127.0.0.1", - 0, - config.host, - config.port, - (err: Error | undefined, stream: any) => { - if (err) { + const ready = (async (): Promise => { + let ssh2: any + try { + // @ts-expect-error — optional dependency + ssh2 = await import("ssh2") + } catch { + throw new Error("SSH tunnel requires the ssh2 package. Run: bun add ssh2 @types/ssh2") + } + const net = await import("net") + if (closed) throw new Error("SSH tunnel was closed before it was ready") + ensureCleanup() + + return new Promise((resolve, reject) => { + rejectReady = reject + client = new ssh2.Client() + + client.on("ready", () => { + if (closed) return + // Create a local TCP server that forwards to remote host:port via SSH + server = net.createServer((localSocket) => { + let remoteStream: Duplex | undefined + localSocket.on("error", () => { + remoteStream?.destroy() + localSocket.destroy() + }) + if (closed) { + localSocket.destroy() + return + } + sockets.add(localSocket) + localSocket.on("close", () => sockets.delete(localSocket)) + client.forwardOut("127.0.0.1", 0, config.host, config.port, (err: Error | undefined, stream: any) => { + remoteStream = stream + stream?.on("error", () => { + localSocket.destroy() + stream.destroy() + }) + if (err || closed || localSocket.destroyed) { + stream?.destroy() localSocket.destroy() return } localSocket.pipe(stream).pipe(localSocket) - }, - ) + }) + }) + + server.on("error", (err: Error) => { + reject(new Error(`SSH tunnel local server error: ${err.message}`)) + close() + }) + + server.listen(0, "127.0.0.1", () => { + if (closed) return + const addr = server?.address() + const localPort = typeof addr === "object" && addr ? addr.port : 0 + resolve({ localPort, close }) + }) }) - server.listen(0, "127.0.0.1", () => { - const addr = server.address() - const localPort = - typeof addr === "object" && addr ? addr.port : 0 - - const tunnelInfo: TunnelInfo = { - localPort, - close() { - try { - server.close() - } catch { - // ignore - } - try { - client.end() - } catch { - // ignore - } - activeTunnels.delete(name) - }, - } - - activeTunnels.set(name, tunnelInfo) - resolve(tunnelInfo) + client.on("error", (err: Error) => { + reject(new Error(`SSH connection error: ${err.message}`)) + close() }) - server.on("error", (err: Error) => { - client.end() - reject(new Error(`SSH tunnel local server error: ${err.message}`)) + // A clean remote disconnect ends the tunnel too: before ready it rejects, after it closes the listener. + client.on("close", () => { + reject(new Error("SSH connection closed")) + close() }) - }) - - client.on("error", (err: Error) => { - reject(new Error(`SSH connection error: ${err.message}`)) - }) - - const connectOptions: Record = { - host: config.ssh_host, - port: config.ssh_port ?? 22, - username: config.ssh_user ?? "root", - } - if (config.ssh_private_key) { - connectOptions.privateKey = config.ssh_private_key - } else if (config.ssh_password) { - connectOptions.password = config.ssh_password - } + const connectOptions: Record = { + host: config.ssh_host, + port: config.ssh_port ?? 22, + username: config.ssh_user ?? "root", + } - client.connect(connectOptions) - }) -} + if (config.ssh_private_key) { + connectOptions.privateKey = config.ssh_private_key + } else if (config.ssh_password) { + connectOptions.password = config.ssh_password + } -/** Get an active tunnel by connection name. */ -export function getActiveTunnel(name: string): TunnelInfo | undefined { - return activeTunnels.get(name) -} + client.connect(connectOptions) + }) + })() -/** Close a specific tunnel by name. */ -export function closeTunnel(name: string): void { - const tunnel = activeTunnels.get(name) - if (tunnel) { - tunnel.close() - activeTunnels.delete(name) - } + const handle: TunnelHandle = { ready, close } + openTunnels.add(handle) + // Any failure ends in the terminal state; the caller still observes the rejection through `ready`. + ready.catch(() => close()) + return handle } -/** Close all active tunnels. */ +/** Close every open tunnel (process teardown and tests). */ export function closeAllTunnels(): void { - for (const [, tunnel] of activeTunnels) { + for (const tunnel of [...openTunnels]) { try { tunnel.close() } catch { // best-effort } } - activeTunnels.clear() + openTunnels.clear() +} + +/** Number of tunnels currently open or opening (diagnostics and tests). */ +export function openTunnelCount(): number { + return openTunnels.size } /** diff --git a/packages/opencode/src/altimate/tools/warehouse-list.ts b/packages/opencode/src/altimate/tools/warehouse-list.ts index 5e12a4bdf9..4816d5fd57 100644 --- a/packages/opencode/src/altimate/tools/warehouse-list.ts +++ b/packages/opencode/src/altimate/tools/warehouse-list.ts @@ -8,7 +8,7 @@ import * as Precedence from "../workspace/precedence" import { discoverCatalog } from "./warehouse-discovery" // altimate_change start — point at the locations the registry actually reads -import { CONN_ENV_PREFIX, globalConfigPath, localConfigPath, projectConfigApplies } from "../native/connections/registry" +import { CONN_ENV_PREFIX, globalConfigPath, localConfigPath } from "../native/connections/registry" /** The no-connections hint. Paths come from the registry's own helpers so it cannot drift from what is loaded. */ export function noConnectionsMessage(): string { @@ -19,8 +19,8 @@ export function noConnectionsMessage(): string { "", "Alternatively, connections are read from these sources (later ones override earlier ones):", ` 1. ${globalConfigPath()} (global)`, - ...(projectConfigApplies() ? [` 2. ${localConfigPath()} (this project)`] : []), - ` ${projectConfigApplies() ? 3 : 2}. ${CONN_ENV_PREFIX} environment variables, each holding one connection's config as JSON (set them in the shell before starting altimate-code; a running process cannot see variables exported later)`, + ` 2. ${localConfigPath()} (this project)`, + ` 3. ${CONN_ENV_PREFIX} environment variables, each holding one connection's config as JSON (set them in the shell before starting altimate-code; a running process cannot see variables exported later)`, "", "After adding the first connection file, call warehouse_list again. Edits to an existing connections.json take effect after a restart. Example connections.json:", "{", diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 5ed8c18373..22d3410862 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -81,6 +81,11 @@ export const Instance = { return input.fn() }) }, + // altimate_change start — enumerate open projects even before their warehouse registry is loaded + directories(): string[] { + return [...cache.keys()] + }, + // altimate_change end get current() { return context.use() }, diff --git a/packages/opencode/test/altimate/registry-per-project.test.ts b/packages/opencode/test/altimate/registry-per-project.test.ts new file mode 100644 index 0000000000..f192d63a71 --- /dev/null +++ b/packages/opencode/test/altimate/registry-per-project.test.ts @@ -0,0 +1,536 @@ +/** + * The connection registry keeps one state per project directory. + * + * One server process serves several project directories (each request carries its own `Instance.directory`). The + * registry used to be process-global, so the first project to load decided every project's connections, and closing + * or reloading in one project closed another project's connectors. These tests drive two project directories in one + * process, through `process.chdir` (the CLI path) and through `Instance.provide` (the server path). + */ + +import { describe, test, expect, beforeEach, afterEach, afterAll, mock, spyOn } from "bun:test" +import * as fs from "fs" +import * as os from "os" +import * as path from "path" +import * as net from "net" +import { EventEmitter } from "events" +import { PassThrough } from "stream" + +// ssh2 is an optional dependency, normally not installed for tests; a fake client lets the real tunnel module run. +// Bun's mock.module leaks across test files, so the genuine module (or its absence) is restored in afterAll. +let genuineSsh2: unknown +try { + // @ts-expect-error — optional dependency + genuineSsh2 = await import("ssh2") +} catch { + genuineSsh2 = undefined +} +afterAll(() => { + // Absent: a module cannot be un-mocked, so later files get a client that fails loudly instead of this fake. + mock.module("ssh2", () => + genuineSsh2 + ? (genuineSsh2 as Record) + : { + Client: class { + constructor() { + throw new Error("Cannot find package 'ssh2'") + } + }, + }, + ) +}) + +const sshClients: { host: string; ended: boolean }[] = [] +/** Hosts whose SSH `ready` is held until the test releases it. */ +const heldReady = new Map void>() +/** Simulates the SSH server closing the connection cleanly (no error event). */ +const remoteClose = new Map void>() +let forwardedStream: PassThrough | undefined +mock.module("ssh2", () => ({ + Client: class { + private handlers = new Map void>() + private record = { host: "", ended: false } + on(event: string, cb: (...args: unknown[]) => void) { + this.handlers.set(event, cb) + return this + } + connect(options: { host: string }) { + this.record.host = options.host + sshClients.push(this.record) + const ready = () => this.handlers.get("ready")?.() + remoteClose.set(options.host, () => this.handlers.get("close")?.()) + if (options.host.startsWith("held-")) heldReady.set(options.host, ready) + else queueMicrotask(ready) + } + forwardOut(_a: string, _b: number, _c: string, _d: number, cb: (err?: Error, stream?: PassThrough) => void) { + if (forwardedStream) cb(undefined, forwardedStream) + else cb(new Error("no forwarding in tests")) + } + end() { + this.record.ended = true + } + }, +})) + +import * as Registry from "../../src/altimate/native/connections/registry" +import { openTunnel, closeAllTunnels, openTunnelCount } from "../../src/altimate/native/connections/ssh-tunnel" +import { Instance } from "../../src/project/instance" +import { disposeInstance } from "../../src/effect/instance-registry" + +/** Wait (bounded) until the fake SSH client for `host` has connected. */ +async function sshConnected(host: string) { + for (let i = 0; i < 500 && !sshClients.some((c) => c.host === host); i++) await new Promise((r) => setTimeout(r, 2)) + expect(sshClients.some((c) => c.host === host)).toBe(true) +} + +const ended = (host: string) => sshClients.filter((c) => c.host === host).map((c) => c.ended) + +function sshStore(sshHost: string, extra: Record = {}) { + return { type: "sqlite", path: "store.sqlite", create: true, ssh_host: sshHost, host: "db", port: 5432, ...extra } +} + +let projectA: string +let projectB: string +let home: string +let prevCwd: string +let prevHome: string | undefined +let ambientConnEnv: Record + +function writeLocal(dir: string, entries: Record) { + fs.mkdirSync(path.join(dir, ".altimate-code"), { recursive: true }) + fs.writeFileSync(path.join(dir, ".altimate-code", "connections.json"), JSON.stringify(entries)) +} + +function writeGlobal(entries: Record) { + fs.mkdirSync(path.join(home, ".altimate-code"), { recursive: true }) + fs.writeFileSync(path.join(home, ".altimate-code", "connections.json"), JSON.stringify(entries)) +} + +function inProject(dir: string, fn: () => T): T { + process.chdir(dir) + return fn() +} + +beforeEach(() => { + // Ambient connection env vars would leak the caller's shell into every assertion. + ambientConnEnv = {} + for (const [key, value] of Object.entries(process.env)) { + if (!key.startsWith(Registry.CONN_ENV_PREFIX) || value === undefined) continue + ambientConnEnv[key] = value + delete process.env[key] + } + prevCwd = process.cwd() + prevHome = process.env.OPENCODE_TEST_HOME + home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "rpp-home-"))) + process.env.OPENCODE_TEST_HOME = home + projectA = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "rpp-a-"))) + projectB = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "rpp-b-"))) + forwardedStream = undefined + sshClients.length = 0 + heldReady.clear() + remoteClose.clear() + Registry.reset() +}) + +afterEach(async () => { + await Registry.closeAll() + closeAllTunnels() + Registry.reset() + process.chdir(prevCwd) + if (prevHome === undefined) delete process.env.OPENCODE_TEST_HOME + else process.env.OPENCODE_TEST_HOME = prevHome + for (const dir of [projectA, projectB, home]) fs.rmSync(dir, { recursive: true, force: true }) + for (const key of Object.keys(process.env)) { + if (key.startsWith(Registry.CONN_ENV_PREFIX)) delete process.env[key] + } + Object.assign(process.env, ambientConnEnv) +}) + +describe("project-local connections are per project", () => { + test("A's local entry is invisible to B, and B's to A (A loads first)", () => { + writeLocal(projectA, { only_a: { type: "postgres", host: "a" } }) + writeLocal(projectB, { only_b: { type: "postgres", host: "b" } }) + expect(inProject(projectA, () => Registry.names())).toEqual(["only_a"]) + expect(inProject(projectB, () => Registry.names())).toEqual(["only_b"]) + expect(inProject(projectA, () => Registry.getConfig("only_b"))).toBeUndefined() + expect(inProject(projectB, () => Registry.getConfig("only_a"))).toBeUndefined() + }) + + test("the same holds when B loads first", () => { + writeLocal(projectA, { only_a: { type: "postgres", host: "a" } }) + writeLocal(projectB, { only_b: { type: "postgres", host: "b" } }) + expect(inProject(projectB, () => Registry.names())).toEqual(["only_b"]) + expect(inProject(projectA, () => Registry.names())).toEqual(["only_a"]) + expect(inProject(projectB, () => Registry.list().warehouses.map((w) => w.name))).not.toContain("only_a") + }) + + test("a file added late to one project is seen there only, in either order", () => { + expect(inProject(projectA, () => Registry.names())).toEqual([]) + expect(inProject(projectB, () => Registry.names())).toEqual([]) + + writeLocal(projectB, { late_b: { type: "postgres" } }) + expect(inProject(projectA, () => Registry.names())).toEqual([]) + expect(inProject(projectB, () => Registry.names())).toEqual(["late_b"]) + + writeLocal(projectA, { late_a: { type: "postgres" } }) + expect(inProject(projectA, () => Registry.names())).toEqual(["late_a"]) + expect(inProject(projectB, () => Registry.names())).toEqual(["late_b"]) + }) + + test("each project re-reads only while its own registry is empty", () => { + writeLocal(projectA, { a1: { type: "postgres" } }) + expect(inProject(projectA, () => Registry.names())).toEqual(["a1"]) + expect(inProject(projectB, () => Registry.names())).toEqual([]) + + // A is populated: an edit is not picked up. B is empty: its new file is. + writeLocal(projectA, { a1: { type: "postgres" }, a2: { type: "postgres" } }) + writeLocal(projectB, { b1: { type: "postgres" } }) + expect(inProject(projectA, () => Registry.names())).toEqual(["a1"]) + expect(inProject(projectB, () => Registry.names())).toEqual(["b1"]) + }) + + test("global and env entries are visible in both projects", () => { + writeGlobal({ shared_global: { type: "postgres", host: "g" } }) + process.env.ALTIMATE_CODE_CONN_SHARED_ENV = JSON.stringify({ type: "postgres", host: "e" }) + writeLocal(projectA, { only_a: { type: "postgres" } }) + expect(inProject(projectA, () => Registry.names().sort())).toEqual(["only_a", "shared_env", "shared_global"]) + expect(inProject(projectB, () => Registry.names().sort())).toEqual(["shared_env", "shared_global"]) + }) + + test("the server path: Instance.provide with two directories", async () => { + writeLocal(projectA, { srv: { type: "postgres", host: "host-a" } }) + writeLocal(projectB, { srv: { type: "postgres", host: "host-b" } }) + const hostIn = (dir: string) => + Instance.provide({ directory: dir, fn: async () => Registry.getConfig("srv")?.host }) + expect(await hostIn(projectA)).toBe("host-a") + expect(await hostIn(projectB)).toBe("host-b") + expect(await hostIn(projectA)).toBe("host-a") + }) +}) + +describe("connectors are per project", () => { + test("the same name resolves to each project's own config and connector", async () => { + writeLocal(projectA, { store: { type: "sqlite", path: "store.sqlite", create: true } }) + writeLocal(projectB, { store: { type: "sqlite", path: "store.sqlite", create: true } }) + expect(inProject(projectA, () => Registry.getConfig("store")?.path)).toBe(path.join(projectA, "store.sqlite")) + expect(inProject(projectB, () => Registry.getConfig("store")?.path)).toBe(path.join(projectB, "store.sqlite")) + + const a = await inProject(projectA, () => Registry.get("store")) + await a.execute("CREATE TABLE marker_a (x INTEGER)") + const b = await inProject(projectB, () => Registry.get("store")) + expect(b).not.toBe(a) + const tables = await b.execute("SELECT name FROM sqlite_master WHERE type = 'table'") + expect(tables.rows.flat()).not.toContain("marker_a") + // the cached connector is per project too + expect(await inProject(projectA, () => Registry.get("store"))).toBe(a) + }) + + test("removing or reloading in B does not close A's connector", async () => { + writeLocal(projectA, { store: { type: "sqlite", path: "store.sqlite", create: true } }) + writeLocal(projectB, { store: { type: "sqlite", path: "store.sqlite", create: true } }) + const a = await inProject(projectA, () => Registry.get("store")) + await inProject(projectB, () => Registry.get("store")) + + await inProject(projectB, () => Registry.reload()) + expect((await a.execute("SELECT 1 AS one")).rows).toEqual([[1]]) + await inProject(projectB, () => Registry.remove("store")) + expect((await a.execute("SELECT 1 AS one")).rows).toEqual([[1]]) + expect(await inProject(projectA, () => Registry.get("store"))).toBe(a) + // B's local file still defines it, so B can connect again with a fresh connector + const b2 = await inProject(projectB, () => Registry.get("store")) + expect(b2).not.toBe(a) + }) + + test("same-named SSH tunnels are per project; removing in B leaves A's open", async () => { + const tunnelled = (sshHost: string) => ({ + store: { type: "sqlite", path: "store.sqlite", create: true, ssh_host: sshHost, host: "db", port: 5432 }, + }) + writeLocal(projectA, tunnelled("bastion-a")) + writeLocal(projectB, tunnelled("bastion-b")) + await inProject(projectA, () => Registry.get("store")) + await inProject(projectB, () => Registry.get("store")) + // B starting its tunnel must not have closed A's + expect(sshClients.map((c) => [c.host, c.ended])).toEqual([ + ["bastion-a", false], + ["bastion-b", false], + ]) + + await inProject(projectB, () => Registry.remove("store")) + expect(sshClients.map((c) => [c.host, c.ended])).toEqual([ + ["bastion-a", false], + ["bastion-b", true], + ]) + }) + + test("a connect in flight when its project reloads is not cached over the reloaded state", async () => { + writeLocal(projectA, { store: { type: "sqlite", path: "store.sqlite", create: true } }) + const inflight = inProject(projectA, () => Registry.get("store")) + await inProject(projectA, () => Registry.reload()) + await expect(inflight).rejects.toThrow("reloaded or removed while it was connecting") + const fresh = await inProject(projectA, () => Registry.get("store")) + expect((await fresh.execute("SELECT 1 AS one")).rows).toEqual([[1]]) + }) +}) + +describe("SSH forwarding errors", () => { + for (const side of ["local", "remote"] as const) { + test(`${side} socket errors destroy both forwarding endpoints without throwing`, async () => { + const stream = new PassThrough() + forwardedStream = stream + let accept: ((socket: net.Socket) => void) | undefined + const server = Object.assign(new EventEmitter(), { + listen: (_port: number, _host: string, ready: () => void) => ready(), + address: () => ({ port: 12345 }), + close: () => {}, + }) + const createServer = spyOn(net, "createServer").mockImplementation(((listener: (socket: net.Socket) => void) => { + accept = listener + return server + }) as typeof net.createServer) + const tunnel = openTunnel({ ssh_host: "forwarding", host: "db", port: 5432 }) + const local = new net.Socket() + try { + await tunnel.ready + expect(accept).toBeDefined() + accept!(local) + const endpoint = side === "local" ? local : stream + expect(() => endpoint.emit("error", new Error("ECONNRESET"))).not.toThrow() + expect(local.destroyed).toBe(true) + expect(stream.destroyed).toBe(true) + } finally { + local.destroy() + tunnel.close() + createServer.mockRestore() + } + }) + } +}) + +describe("each connect attempt owns its SSH tunnel", () => { + test("a stale tunnel start that finishes after a newer start never publishes or closes the newer tunnel", async () => { + writeLocal(projectA, { store: sshStore("held-old") }) + const stale = inProject(projectA, () => Registry.get("store")) + await sshConnected("held-old") + + // replace the config while the old tunnel is still opening; the new attempt connects through its own tunnel + const added = await inProject(projectA, () => Registry.add("store", sshStore("new") as any)) + expect(added.success).toBe(true) + await expect(stale).rejects.toThrow() + const fresh = await inProject(projectA, () => Registry.get("store")) + + // the old SSH handshake completes late: nothing is registered, the newer tunnel stays open + heldReady.get("held-old")?.() + await new Promise((r) => setTimeout(r, 5)) + expect(ended("held-old")).toEqual([true]) + expect(ended("new")).toEqual([false]) + expect(openTunnelCount()).toBe(1) + expect((await fresh.execute("SELECT 1 AS one")).rows).toEqual([[1]]) + }) + + test("a failing attempt closes only the tunnel it opened", async () => { + writeLocal(projectB, { store: sshStore("b-host") }) + await inProject(projectB, () => Registry.get("store")) + // same name in A, store missing and not creatable: the driver fails after A's tunnel opened + writeLocal(projectA, { store: sshStore("a-host", { create: false, path: "missing.sqlite" }) }) + await expect(inProject(projectA, () => Registry.get("store"))).rejects.toThrow("not found") + expect(ended("a-host")).toEqual([true]) + expect(ended("b-host")).toEqual([false]) + expect(openTunnelCount()).toBe(1) + }) + + test("replacing an SSH connection with a non-SSH one closes the old tunnel (cached and in flight)", async () => { + writeLocal(projectA, { store: sshStore("cached-host"), other: sshStore("held-pending", { path: "other.sqlite" }) }) + await inProject(projectA, () => Registry.get("store")) + const pending = inProject(projectA, () => Registry.get("other")) + await sshConnected("held-pending") + + const plain = { type: "sqlite", path: "plain.sqlite", create: true } as any + await inProject(projectA, () => Registry.add("store", plain)) + await inProject(projectA, () => Registry.add("other", plain)) + await expect(pending).rejects.toThrow() + expect(ended("cached-host")).toEqual([true]) + expect(ended("held-pending")).toEqual([true]) + expect(openTunnelCount()).toBe(0) + const store = await inProject(projectA, () => Registry.get("store")) + expect((await store.execute("SELECT 1 AS one")).rows).toEqual([[1]]) + expect(openTunnelCount()).toBe(0) + }) + + test("a clean SSH disconnect before ready fails the connect and leaves no tunnel", async () => { + writeLocal(projectA, { store: sshStore("held-closing") }) + const pending = inProject(projectA, () => Registry.get("store")) + await sshConnected("held-closing") + remoteClose.get("held-closing")?.() + await expect(pending).rejects.toThrow("SSH connection closed") + expect(openTunnelCount()).toBe(0) + }) + + test("reload with a pending SSH connect closes that attempt's tunnel", async () => { + writeLocal(projectA, { store: sshStore("held-reload") }) + const pending = inProject(projectA, () => Registry.get("store")) + await sshConnected("held-reload") + await inProject(projectA, () => Registry.reload()) + await expect(pending).rejects.toThrow() + expect(ended("held-reload")).toEqual([true]) + expect(openTunnelCount()).toBe(0) + }) +}) + +describe("instance disposal", () => { + test("disposing a project's instance closes its connectors and tunnels only", async () => { + writeLocal(projectA, { store: sshStore("a-host") }) + writeLocal(projectB, { store: sshStore("b-host") }) + const a = await inProject(projectA, () => Registry.get("store")) + const b = await inProject(projectB, () => Registry.get("store")) + + await disposeInstance(projectA) + expect(ended("a-host")).toEqual([true]) + expect(ended("b-host")).toEqual([false]) + await expect(a.execute("SELECT 1")).rejects.toThrow() + expect((await b.execute("SELECT 1 AS one")).rows).toEqual([[1]]) + + // reopening A connects again with a new connector + const a2 = await inProject(projectA, () => Registry.get("store")) + expect(a2).not.toBe(a) + expect((await a2.execute("SELECT 1 AS one")).rows).toEqual([[1]]) + }) + + test("session-only credentials from warehouse_add survive disposal", async () => { + // The test preload disables the OS credential store, so the password exists only in memory. + await inProject(projectA, () => Registry.add("pg", { type: "postgres", host: "h", password: "s3cret-test" } as any)) + await disposeInstance(projectA) + expect(inProject(projectA, () => Registry.getConfig("pg")?.password)).toBe("s3cret-test") + }) + + test("a connect dropped while its tunnel is opening fails as superseded, with the retry hint", async () => { + writeLocal(projectA, { store: sshStore("held-dispose") }) + const pending = inProject(projectA, () => Registry.get("store")) + await sshConnected("held-dispose") + await disposeInstance(projectA) + await expect(pending).rejects.toThrow("reloaded or removed while it was connecting. Retry the request.") + }) +}) + +describe("remove()", () => { + test("a failed global file write leaves the project's connection usable", async () => { + writeGlobal({ g: { type: "sqlite", path: path.join(projectA, "g.sqlite"), create: true } }) + const connector = await inProject(projectA, () => Registry.get("g")) + const globalDir = path.join(home, ".altimate-code") + fs.chmodSync(globalDir, 0o500) + fs.chmodSync(path.join(globalDir, "connections.json"), 0o400) + try { + const result = await inProject(projectA, () => Registry.remove("g")) + expect(result.success).toBe(false) + expect(inProject(projectA, () => Registry.names())).toContain("g") + expect(await inProject(projectA, () => Registry.get("g"))).toBe(connector) + } finally { + fs.chmodSync(globalDir, 0o700) + fs.chmodSync(path.join(globalDir, "connections.json"), 0o600) + } + }) +}) + +describe("credentials of a removed connection", () => { + test("are kept for an open project that has never used the warehouse registry", async () => { + const { setSecretBackendForTests } = await import("../../src/altimate/native/connections/credential-store") + const store = new Map() + setSecretBackendForTests({ + name: "test", + get: async (k: string) => store.get(k) ?? null, + set: async (k: string, v: string) => void store.set(k, v), + delete: async (k: string) => store.delete(k), + } as any) + try { + writeLocal(projectB, { shared: { type: "postgres", host: "b" } }) + await Instance.provide({ directory: projectB, fn: async () => {} }) + await inProject(projectA, () => Registry.add("shared", { type: "postgres", host: "g", password: "pw-test" } as any)) + expect(store.get("shared/password")).toBe("pw-test") + + expect((await inProject(projectA, () => Registry.remove("shared"))).success).toBe(true) + expect(store.get("shared/password")).toBe("pw-test") + } finally { + await Instance.provide({ directory: projectB, fn: () => Instance.dispose() }) + setSecretBackendForTests(undefined) + } + }) + + test("are kept while another served project's local config still names the connection", async () => { + const { setSecretBackendForTests } = await import("../../src/altimate/native/connections/credential-store") + const store = new Map() + setSecretBackendForTests({ + name: "test", + get: async (k: string) => store.get(k) ?? null, + set: async (k: string, v: string) => void store.set(k, v), + delete: async (k: string) => store.delete(k), + } as any) + try { + writeLocal(projectB, { shared: { type: "postgres", host: "b" } }) + expect(inProject(projectB, () => Registry.names())).toEqual(["shared"]) + await inProject(projectA, () => Registry.add("shared", { type: "postgres", host: "g", password: "pw-test" } as any)) + expect(store.get("shared/password")).toBe("pw-test") + + await inProject(projectA, () => Registry.remove("shared")) + expect(store.get("shared/password")).toBe("pw-test") + + // with no served project naming it, removal forgets the secret + fs.rmSync(path.join(projectB, ".altimate-code"), { recursive: true }) + await inProject(projectA, () => Registry.add("shared", { type: "postgres", host: "g", password: "pw-test" } as any)) + await inProject(projectA, () => Registry.remove("shared")) + expect(store.has("shared/password")).toBe(false) + } finally { + setSecretBackendForTests(undefined) + } + }) +}) + +describe("warehouse_add keeps in-memory credentials in its project", () => { + test("credentials added in A stay in A's state; B sees only the saved, sanitized entry", async () => { + // The test preload disables the OS credential store, so the password is held in memory only. + const added = await inProject(projectA, () => + Registry.add("added_pg", { type: "postgres", host: "h", user: "u", password: "s3cret-test" } as any), + ) + expect(added.success).toBe(true) + expect(inProject(projectA, () => Registry.getConfig("added_pg")?.password)).toBe("s3cret-test") + + const inB = inProject(projectB, () => Registry.getConfig("added_pg")) + expect(inB?.host).toBe("h") + expect(inB?.password).toBeUndefined() + + // switching back and forth does not lose A's in-memory credential + expect(inProject(projectA, () => Registry.getConfig("added_pg")?.password)).toBe("s3cret-test") + }) +}) + +describe("test helpers", () => { + test("setConfigs applies to every project until reset", () => { + Registry.setConfigs({ injected: { type: "postgres" } }) + expect(inProject(projectA, () => Registry.names())).toEqual(["injected"]) + expect(inProject(projectB, () => Registry.names())).toEqual(["injected"]) + Registry.reset() + writeLocal(projectA, { only_a: { type: "postgres" } }) + expect(inProject(projectA, () => Registry.names())).toEqual(["only_a"]) + expect(inProject(projectB, () => Registry.names())).toEqual([]) + }) + + test("reset clears every project", () => { + writeLocal(projectA, { only_a: { type: "postgres" } }) + expect(inProject(projectA, () => Registry.names())).toEqual(["only_a"]) + fs.rmSync(path.join(projectA, ".altimate-code"), { recursive: true }) + Registry.reset() + expect(inProject(projectA, () => Registry.names())).toEqual([]) + }) + + test("a connect in flight across reset() is not cached into the dropped state", async () => { + writeLocal(projectA, { store: { type: "sqlite", path: "store.sqlite", create: true } }) + const inflight = inProject(projectA, () => Registry.get("store")) + Registry.reset() + await expect(inflight).rejects.toThrow("reloaded or removed while it was connecting") + }) + + test("closeAll releases connectors cached under setConfigs after load()", async () => { + Registry.setConfigs({ mem: { type: "sqlite", path: ":memory:" } }) + const injected = await Registry.get("mem") + inProject(projectA, () => Registry.load()) + await Registry.closeAll() + await expect(injected.execute("SELECT 1")).rejects.toThrow() + }) +}) diff --git a/packages/opencode/test/altimate/ssh-tunnel.test.ts b/packages/opencode/test/altimate/ssh-tunnel.test.ts index b6ab3e9b6d..8038cf2b93 100644 --- a/packages/opencode/test/altimate/ssh-tunnel.test.ts +++ b/packages/opencode/test/altimate/ssh-tunnel.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, afterEach } from "bun:test" -import { extractSshConfig, closeTunnel, closeAllTunnels, getActiveTunnel } from "../../src/altimate/native/connections/ssh-tunnel" +import { extractSshConfig, closeAllTunnels, openTunnelCount } from "../../src/altimate/native/connections/ssh-tunnel" afterEach(() => { closeAllTunnels() @@ -71,16 +71,13 @@ describe("extractSshConfig", () => { }) // --------------------------------------------------------------------------- -// closeTunnel / getActiveTunnel — idempotent operations on empty state +// closeAllTunnels — idempotent on empty state // --------------------------------------------------------------------------- describe("SSH tunnel state management", () => { - test("closeTunnel is a no-op for non-existent tunnel", () => { - closeTunnel("nonexistent-tunnel-name") - expect(getActiveTunnel("nonexistent-tunnel-name")).toBeUndefined() - }) - - test("getActiveTunnel returns undefined for non-existent tunnel", () => { - expect(getActiveTunnel("nonexistent")).toBeUndefined() + test("closeAllTunnels is a no-op when nothing is open", () => { + closeAllTunnels() + closeAllTunnels() + expect(openTunnelCount()).toBe(0) }) }) diff --git a/packages/opencode/test/altimate/warehouse-list-hint.test.ts b/packages/opencode/test/altimate/warehouse-list-hint.test.ts index 2128e69a24..bbfc2d840e 100644 --- a/packages/opencode/test/altimate/warehouse-list-hint.test.ts +++ b/packages/opencode/test/altimate/warehouse-list-hint.test.ts @@ -132,6 +132,9 @@ describe("warehouse_list no-connections hint", () => { JSON.stringify({ "proj-b": { type: "postgres" } }), ) process.chdir(other) + // each project has its own registry + expect(Registry.names()).toEqual(["proj-b"]) + process.chdir(project) expect(Registry.names()).toEqual(["proj-a"]) } finally { process.chdir(project) @@ -150,12 +153,11 @@ describe("warehouse_list no-connections hint", () => { JSON.stringify({ "proj-b": { type: "postgres" } }), ) process.chdir(other) - expect(Registry.names()).toEqual([]) - expect(Registry.projectConfigApplies()).toBe(false) - expect(noConnectionsMessage()).not.toContain(Registry.localConfigPath()) - expect(noConnectionsMessage()).toContain(Registry.globalConfigPath()) + expect(Registry.names()).toEqual(["proj-b"]) + expect(noConnectionsMessage()).toContain(Registry.localConfigPath()) process.chdir(dirA) - expect(Registry.projectConfigApplies()).toBe(true) + expect(Registry.names()).toEqual([]) + expect(noConnectionsMessage()).toContain(Registry.localConfigPath()) // the same project still picks up its own file after an empty answer fs.mkdirSync(path.join(dirA, ".altimate-code")) fs.writeFileSync( From 2f2ff27cfa9a16cb9aa8350b428d25bcdbfd4acb Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 20 Oct 2026 13:40:56 -0700 Subject: [PATCH 33/39] feat(mcp): add connections from an empty MCP session with `warehouse_add` A client that starts the MCP server in a project with no connections could not add one: the toolkit had no add tool. Now: - `warehouse_add` is exposed with a compact description; in MCP mode it skips post-connect suggestions for tools the toolkit lacks, and a read-only session refuses `create: true`. - An empty `warehouse_list` also names candidates from the existing env-var and dbt-profile detection (type and source only, never values). - Server instructions mention `warehouse_add`. The registry re-read while empty and the real-path hint come from the cherry-picked #1559/#1567. Tests: `server.test.ts` (ALTIMATE_MCP_E2E=1): empty registry -> connections file -> next call sees it; add -> query; candidates without values; read-only create refused. Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/mcp-server/adapter.ts | 18 ++- .../src/altimate/mcp-server/server.ts | 2 +- .../src/altimate/tools/warehouse-add.ts | 18 +++ .../src/altimate/tools/warehouse-list.ts | 27 ++++- .../test/altimate/mcp-server/server.test.ts | 106 +++++++++++++++++- .../test/altimate/mcp-server/stdio.test.ts | 2 +- 6 files changed, 166 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/altimate/mcp-server/adapter.ts b/packages/opencode/src/altimate/mcp-server/adapter.ts index 6da8828a88..2fb81103c3 100644 --- a/packages/opencode/src/altimate/mcp-server/adapter.ts +++ b/packages/opencode/src/altimate/mcp-server/adapter.ts @@ -10,6 +10,7 @@ import { Tool } from "@/tool/tool" import { Truncate } from "@/tool/truncate" import { outputLimits } from "./output-settings" import { WarehouseListTool } from "../tools/warehouse-list" +import { WarehouseAddTool } from "../tools/warehouse-add" import { SchemaInspectTool } from "../tools/schema-inspect" import { SqlExecuteTool } from "../tools/sql-execute" import { SqlCheckTool } from "../tools/sql-check" @@ -22,6 +23,7 @@ import type { SessionID, MessageID } from "@/session/schema" export const descriptions: Record = { warehouse_list: "List connections; for local databases also tables, columns and a sample row.", + warehouse_add: "Add a connection when warehouse_list shows none or the one you need is missing.", schema_inspect: "List tables when table is omitted; otherwise one table's columns and value profile.", sql_execute: "Run SQL and return rows. Put independent queries in one call with `queries`. Source writes need --allow-write.", @@ -38,6 +40,12 @@ export const descriptions: Record = { */ const DROP = Symbol("drop") const PROPERTY_DESCRIPTIONS: Record> = { + warehouse_add: { + name: "Connection name", + config: + 'Must include "type". postgres/mysql: host, port, database, user, password; mongodb: connection_string, ' + + "database; duckdb/sqlite: path (absolute, existing file); snowflake/bigquery/others: their usual fields", + }, schema_inspect: { table: "Table, optionally schema-qualified; omit to list tables", schema_name: null, @@ -276,7 +284,15 @@ export async function createAdapter(options: { allowWrite?: boolean } = {}) { Effect.scoped( Effect.gen(function* () { const result = new Map() - for (const source of [WarehouseListTool, SchemaInspectTool, SqlExecuteTool, SqlCheckTool, SqlMaterializeTool, AnswerCommitTool]) { + for (const source of [ + WarehouseListTool, + WarehouseAddTool, + SchemaInspectTool, + SqlExecuteTool, + SqlCheckTool, + SqlMaterializeTool, + AnswerCommitTool, + ]) { const info = yield* source const tool = yield* Tool.init(info, { agent: analyst }) if (tool.jsonSchema) { diff --git a/packages/opencode/src/altimate/mcp-server/server.ts b/packages/opencode/src/altimate/mcp-server/server.ts index 5034ec106a..df51b496b1 100644 --- a/packages/opencode/src/altimate/mcp-server/server.ts +++ b/packages/opencode/src/altimate/mcp-server/server.ts @@ -16,7 +16,7 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version" export const SERVER_INSTRUCTIONS = [ "Analyst method for data questions:", - "1. Start with warehouse_list; it shows local tables, columns and a sample row.", + "1. Start with warehouse_list; it shows local tables, columns and a sample row. If none fit, warehouse_add one.", "2. Check stored value formats (schema_inspect value profile) before filtering or joining on a column; formats differ across databases.", "3. Compute in the database; batch independent queries in one sql_execute call (`queries`).", "4. Cross-connection joins: sql_materialize the smaller filtered side into `scratch` and join there.", diff --git a/packages/opencode/src/altimate/tools/warehouse-add.ts b/packages/opencode/src/altimate/tools/warehouse-add.ts index 54adc86657..8fb82b77b5 100644 --- a/packages/opencode/src/altimate/tools/warehouse-add.ts +++ b/packages/opencode/src/altimate/tools/warehouse-add.ts @@ -49,12 +49,30 @@ IMPORTANT: For private key file paths, always use "private_key_path" (not "priva } } + // altimate_change start — a read-only MCP session must not create a database file through `create` + if (ctx.extra?.forceReadOnly === true && args.config.create === true) { + return { + title: `Add '${args.name}': FAILED`, + metadata: { success: false, name: args.name, type: String(args.config.type) }, + output: `This session is read-only, so "create": true is not allowed. Point "path" at an existing database file.`, + } + } + // altimate_change end try { const result = await Dispatcher.call("warehouse.add", { name: args.name, config: args.config, }) + // altimate_change start — MCP clients have only the toolkit: no warehouse_test or suggested follow-up tools + if (result.success && ctx.extra?.mcpClient === true) { + let output = `Added warehouse '${result.name}' (type: ${result.type}). Call warehouse_list to see its tables.` + output += driverReadinessNote(result.type) + if (result.warnings?.length) + output += `\n\nWarning — not every credential was saved:\n${result.warnings.map((w) => `- ${w}`).join("\n")}` + return { title: `Add '${args.name}': OK`, metadata: { success: true, name: result.name, type: result.type }, output } + } + // altimate_change end if (result.success) { // altimate_change start — append post-connect feature suggestions (async, non-blocking) let output = `Successfully added warehouse '${result.name}' (type: ${result.type}).\n\nUse warehouse_test to verify connectivity.` diff --git a/packages/opencode/src/altimate/tools/warehouse-list.ts b/packages/opencode/src/altimate/tools/warehouse-list.ts index 4816d5fd57..43a5e280b2 100644 --- a/packages/opencode/src/altimate/tools/warehouse-list.ts +++ b/packages/opencode/src/altimate/tools/warehouse-list.ts @@ -33,6 +33,28 @@ export function noConnectionsMessage(): string { } // altimate_change end +// altimate_change start — MCP: connection candidates from the existing project-scan detection (names and types only, +// never values); every probe is local and fail-safe. +const MAX_CANDIDATES = 10 +async function discoveredCandidates(): Promise { + const scan = await import("./project-scan") + const [env, dbt] = await Promise.all([ + scan.detectEnvVars().catch(() => []), + scan + .detectDbtProject(process.cwd()) + .then((project) => Dispatcher.call("dbt.profiles", { projectDir: project.found ? project.path : undefined })) + .then((result) => result.connections ?? []) + .catch(() => []), + ]) + const lines = [ + ...env.map((c) => `- ${c.type} from environment variable ${c.signal}`), + ...dbt.map((c) => `- ${c.type} from dbt profile ${c.name}`), + ].slice(0, MAX_CANDIDATES) + if (!lines.length) return "" + return ["", "", "Detected but not configured (read the values from their source, then call warehouse_add):", ...lines].join("\n") +} +// altimate_change end + export const WarehouseListTool = Tool.define("warehouse_list", { description: "List all configured warehouse connections. Shows connection name, type, and database.", parameters: z.object({}), @@ -42,10 +64,13 @@ export const WarehouseListTool = Tool.define("warehouse_list", { const warehouses = result.warehouses ?? [] if (warehouses.length === 0) { + // altimate_change start — MCP: name connections the existing env-var and dbt-profile detection can see + const candidates = ctx.extra?.mcpClient === true ? await discoveredCandidates() : "" + // altimate_change end return { title: "Warehouses: none configured", metadata: { count: 0 }, - output: noConnectionsMessage(), + output: noConnectionsMessage() + candidates, } } diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index 15f4128669..f0d9f819de 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -71,7 +71,7 @@ mcpTest("initialize result carries short analyst-method instructions", async () } }) -mcpTest("lists exactly six tools and executes warehouse, schema, SQL and checks without routing notices", async () => { +mcpTest("lists exactly seven tools and executes warehouse, schema, SQL and checks without routing notices", async () => { const { client, call } = await fixture() const listed = await client.listTools() expect(listed.tools.map((t) => t.name).sort()).toEqual([ @@ -80,6 +80,7 @@ mcpTest("lists exactly six tools and executes warehouse, schema, SQL and checks "sql_check", "sql_execute", "sql_materialize", + "warehouse_add", "warehouse_list", ]) expect(listed.tools.every((t) => t.inputSchema.type === "object")).toBe(true) @@ -261,7 +262,7 @@ mcpTest("server startup and tool listing make no network calls", async () => { ) try { const { client } = await fixture() - expect((await client.listTools()).tools).toHaveLength(6) + expect((await client.listTools()).tools).toHaveLength(7) expect(fetch).not.toHaveBeenCalled() } finally { fetch.mockRestore() @@ -835,7 +836,7 @@ mcpTest("tool definitions stay compact and keep their required properties", asyn const byName = Object.fromEntries(tools.map((tool) => [tool.name, tool])) expect(size(byName.answer_commit!)).toBeLessThanOrEqual(2_250) expect(byName.answer_commit!.description).toContain("'the repository and the most copied file' needs two") - expect(tools.reduce((total, tool) => total + size(tool), 0)).toBeLessThan(6_250) + expect(tools.reduce((total, tool) => total + size(tool), 0)).toBeLessThan(6_600) expect(client.getInstructions()!.length).toBeLessThanOrEqual(723) const declared = JSON.stringify(tools) expect(declared).not.toContain("$schema") @@ -982,3 +983,102 @@ mcpTest("MCP inline results, listings and receipts drop the rule line and repeat expect(again).not.toContain("Join it in scratch") }) // altimate_change end + +// altimate_change start — setup from zero: no connections file, then a file or warehouse_add +async function emptyProject(env: Record = {}) { + const project = await mkdtemp(path.join(os.tmpdir(), "mcp-empty-project-")) + const previous = process.cwd() + const saved: Record = {} + // Ambient connection variables would make the empty-registry assertions depend on the caller's shell. + for (const key of Object.keys(process.env)) { + if (key.startsWith(Registry.CONN_ENV_PREFIX) || key in env) saved[key] = process.env[key] + if (key.startsWith(Registry.CONN_ENV_PREFIX)) delete process.env[key] + } + for (const [key, value] of Object.entries(env)) { + saved[key] ??= process.env[key] + process.env[key] = value + } + process.chdir(project) + Registry.reset() + const db = new Database(path.join(project, "store.sqlite")) + db.run("CREATE TABLE fruit (id INTEGER, name TEXT)") + db.run("INSERT INTO fruit VALUES (1, 'fig')") + db.close() + const server = await createServer({}) + const client = new Client({ name: "test", version: "1" }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + await client.connect(clientTransport) + cleanups.push(async () => { + await client.close() + await server.close() + await Registry.closeAll() + process.chdir(previous) + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + // warehouse_add writes the (test-isolated) global file; later tests must not see it. + await rm(Registry.globalConfigPath(), { force: true }) + await rm(project, { recursive: true, force: true }) + }) + const call = async (name: string, args: Record) => { + const result = await client.callTool({ name, arguments: args }) + return { result, body: (result.content as { text?: string }[]).map((part) => part.text ?? "").join("\n") } + } + return { project, call } +} + +mcpTest("an empty registry names the real config paths and sees a connections file written afterwards", async () => { + const { project, call } = await emptyProject() + const first = await call("warehouse_list", {}) + expect(first.body).toContain("No warehouse connections configured.") + expect(first.body).toContain(Registry.localConfigPath()) + expect(first.body).toContain(Registry.globalConfigPath()) + expect(first.body).toContain(Registry.CONN_ENV_PREFIX) + expect(first.body).not.toContain(".opencode/connections.json") + await Bun.write( + path.join(project, ".altimate-code", "connections.json"), + JSON.stringify({ local: { type: "sqlite", path: path.join(project, "store.sqlite") } }), + ) + const second = await call("warehouse_list", {}) + expect(second.body).toContain("local | sqlite") + const rows = await call("sql_execute", { warehouse: "local", query: "SELECT name FROM fruit" }) + expect(rows.result.isError).toBe(false) + expect(rows.body).toContain("fig") +}) + +mcpTest("warehouse_add from an empty registry makes the connection queryable", async () => { + const { project, call } = await emptyProject() + const added = await call("warehouse_add", { + name: "added", + config: { type: "sqlite", path: path.join(project, "store.sqlite") }, + }) + expect(added.result.isError).toBe(false) + expect(added.body).toContain("Call warehouse_list") + expect(added.body).not.toMatch(/warehouse_test|sql_analyze|lineage_check/) + expect((await call("warehouse_list", {})).body).toContain("added | sqlite") + const rows = await call("sql_execute", { warehouse: "added", query: "SELECT name FROM fruit" }) + expect(rows.result.isError).toBe(false) + expect(rows.body).toContain("fig") +}) + +mcpTest("an empty registry lists detected connection candidates by type and source, never their values", async () => { + const { call } = await emptyProject({ SNOWFLAKE_ACCOUNT: "acct-value-xyz", SNOWFLAKE_PASSWORD: "secret-value-xyz" }) + const { body } = await call("warehouse_list", {}) + expect(body).toContain("snowflake from environment variable SNOWFLAKE_ACCOUNT") + expect(body).not.toContain("acct-value-xyz") + expect(body).not.toContain("secret-value-xyz") +}) +// altimate_change end + +// altimate_change start — read-only MCP sessions cannot create a store through warehouse_add +mcpTest("warehouse_add refuses create:true in a read-only session and creates no file", async () => { + const { project, call } = await emptyProject() + const target = path.join(project, "new.duckdb") + const added = await call("warehouse_add", { name: "fresh", config: { type: "duckdb", path: target, create: true } }) + expect(added.body).toContain("read-only") + expect((await call("warehouse_list", {})).body).not.toContain("fresh") + expect(await Bun.file(target).exists()).toBe(false) +}) +// altimate_change end diff --git a/packages/opencode/test/altimate/mcp-server/stdio.test.ts b/packages/opencode/test/altimate/mcp-server/stdio.test.ts index 4b82f886ad..db74d64403 100644 --- a/packages/opencode/test/altimate/mcp-server/stdio.test.ts +++ b/packages/opencode/test/altimate/mcp-server/stdio.test.ts @@ -88,7 +88,7 @@ for (const allowWrite of [false, true]) { let spill: string | undefined try { await client.connect() - expect((await client.listTools()).tools).toHaveLength(6) + expect((await client.listTools()).tools).toHaveLength(7) const write = await client.callTool({ name: "sql_execute", arguments: { warehouse: "source", query: "INSERT INTO items VALUES (7)" }, From 8f3c98635972186b969979630ff5bf81f3579c66 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 20 Oct 2026 13:49:19 -0700 Subject: [PATCH 34/39] feat(mcp): describe tables and run queries in one `sql_execute` call; five-tool default Tool definitions are re-sent on every model request and each tool call is a round trip. Now: - `sql_execute` takes `tables`: the call describes those tables (columns, types, value profile, via the existing `schema_inspect` code) and then runs `query`/`queries`/`query_from`, in one response. A schema-only call that describes nothing is an error; cancellation stops between tables. - The default toolset is `warehouse_list`, `warehouse_add`, `sql_execute`, `sql_materialize`, `answer_commit`. `mcp serve --all-tools` also exposes `schema_inspect` and `sql_check`. - Shorter server instructions that name only advertised tools. Declarations 5,163 chars + 580 instructions (was 6,554 + 713). Tests: `server.test.ts` (ALTIMATE_MCP_E2E=1) five-tool default, fused, schema-only, missing table, hidden tools; `stdio.test.ts`. Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/mcp-server/adapter.ts | 75 +++++++++++++++++-- .../src/altimate/mcp-server/server.ts | 11 +-- packages/opencode/src/cli/cmd/mcp.ts | 5 +- .../test/altimate/mcp-server/server.test.ts | 54 ++++++++++++- .../test/altimate/mcp-server/stdio.test.ts | 2 +- 5 files changed, 130 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/altimate/mcp-server/adapter.ts b/packages/opencode/src/altimate/mcp-server/adapter.ts index 2fb81103c3..543e93c8f7 100644 --- a/packages/opencode/src/altimate/mcp-server/adapter.ts +++ b/packages/opencode/src/altimate/mcp-server/adapter.ts @@ -26,7 +26,7 @@ export const descriptions: Record = { warehouse_add: "Add a connection when warehouse_list shows none or the one you need is missing.", schema_inspect: "List tables when table is omitted; otherwise one table's columns and value profile.", sql_execute: - "Run SQL and return rows. Put independent queries in one call with `queries`. Source writes need --allow-write.", + "Run SQL and return rows. Put independent queries in one call with `queries`; `tables` describes tables in the same call. Source writes need --allow-write.", sql_check: "Read-only data check: fanout, keys, grain, nulls or coverage.", sql_materialize: "Store a read-only query's rows as a scratch table, to join across connections.", answer_commit: @@ -187,6 +187,33 @@ export function compactSchema(name: string, schema: T): T { const BASIS_LIMIT = 400 const MCP_ONLY_TOOLS = new Set(["answer_commit"]) +/** + * Tool definitions are re-sent with every model request, so the default toolset is the five tools clients use; + * `sql_execute.tables` covers table inspection in the same call as the queries. `allTools` also advertises + * schema_inspect and sql_check. + */ +const LEAN_TOOLS = new Set(["warehouse_list", "warehouse_add", "sql_execute", "sql_materialize", "answer_commit"]) +const MAX_FUSED_TABLES = 8 + +/** Adds `tables` to the advertised sql_execute schema: tables described in the same call, before its queries. */ +function withTables(schema: T): T { + const copy = JSON.parse(JSON.stringify(schema)) as { properties?: Record } + copy.properties = { + tables: { + type: "array", + items: { type: "string" }, + maxItems: MAX_FUSED_TABLES, + description: "Tables to describe first in this call (columns, types, value profile); a query is optional", + }, + ...copy.properties, + } + return copy as T +} + +function hasQuery(args: Record): boolean { + return Boolean(args.query) || Boolean(args.query_from) || (Array.isArray(args.queries) && args.queries.length > 0) +} + /** Session-level record the native commit checks read: tool outputs and rejections of the current turn. */ type TurnMessage = Tool.Context["messages"][number] /** @@ -274,7 +301,7 @@ function relaxBasis(schema: T): T { return copy as T } -export async function createAdapter(options: { allowWrite?: boolean } = {}) { +export async function createAdapter(options: { allowWrite?: boolean; allTools?: boolean } = {}) { const limits = await outputLimits() const directory = await mkdtemp(path.join(tmpdir(), "altimate-code-mcp-results-")) await chmod(directory, 0o700) @@ -305,6 +332,7 @@ export async function createAdapter(options: { allowWrite?: boolean } = {}) { } if (tool.jsonSchema && info.id === "answer_commit") tool.jsonSchema = withRequest(relaxBasis(tool.jsonSchema)) if (tool.jsonSchema) tool.jsonSchema = compactSchema(info.id, tool.jsonSchema) + if (tool.jsonSchema && info.id === "sql_execute") tool.jsonSchema = withTables(tool.jsonSchema) result.set(info.id, tool) } return result @@ -339,12 +367,41 @@ export async function createAdapter(options: { allowWrite?: boolean } = {}) { messages.push(userMessage()) return { ...result, output: finalAnswerText(args, result.metadata.committed_text ?? result.output, request) } } + const advertised = new Map([...tools].filter(([name]) => options.allTools || LEAN_TOOLS.has(name))) + /** sql_execute with `tables`: describe each table with schema_inspect, then run the queries, in one call. */ + const fused = async (tables: unknown[], args: Record, ctx: Tool.Context): Promise => { + const inspect = tools.get("schema_inspect")! + const parts: string[] = [] + let inspected = 0 + for (const table of tables.slice(0, MAX_FUSED_TABLES)) { + if (typeof table !== "string" || !table.trim()) continue + ctx.abort.throwIfAborted() + const target = { table: table.trim(), ...(typeof args.warehouse === "string" ? { warehouse: args.warehouse } : {}) } + try { + const result = await Effect.runPromise(inspect.execute(target, ctx)) + if (result.status !== "error" && !result.metadata?.error) inspected++ + parts.push(result.output) + } catch (error) { + if (ctx.abort.aborted) throw error + parts.push(`Table ${table}: ${error instanceof Error ? error.message : String(error)}`) + } + } + ctx.abort.throwIfAborted() + if (!hasQuery(args)) { + const output = parts.join("\n\n") + // Nothing described and nothing queried: report a failure, not completed evidence. + if (inspected === 0) return { title: "Schema: no table described", metadata: { error: output }, output, status: "error" } + return { title: `Schema: ${inspected} table(s)`, metadata: {}, output } + } + const result = await Effect.runPromise(tools.get("sql_execute")!.execute(args, ctx)) + return { ...result, output: [...parts, result.output].join("\n\n") } + } return { - tools, + tools: advertised, async execute(name: string, args: Record, signal: AbortSignal) { signal = AbortSignal.any([signal, shutdown.signal]) signal.throwIfAborted() - const tool = tools.get(name) + const tool = advertised.get(name) if (!tool) throw new Error(`Unknown tool: ${name}`) const ctx: Tool.Context = { sessionID, @@ -371,7 +428,15 @@ export async function createAdapter(options: { allowWrite?: boolean } = {}) { recordRequest(messages, request) args = rest } - const pending = Effect.runPromise(tool.execute(args, ctx)) + let pending: Promise + if (name === "sql_execute" && Array.isArray(args.tables) && args.tables.length > 0) { + const { tables, ...rest } = args + args = rest + pending = fused(tables, rest, ctx) + } else { + if (name === "sql_execute") args = Object.fromEntries(Object.entries(args).filter(([key]) => key !== "tables")) + pending = Effect.runPromise(tool.execute(args, ctx)) + } active.add(pending) try { const result = await pending diff --git a/packages/opencode/src/altimate/mcp-server/server.ts b/packages/opencode/src/altimate/mcp-server/server.ts index df51b496b1..cc3802778e 100644 --- a/packages/opencode/src/altimate/mcp-server/server.ts +++ b/packages/opencode/src/altimate/mcp-server/server.ts @@ -16,12 +16,11 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version" export const SERVER_INSTRUCTIONS = [ "Analyst method for data questions:", - "1. Start with warehouse_list; it shows local tables, columns and a sample row. If none fit, warehouse_add one.", - "2. Check stored value formats (schema_inspect value profile) before filtering or joining on a column; formats differ across databases.", - "3. Compute in the database; batch independent queries in one sql_execute call (`queries`).", + "1. Start with warehouse_list (local tables, columns, a sample row); if none fit, warehouse_add one.", + "2. Check stored value formats before filtering or joining: sql_execute `tables` returns value profiles in the same call as your queries.", + "3. Compute in the database; put independent queries in one sql_execute call (`queries`).", "4. Cross-connection joins: sql_materialize the smaller filtered side into `scratch` and join there.", - "5. Use sql_check when keys, fan-out, grain or nulls could change the answer; make sure every condition filters something.", - "6. Finish with answer_commit (`asked` = the requested items, one field each); reply with exactly its FINAL ANSWER text.", + "5. Finish with answer_commit (`asked` = the requested items, one field each); reply with exactly its FINAL ANSWER text.", ].join("\n") /** Spilled results: a resource_link next to the text, which keeps the preview, row count and path. */ @@ -50,6 +49,8 @@ const analyzePrompt = { export interface ServeOptions { connections?: string allowWrite?: boolean + // Also advertise schema_inspect and sql_check (default: the five-tool set, see adapter LEAN_TOOLS). + allTools?: boolean // Bounds the reading question so an unanswered prompt cannot outlast the client's tool-call timeout. elicitTimeoutMs?: number } diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index ae293db694..e39128c991 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -133,10 +133,11 @@ export const McpServeCommand = cmd({ describe: "serve data tools over MCP stdio (no model required)", builder: (yargs) => yargs .option("connections", { type: "string", describe: "Connections JSON file" }) - .option("allow-write", { type: "boolean", default: false, describe: "Allow SQL write permissions" }), + .option("allow-write", { type: "boolean", default: false, describe: "Allow SQL write permissions" }) + .option("all-tools", { type: "boolean", default: false, describe: "Also expose schema_inspect and sql_check" }), async handler(args) { const { serveStdio } = await import("../../altimate/mcp-server/server") - await serveStdio({ connections: args.connections, allowWrite: args.allowWrite }) + await serveStdio({ connections: args.connections, allowWrite: args.allowWrite, allTools: args.allTools }) }, }) // altimate_change end diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index f0d9f819de..bc23c04c09 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -32,6 +32,7 @@ async function fixture( capabilities: Record = {}, elicitTimeoutMs?: number, setup?: (db: Database) => void, + allTools = true, ) { const dir = await mkdtemp(path.join(os.tmpdir(), "mcp-tools-test-")) cleanups.push(() => rm(dir, { recursive: true, force: true })) @@ -43,7 +44,7 @@ async function fixture( db.close() const config = path.join(dir, "connections.json") await Bun.write(config, JSON.stringify({ source: { type: "sqlite", path: "source.sqlite" } })) - const server = await createServer({ allowWrite, connections: config, elicitTimeoutMs }) + const server = await createServer({ allowWrite, connections: config, elicitTimeoutMs, allTools }) const client = new Client({ name: "test", version: "1" }, { capabilities }) const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() await server.connect(serverTransport) @@ -66,12 +67,57 @@ mcpTest("initialize result carries short analyst-method instructions", async () const instructions = client.getInstructions() expect(instructions).toBe(SERVER_INSTRUCTIONS) expect(instructions!.split(/\s+/).length).toBeLessThanOrEqual(120) - for (const tool of ["warehouse_list", "schema_inspect", "sql_materialize", "sql_check"]) { + for (const tool of ["warehouse_list", "warehouse_add", "sql_execute", "sql_materialize", "answer_commit"]) { expect(instructions).toContain(tool) } + // Instructions name only advertised tools. + expect(instructions).not.toMatch(/schema_inspect|sql_check/) }) -mcpTest("lists exactly seven tools and executes warehouse, schema, SQL and checks without routing notices", async () => { +mcpTest("the default toolset is five tools; sql_execute `tables` describes tables and runs queries in one call", async () => { + const { client, call } = await fixture(false, {}, undefined, undefined, false) + const listed = await client.listTools() + expect(listed.tools.map((t) => t.name).sort()).toEqual([ + "answer_commit", + "sql_execute", + "sql_materialize", + "warehouse_add", + "warehouse_list", + ]) + const declared = listed.tools.reduce( + (total, t) => total + JSON.stringify({ name: t.name, description: t.description, inputSchema: t.inputSchema }).length, + 0, + ) + expect(declared).toBeLessThan(5_200) + expect(client.getInstructions()!.length).toBeLessThanOrEqual(600) + const sqlExecute = listed.tools.find((t) => t.name === "sql_execute")! + expect(Object.keys(sqlExecute.inputSchema.properties ?? {})).toContain("tables") + const fused = await call("sql_execute", { + warehouse: "source", + tables: ["items"], + queries: [{ label: "fruit", query: "SELECT name FROM items ORDER BY id" }], + }) + expect(fused.isError).toBe(false) + const body = text(fused) + // Schema first (column list with its value profile), then the query result, in one response. + expect(body).toContain("name") + expect(body.indexOf("items")).toBeLessThan(body.indexOf("apple")) + expect(body).toContain("pear") + const schemaOnly = await call("sql_execute", { warehouse: "source", tables: ["items"] }) + expect(schemaOnly.isError).toBe(false) + expect(text(schemaOnly)).toContain("items") + expect(text(schemaOnly)).not.toContain("apple | ") + const schemaMiss = await call("sql_execute", { warehouse: "source", tables: ["no_such_table"] }) + expect(schemaMiss.isError).toBe(true) + const missing = await call("sql_execute", { warehouse: "source", tables: ["no_such_table"], query: "SELECT 1 AS one" }) + expect(text(missing)).toContain("no_such_table") + expect(text(missing)).toContain("one") + // Unadvertised tools are not callable. + const hidden = await call("sql_check", { warehouse: "source", check: "nulls", sql: "SELECT * FROM items", columns: ["id"] }) + expect(hidden.isError).toBe(true) +}) + +mcpTest("allTools lists exactly seven tools and executes warehouse, schema, SQL and checks without routing notices", async () => { const { client, call } = await fixture() const listed = await client.listTools() expect(listed.tools.map((t) => t.name).sort()).toEqual([ @@ -836,7 +882,7 @@ mcpTest("tool definitions stay compact and keep their required properties", asyn const byName = Object.fromEntries(tools.map((tool) => [tool.name, tool])) expect(size(byName.answer_commit!)).toBeLessThanOrEqual(2_250) expect(byName.answer_commit!.description).toContain("'the repository and the most copied file' needs two") - expect(tools.reduce((total, tool) => total + size(tool), 0)).toBeLessThan(6_600) + expect(tools.reduce((total, tool) => total + size(tool), 0)).toBeLessThan(7_000) expect(client.getInstructions()!.length).toBeLessThanOrEqual(723) const declared = JSON.stringify(tools) expect(declared).not.toContain("$schema") diff --git a/packages/opencode/test/altimate/mcp-server/stdio.test.ts b/packages/opencode/test/altimate/mcp-server/stdio.test.ts index db74d64403..54f5c11416 100644 --- a/packages/opencode/test/altimate/mcp-server/stdio.test.ts +++ b/packages/opencode/test/altimate/mcp-server/stdio.test.ts @@ -88,7 +88,7 @@ for (const allowWrite of [false, true]) { let spill: string | undefined try { await client.connect() - expect((await client.listTools()).tools).toHaveLength(7) + expect((await client.listTools()).tools).toHaveLength(5) const write = await client.callTool({ name: "sql_execute", arguments: { warehouse: "source", query: "INSERT INTO items VALUES (7)" }, From d4abef2b98d50e7f5da59c841170d9ca78169bc8 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 20 Oct 2026 14:06:20 -0700 Subject: [PATCH 35/39] docs(mcp): describe the five-tool default, `tables`, `--all-tools` and `warehouse_add` Co-Authored-By: Claude Opus 5.5 --- docs/docs/configure/mcp-servers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/configure/mcp-servers.md b/docs/docs/configure/mcp-servers.md index f618507371..26c83dc170 100644 --- a/docs/docs/configure/mcp-servers.md +++ b/docs/docs/configure/mcp-servers.md @@ -23,7 +23,7 @@ Add this entry to your MCP client's configuration (for example, Claude Code's `. } ``` -The server exposes exactly `warehouse_list`, `schema_inspect`, `sql_execute`, `sql_check`, and `sql_materialize`. A connections file is a JSON object keyed by connection name: +By default the server exposes `warehouse_list`, `warehouse_add`, `sql_execute`, `sql_materialize` and `answer_commit`. Pass `tables` to `sql_execute` to get those tables' columns and value profiles in the same call as its queries. Start with `--all-tools` to also expose `schema_inspect` and `sql_check`. Without `--connections`, the server reads the usual connection sources and re-reads them while none are configured, so a connections file written mid-session, or a connection added with `warehouse_add`, is picked up without a restart. A connections file is a JSON object keyed by connection name: ```json { From 873d18a5636ee28de713175033073a39d9c5d23b Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 20 Oct 2026 15:08:27 -0700 Subject: [PATCH 36/39] fix(warehouse): open SQLite/DuckDB stores this process cannot write read-only on `warehouse_add` Adding a store whose file or directory is not writable left it opened read-write, and every query failed with "attempt to write a readonly database". `warehouse_add` now sets `readonly: true` for an existing absolute store path that is not writable (a property of the file, so it is safe to persist); writable stores and explicit `readonly` values are unchanged. Tests: `server.test.ts` (ALTIMATE_MCP_E2E=1): 0444 SQLite file in a 0555 directory is queryable after add; a writable store keeps no `readonly` flag. Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/tools/warehouse-add.ts | 25 +++++++++++++++++- .../test/altimate/mcp-server/server.test.ts | 26 ++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/altimate/tools/warehouse-add.ts b/packages/opencode/src/altimate/tools/warehouse-add.ts index 8fb82b77b5..a7a5398e6b 100644 --- a/packages/opencode/src/altimate/tools/warehouse-add.ts +++ b/packages/opencode/src/altimate/tools/warehouse-add.ts @@ -1,4 +1,6 @@ import z from "zod" +import fs from "node:fs" +import path from "node:path" import { Tool } from "../../tool/tool" import { Dispatcher } from "../native" // altimate_change start — post-connect feature suggestions @@ -58,10 +60,15 @@ IMPORTANT: For private key file paths, always use "private_key_path" (not "priva } } // altimate_change end + // altimate_change start — a store this process cannot write (file or directory) is opened read-only; opening it + // read-write failed every query with "attempt to write a readonly database". A property of the file, so it is + // correct to persist; writable stores are unchanged. + const config = unwritableFileStore(args.config) ? { ...args.config, readonly: true } : args.config + // altimate_change end try { const result = await Dispatcher.call("warehouse.add", { name: args.name, - config: args.config, + config, }) // altimate_change start — MCP clients have only the toolkit: no warehouse_test or suggested follow-up tools @@ -175,6 +182,22 @@ IMPORTANT: For private key file paths, always use "private_key_path" (not "priva }, }) +// altimate_change start — read-only detection for existing SQLite/DuckDB store files +function unwritableFileStore(config: Record): boolean { + const type = String(config.type).toLowerCase() + const store = config.path + if ((type !== "sqlite" && type !== "duckdb") || typeof store !== "string" || !path.isAbsolute(store)) return false + if (config.readonly !== undefined || !fs.existsSync(store)) return false + try { + fs.accessSync(store, fs.constants.W_OK) + fs.accessSync(path.dirname(store), fs.constants.W_OK) + return false + } catch { + return true + } +} +// altimate_change end + // altimate_change start — driver readiness note for newly added warehouses /** * Note appended to a successful add when the warehouse's driver is missing. diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index bc23c04c09..5fd076931c 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -1,7 +1,7 @@ // altimate_change start — standalone MCP enforcement and lifecycle regressions import { afterEach, expect, spyOn, test } from "bun:test" import { Database } from "bun:sqlite" -import { mkdtemp, rm, stat, readFile } from "node:fs/promises" +import { chmod, mkdir, mkdtemp, rm, stat, readFile } from "node:fs/promises" import os from "node:os" import path from "node:path" import { fileURLToPath } from "node:url" @@ -1104,6 +1104,8 @@ mcpTest("warehouse_add from an empty registry makes the connection queryable", a expect(added.body).toContain("Call warehouse_list") expect(added.body).not.toMatch(/warehouse_test|sql_analyze|lineage_check/) expect((await call("warehouse_list", {})).body).toContain("added | sqlite") + // A writable store keeps its configured mode. + expect(Registry.getConfig("added")?.readonly).toBeUndefined() const rows = await call("sql_execute", { warehouse: "added", query: "SELECT name FROM fruit" }) expect(rows.result.isError).toBe(false) expect(rows.body).toContain("fig") @@ -1128,3 +1130,25 @@ mcpTest("warehouse_add refuses create:true in a read-only session and creates no expect(await Bun.file(target).exists()).toBe(false) }) // altimate_change end + +// altimate_change start — a read-only session opens added file stores read-only (read-only files and directories work) +mcpTest("warehouse_add of a SQLite file this process cannot write opens it read-only so it can be queried", async () => { + const { project, call } = await emptyProject() + const locked = path.join(project, "locked") + await mkdir(locked) + const store = path.join(locked, "ro.sqlite") + const db = new Database(store) + db.run("CREATE TABLE fruit (id INTEGER, name TEXT)") + db.run("INSERT INTO fruit VALUES (1, 'quince')") + db.close() + await chmod(store, 0o444) + await chmod(locked, 0o555) + cleanups.push(() => chmod(locked, 0o755)) + const added = await call("warehouse_add", { name: "ro", config: { type: "sqlite", path: store } }) + expect(added.result.isError).toBe(false) + const rows = await call("sql_execute", { warehouse: "ro", tables: ["fruit"], query: "SELECT name FROM fruit" }) + expect(rows.body).not.toContain("readonly database") + expect(rows.result.isError).toBe(false) + expect(rows.body).toContain("quince") +}) +// altimate_change end From 24b4538957ea7188fdcb2613616260c51df2808b Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 20 Oct 2026 16:55:30 -0700 Subject: [PATCH 37/39] refactor(mcp): shorter property descriptions for `sql_execute` and `sql_materialize` Definitions are re-sent with every request. Shorter `queries`, `query_from`, `full_text_columns`, `tables`, `sql` and `target_table` descriptions; defaults that the parsers apply (`queries[].label`, `queries[].warehouse`, `query_from.sql`, `max_rows`) lose theirs. Parsing unchanged. Declarations 5,163 -> 4,824 chars. Tests: `server.test.ts`, `stdio.test.ts` (ALTIMATE_MCP_E2E=1). Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/mcp-server/adapter.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/altimate/mcp-server/adapter.ts b/packages/opencode/src/altimate/mcp-server/adapter.ts index 543e93c8f7..6e4f20c9fd 100644 --- a/packages/opencode/src/altimate/mcp-server/adapter.ts +++ b/packages/opencode/src/altimate/mcp-server/adapter.ts @@ -54,16 +54,16 @@ const PROPERTY_DESCRIPTIONS: Record(schema: T): T { type: "array", items: { type: "string" }, maxItems: MAX_FUSED_TABLES, - description: "Tables to describe first in this call (columns, types, value profile); a query is optional", + description: "Tables to describe in this call (columns, value profile); query optional", }, ...copy.properties, } From b9dbc21ffd07d94b92d7a33f69535f08a68af750 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 20 Oct 2026 18:25:08 -0700 Subject: [PATCH 38/39] feat(mcp): chain dependent `sql_execute` batch steps with `{{label.column}}` and `{{label}}` Dependent queries cost a model round trip each. In a `queries` batch a later step can now use an earlier step's rows: `{{label.column}}` becomes a literal value list (SQL `(...)`, MongoDB JSON array; quoted, at most 1,000 source rows) and `{{label}}` a scratch table holding them (stored as `result_N`, steps on warehouse `scratch`). A referenced step fetches up to 1,000 rows. A reference to a failed, missing, oversized or non-scratch step fails only that step; the rest still run. Substituted SQL goes through the usual read-only checks. Descriptions +132 chars. Tests: `server.test.ts` (ALTIMATE_MCP_E2E=1): same-connection chain, cross-connection list + scratch join, over-cap list, mid-batch error. Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/mcp-server/adapter.ts | 4 +- .../src/altimate/mcp-server/server.ts | 2 +- .../src/altimate/tools/sql-execute.ts | 91 ++++++++++++++-- .../test/altimate/mcp-server/server.test.ts | 103 ++++++++++++++++++ 4 files changed, 189 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/altimate/mcp-server/adapter.ts b/packages/opencode/src/altimate/mcp-server/adapter.ts index 6e4f20c9fd..827c62ba9c 100644 --- a/packages/opencode/src/altimate/mcp-server/adapter.ts +++ b/packages/opencode/src/altimate/mcp-server/adapter.ts @@ -26,7 +26,7 @@ export const descriptions: Record = { warehouse_add: "Add a connection when warehouse_list shows none or the one you need is missing.", schema_inspect: "List tables when table is omitted; otherwise one table's columns and value profile.", sql_execute: - "Run SQL and return rows. Put independent queries in one call with `queries`; `tables` describes tables in the same call. Source writes need --allow-write.", + "Run SQL and return rows. Chain queries in one call with `queries`: a later one can use {{label.column}} (value list from an earlier result) or {{label}} (its rows as a scratch table, warehouse scratch). `tables` describes tables first. Source writes need --allow-write.", sql_check: "Read-only data check: fanout, keys, grain, nulls or coverage.", sql_materialize: "Store a read-only query's rows as a scratch table, to join across connections.", answer_commit: @@ -54,7 +54,7 @@ const PROPERTY_DESCRIPTIONS: Record void } +// Batch steps reference earlier steps: {{label.column}} (literal value list) or {{label}} (scratch table). +const STEP_REF = /\{\{([A-Za-z_]\w*)(?:\.([A-Za-z_]\w*))?\}\}/g +const MAX_BOUND_ROWS = 1000 + function generatedRead(sql: string, warehouse: string, stage: "source" | "result"): string { try { return readSql(sql, Registry.canonicalType(Registry.getConfig(warehouse)?.type) ?? undefined) @@ -75,7 +82,7 @@ const parameters = z .max(MAX_BATCH_QUERIES, `queries accepts at most ${MAX_BATCH_QUERIES} items`) .nullish() .describe( - `Up to ${MAX_BATCH_QUERIES} independent read-only queries run in one call, each returning its own labeled result; one failing query does not stop the others. Use instead of query when no query depends on another's result.`, + `Up to ${MAX_BATCH_QUERIES} read-only queries run in order in one call, each returning its own labeled result; one failing query does not stop the others. A later query can use an earlier one's rows: {{label.column}} becomes a literal value list (at most ${MAX_BOUND_ROWS} rows) and {{label}} a scratch table (warehouse scratch).`, ), query_from: z .object({ @@ -123,7 +130,7 @@ export const SqlExecuteTool = Tool.define( description: "Execute SQL against a connected data warehouse. Returns all selected columns as a formatted table. full_text_columns prioritizes named cells within the ordinary output budget and always saves returned rows to result_path; read with char_offset/max_chars recovers clipped text. [] or null, or omitting the field, turns the mode off. A non-empty list caps preview rows at 20: omit limit to use the default; larger limits clamp to 20 with a result note. Missing column names produce a bounded note listing available columns while still returning rows. " + "Provide exactly one of query, query_from or queries. query_from runs a read-only generator SELECT and executes its single SQL string result (up to 1 MiB) as a read-only SELECT on warehouse, without previewing or retyping the generated SQL. " + - `queries runs up to ${MAX_BATCH_QUERIES} independent read-only queries in one call and returns one labeled result per query; batch queries that do not depend on each other. ` + + `queries runs up to ${MAX_BATCH_QUERIES} read-only queries in order in one call and returns one labeled result per query; a later query can reference an earlier one with {{label.column}} (value list) or {{label}} (scratch table). ` + "For DuckDB table sets, generate query_table([...], true) with escaped table-name string literals. For thousands of tables, UNION ALL BY NAME batches of at most 128 names to stay within the default expression depth.", parameters, formatValidationError: (error: z.ZodError) => error.issues?.map((issue) => issue.message).join("; ") || LIMIT_ERROR, @@ -310,7 +317,7 @@ export const SqlExecuteTool = Tool.define( const result = await Dispatcher.call("sql.execute", { sql: query, warehouse: args.warehouse, - limit, + limit: Math.max(limit, budget?.minRows ?? 0), query_timeout_ms: args.query_timeout_ms, ...(readOnly ? { read_only: true } : {}), }, { signal: ctx.abort, sessionID: ctx.sessionID, ...(ctx.extra?.forceReadOnly === true ? { forceReadOnly: true } : {}) }) @@ -341,6 +348,7 @@ export const SqlExecuteTool = Tool.define( }) } // altimate_change end + budget?.capture?.(result) // altimate_change start — emit SQL structure fingerprint telemetry on the // success path, BEFORE formatting the result. A query that reached this point @@ -396,7 +404,7 @@ export const SqlExecuteTool = Tool.define( const lint = await lintSqlResult(sql, args.warehouse, ctx.sessionID) const formatted = await formatSqlResult(result, { mcpClient: ctx.extra?.mcpClient === true, - limit, + limit: Math.max(limit, budget?.minRows ?? 0), fullTextColumns: args.full_text_columns, // The statement text lets the formatter add the empty/near-empty // result hint when a string-literal filter is present. @@ -512,21 +520,36 @@ async function executeBatch( let linesLeft = configured.maxLines const blocks: string[] = [] const summary: { label: string; rowCount: number; error?: string; result_path?: string }[] = [] + const labels = items.map((item, index) => (item.label ?? "").replace(/\s+/g, " ").trim().slice(0, 60) || `q${index + 1}`) + // Steps a later step references, as a value list ({{label.column}}) or a scratch table ({{label}}). + const listRefs = new Set() + const tableRefs = new Set() + for (const item of items.slice(1)) + for (const [, ref, column] of item.query.matchAll(STEP_REF)) (column ? listRefs : tableRefs).add(ref) + const captured = new Map() + const stored = new Map() for (const [index, item] of items.entries()) { ctx.abort.throwIfAborted() const share = Math.floor(bytesLeft / (items.length - index)) const lineShare = Math.max(1, Math.floor(linesLeft / (items.length - index))) - const label = (item.label ?? "").replace(/\s+/g, " ").trim().slice(0, 60) || `q${index + 1}` + const label = labels[index] const header = `[${label}]\n` const separator = blocks.length ? "\n\n" : "" const reserved = Buffer.byteLength(separator + header, "utf8") + Buffer.byteLength(BATCH_CLIPPED, "utf8") - const budget = { maxBytes: Math.max(0, share - reserved), maxLines: Math.max(1, lineShare - 2) } + const referenced = listRefs.has(label) || tableRefs.has(label) + const budget: ItemBudget = { + maxBytes: Math.max(0, share - reserved), + maxLines: Math.max(1, lineShare - 2), + ...(referenced ? { minRows: MAX_BOUND_ROWS, capture: (raw: SqlExecuteResult) => captured.set(label, raw) } : {}), + } + const warehouse = item.warehouse?.trim() || args.warehouse let result: LegacyResult try { + const query = resolveStepRefs(item.query, labels.slice(0, index), captured, stored, warehouse) result = await run( { - query: item.query, - warehouse: item.warehouse?.trim() || args.warehouse, + query, + warehouse, limit: args.limit, full_text_columns: args.full_text_columns, query_timeout_ms: args.query_timeout_ms, @@ -538,6 +561,14 @@ async function executeBatch( const message = error instanceof Error ? error.message : String(error) result = { title: "SQL: ERROR", metadata: { error: message }, output: `Failed to execute SQL: ${message}` } } + const failedStep = typeof result.metadata?.error === "string" && result.metadata?.success !== true + if (failedStep) captured.delete(label) + // A step referenced as {{label}} is stored in scratch (result_N), like a spilled result. + const raw = captured.get(label) + if (raw && tableRefs.has(label) && !raw.truncated) { + const table = await storeSpill(raw, { signal: ctx.abort, sessionID: ctx.sessionID }) + if (table) stored.set(label, table) + } const body = clipToBudget(result.output, budget.maxBytes, budget.maxLines) const block = separator + header + body blocks.push(block) @@ -566,6 +597,50 @@ async function executeBatch( } } +/** + * Replaces {{label.column}} with a literal list of that earlier step's distinct column values ((...) for SQL, a JSON + * array for MongoDB) and {{label}} with the scratch table holding its rows. Throws, failing only this step, when the + * reference names no earlier successful step, a missing column, a step with more than MAX_BOUND_ROWS rows, or a + * table reference outside scratch. + */ +function resolveStepRefs( + query: string, + earlier: string[], + captured: Map, + stored: Map, + warehouse: string | undefined, +): string { + return query.replace(STEP_REF, (match, ref: string, column: string | undefined) => { + if (!earlier.includes(ref)) throw new Error(`${match} names no earlier step in this call.`) + const source = captured.get(ref) + if (!source) throw new Error(`${match}: step "${ref}" failed, so this step did not run.`) + if (source.truncated) + throw new Error(`${match}: step "${ref}" has more than ${MAX_BOUND_ROWS} rows; filter or aggregate it first.`) + if (!column) { + if (!isScratch(warehouse)) + throw new Error(`${match} is a scratch table: run this step on warehouse "scratch", or use {{${ref}.column}}.`) + const table = stored.get(ref) + if (!table) throw new Error(`${match}: storing step "${ref}" in scratch failed.`) + return table + } + const index = source.columns.findIndex((name) => name.toLowerCase() === column.toLowerCase()) + if (index < 0) throw new Error(`${match}: step "${ref}" has no column ${column} (columns: ${source.columns.join(", ")}).`) + const values = [...new Set(source.rows.map((row) => row[index]).filter((value) => value !== null && value !== undefined))] + if (isMongoWarehouse(warehouse)) return JSON.stringify(values) + const mysql = ["mysql", "mariadb"].includes(Registry.canonicalType(warehouse ? Registry.getConfig(warehouse)?.type : undefined) ?? "") + return values.length ? `(${values.map((value) => sqlLiteral(value, mysql)).join(", ")})` : "(NULL)" + }) +} + +function sqlLiteral(value: unknown, mysql: boolean): string { + if (typeof value === "number" && Number.isFinite(value)) return String(value) + if (typeof value === "bigint") return value.toString() + if (typeof value === "boolean") return value ? "TRUE" : "FALSE" + const text = typeof value === "string" ? value : value instanceof Date ? value.toISOString() : JSON.stringify(value) + const escaped = text.replaceAll("'", "''") + return `'${mysql ? escaped.replaceAll("\\", "\\\\") : escaped}'` +} + const BATCH_CLIPPED = "…[output clipped]" /** Clip text to a byte and line budget on code-point boundaries, marking any clip. */ diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index 5fd076931c..178d86c9ff 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -1152,3 +1152,106 @@ mcpTest("warehouse_add of a SQLite file this process cannot write opens it read- expect(rows.body).toContain("quince") }) // altimate_change end + +// altimate_change start — chained batch steps: {{label.column}} value lists and {{label}} scratch tables +async function chainFixture() { + const dir = await mkdtemp(path.join(os.tmpdir(), "mcp-chain-test-")) + cleanups.push(() => rm(dir, { recursive: true, force: true })) + const a = new Database(path.join(dir, "a.sqlite")) + a.run("CREATE TABLE items (id INTEGER, name TEXT)") + a.run("INSERT INTO items VALUES (1, 'apple'), (2, 'pear'), (3, 'o''brien')") + a.run("CREATE TABLE big (id INTEGER)") + a.run(`INSERT INTO big VALUES ${Array.from({ length: 1500 }, (_, i) => `(${i})`).join(",")}`) + a.close() + const b = new Database(path.join(dir, "b.sqlite")) + b.run("CREATE TABLE prices (item_id INTEGER, label TEXT, price REAL)") + b.run("INSERT INTO prices VALUES (1, 'apple', 3.5), (2, 'pear', 1.25), (3, 'o''brien', 9.75)") + b.close() + const config = path.join(dir, "connections.json") + await Bun.write(config, JSON.stringify({ a: { type: "sqlite", path: "a.sqlite" }, b: { type: "sqlite", path: "b.sqlite" } })) + const server = await createServer({ connections: config }) + const client = new Client({ name: "test", version: "1" }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + await client.connect(clientTransport) + cleanups.push(async () => { + await client.close() + await server.close() + await Registry.closeAll() + }) + return async (queries: Record[], warehouse = "a") => { + const result = await client.callTool({ name: "sql_execute", arguments: { warehouse, queries } }) + return { result, body: (result.content as { text?: string }[]).map((part) => part.text ?? "").join("\n") } + } +} + +function block(body: string, label: string): string { + const start = body.indexOf(`[${label}]`) + if (start < 0) return "" + const next = body.indexOf("\n\n[", start + 1) + return body.slice(start, next < 0 ? undefined : next) +} + +mcpTest("a later batch step filters on an earlier step's column on the same connection", async () => { + const batch = await chainFixture() + const { result, body } = await batch([ + { label: "pick", query: "SELECT id FROM items WHERE name IN ('apple', 'o''brien')" }, + { label: "names", query: "SELECT name FROM items WHERE id IN {{pick.id}} ORDER BY id" }, + ]) + expect(result.isError).toBe(false) + expect(block(body, "names")).toContain("apple") + expect(block(body, "names")).toContain("o'brien") + expect(block(body, "names")).not.toContain("pear") +}) + +mcpTest("a batch step feeds another connection's IN list, and a scratch step joins stored step rows", async () => { + const batch = await chainFixture() + const { result, body } = await batch([ + { label: "ids", warehouse: "a", query: "SELECT id, name FROM items WHERE name <> 'pear'" }, + { label: "price", warehouse: "b", query: "SELECT item_id, label, price FROM prices WHERE label IN {{ids.name}}" }, + { + label: "joined", + warehouse: "scratch", + query: "SELECT i.name, p.price FROM {{ids}} i JOIN {{price}} p ON p.item_id = i.id ORDER BY p.price DESC", + }, + ]) + expect(result.isError).toBe(false) + expect(block(body, "price")).toContain("3.5") + expect(block(body, "price")).not.toContain("1.25") + expect(block(body, "joined")).toContain("9.75") + expect(block(body, "joined")).toContain("o'brien") + // A table reference outside scratch fails only that step. + const outside = await batch([ + { label: "ids", query: "SELECT id FROM items" }, + { label: "bad", query: "SELECT * FROM {{ids}}" }, + ]) + expect(block(outside.body, "bad")).toContain('warehouse "scratch"') +}) + +mcpTest("a value list from a step with more than the bound row cap fails that step only", async () => { + const batch = await chainFixture() + const { body } = await batch([ + { label: "all", query: "SELECT id FROM big" }, + { label: "use", query: "SELECT count(*) AS n FROM big WHERE id IN {{all.id}}" }, + { label: "few", query: "SELECT id FROM big WHERE id < 3" }, + { label: "small", query: "SELECT count(*) AS n FROM big WHERE id IN {{few.id}}" }, + ]) + expect(block(body, "use")).toContain("more than 1000 rows") + expect(block(body, "small")).toMatch(/\b3\b/) +}) + +mcpTest("a failing batch step fails its dependents and the remaining steps still run", async () => { + const batch = await chainFixture() + const { result, body } = await batch([ + { label: "bad", query: "SELEC id FROM items" }, + { label: "dep", query: "SELECT * FROM items WHERE id IN {{bad.id}}" }, + { label: "missing", query: "SELECT * FROM items WHERE id IN {{nope.id}}" }, + { label: "ok", query: "SELECT 42 AS answer" }, + ]) + expect(result.isError).toBe(false) + expect(block(body, "bad")).toContain("Failed") + expect(block(body, "dep")).toContain('step "bad" failed') + expect(block(body, "missing")).toContain("names no earlier step") + expect(block(body, "ok")).toContain("42") +}) +// altimate_change end From 07b3c80142480c56ed1e00c67e7a4d6b859f63c6 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Tue, 20 Oct 2026 18:56:18 -0700 Subject: [PATCH 39/39] fix(mcp): expand `{{label.column}}` to bare comma-separated values In trials the model wrote `IN ({{x.id}})` and DuckDB lists `[{{x.id}}]`; the parenthesized expansion turned those into row values/STRUCTs and failed 6 of 9 chained calls. Values are now comma-separated literals (NULL when empty) that fit `IN (...)`, DuckDB lists and MongoDB arrays; an empty earlier step whose driver reports no column names also expands to NULL. Descriptions show `IN ({{label.column}})`. Tests: `server.test.ts` (ALTIMATE_MCP_E2E=1): IN (...) forms, DuckDB list, empty earlier step. Co-Authored-By: Claude Opus 5.5 --- .../src/altimate/mcp-server/adapter.ts | 2 +- .../src/altimate/tools/sql-execute.ts | 15 ++++++++----- .../test/altimate/mcp-server/server.test.ts | 22 ++++++++++++++----- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/altimate/mcp-server/adapter.ts b/packages/opencode/src/altimate/mcp-server/adapter.ts index 827c62ba9c..4d3e8efb84 100644 --- a/packages/opencode/src/altimate/mcp-server/adapter.ts +++ b/packages/opencode/src/altimate/mcp-server/adapter.ts @@ -26,7 +26,7 @@ export const descriptions: Record = { warehouse_add: "Add a connection when warehouse_list shows none or the one you need is missing.", schema_inspect: "List tables when table is omitted; otherwise one table's columns and value profile.", sql_execute: - "Run SQL and return rows. Chain queries in one call with `queries`: a later one can use {{label.column}} (value list from an earlier result) or {{label}} (its rows as a scratch table, warehouse scratch). `tables` describes tables first. Source writes need --allow-write.", + "Run SQL and return rows. Chain queries in one call with `queries`: a later one can use IN ({{label.column}}) (an earlier result's values) or {{label}} (its rows as a scratch table, warehouse scratch). `tables` describes tables first. Source writes need --allow-write.", sql_check: "Read-only data check: fanout, keys, grain, nulls or coverage.", sql_materialize: "Store a read-only query's rows as a scratch table, to join across connections.", answer_commit: diff --git a/packages/opencode/src/altimate/tools/sql-execute.ts b/packages/opencode/src/altimate/tools/sql-execute.ts index ff5a826d5b..050b2e6c6b 100644 --- a/packages/opencode/src/altimate/tools/sql-execute.ts +++ b/packages/opencode/src/altimate/tools/sql-execute.ts @@ -82,7 +82,7 @@ const parameters = z .max(MAX_BATCH_QUERIES, `queries accepts at most ${MAX_BATCH_QUERIES} items`) .nullish() .describe( - `Up to ${MAX_BATCH_QUERIES} read-only queries run in order in one call, each returning its own labeled result; one failing query does not stop the others. A later query can use an earlier one's rows: {{label.column}} becomes a literal value list (at most ${MAX_BOUND_ROWS} rows) and {{label}} a scratch table (warehouse scratch).`, + `Up to ${MAX_BATCH_QUERIES} read-only queries run in order in one call, each returning its own labeled result; one failing query does not stop the others. A later query can use an earlier one's rows: {{label.column}} becomes its values as comma-separated literals, e.g. IN ({{ids.id}}) (at most ${MAX_BOUND_ROWS} rows) and {{label}} a scratch table (warehouse scratch).`, ), query_from: z .object({ @@ -130,7 +130,7 @@ export const SqlExecuteTool = Tool.define( description: "Execute SQL against a connected data warehouse. Returns all selected columns as a formatted table. full_text_columns prioritizes named cells within the ordinary output budget and always saves returned rows to result_path; read with char_offset/max_chars recovers clipped text. [] or null, or omitting the field, turns the mode off. A non-empty list caps preview rows at 20: omit limit to use the default; larger limits clamp to 20 with a result note. Missing column names produce a bounded note listing available columns while still returning rows. " + "Provide exactly one of query, query_from or queries. query_from runs a read-only generator SELECT and executes its single SQL string result (up to 1 MiB) as a read-only SELECT on warehouse, without previewing or retyping the generated SQL. " + - `queries runs up to ${MAX_BATCH_QUERIES} read-only queries in order in one call and returns one labeled result per query; a later query can reference an earlier one with {{label.column}} (value list) or {{label}} (scratch table). ` + + `queries runs up to ${MAX_BATCH_QUERIES} read-only queries in order in one call and returns one labeled result per query; a later query can reference an earlier one with IN ({{label.column}}) (its values) or {{label}} (scratch table). ` + "For DuckDB table sets, generate query_table([...], true) with escaped table-name string literals. For thousands of tables, UNION ALL BY NAME batches of at most 128 names to stay within the default expression depth.", parameters, formatValidationError: (error: z.ZodError) => error.issues?.map((issue) => issue.message).join("; ") || LIMIT_ERROR, @@ -598,8 +598,9 @@ async function executeBatch( } /** - * Replaces {{label.column}} with a literal list of that earlier step's distinct column values ((...) for SQL, a JSON - * array for MongoDB) and {{label}} with the scratch table holding its rows. Throws, failing only this step, when the + * Replaces {{label.column}} with that earlier step's distinct column values as comma-separated literals (no brackets, + * so `IN ({{x.id}})`, a DuckDB list `[{{x.id}}]` and a MongoDB `[{{x.id}}]` all work; NULL when empty) and {{label}} + * with the scratch table holding its rows. Throws, failing only this step, when the * reference names no earlier successful step, a missing column, a step with more than MAX_BOUND_ROWS rows, or a * table reference outside scratch. */ @@ -624,11 +625,13 @@ function resolveStepRefs( return table } const index = source.columns.findIndex((name) => name.toLowerCase() === column.toLowerCase()) + // Some drivers return no column names for an empty result; an empty step is an empty list either way. + if (index < 0 && !source.rows.length && !source.columns.length) return isMongoWarehouse(warehouse) ? "null" : "NULL" if (index < 0) throw new Error(`${match}: step "${ref}" has no column ${column} (columns: ${source.columns.join(", ")}).`) const values = [...new Set(source.rows.map((row) => row[index]).filter((value) => value !== null && value !== undefined))] - if (isMongoWarehouse(warehouse)) return JSON.stringify(values) + if (isMongoWarehouse(warehouse)) return values.map((value) => JSON.stringify(value)).join(", ") || "null" const mysql = ["mysql", "mariadb"].includes(Registry.canonicalType(warehouse ? Registry.getConfig(warehouse)?.type : undefined) ?? "") - return values.length ? `(${values.map((value) => sqlLiteral(value, mysql)).join(", ")})` : "(NULL)" + return values.length ? values.map((value) => sqlLiteral(value, mysql)).join(", ") : "NULL" }) } diff --git a/packages/opencode/test/altimate/mcp-server/server.test.ts b/packages/opencode/test/altimate/mcp-server/server.test.ts index 178d86c9ff..ba363404f3 100644 --- a/packages/opencode/test/altimate/mcp-server/server.test.ts +++ b/packages/opencode/test/altimate/mcp-server/server.test.ts @@ -1196,19 +1196,29 @@ mcpTest("a later batch step filters on an earlier step's column on the same conn const batch = await chainFixture() const { result, body } = await batch([ { label: "pick", query: "SELECT id FROM items WHERE name IN ('apple', 'o''brien')" }, - { label: "names", query: "SELECT name FROM items WHERE id IN {{pick.id}} ORDER BY id" }, + { label: "names", query: "SELECT name FROM items WHERE id IN ({{pick.id}}) ORDER BY id" }, ]) expect(result.isError).toBe(false) expect(block(body, "names")).toContain("apple") expect(block(body, "names")).toContain("o'brien") expect(block(body, "names")).not.toContain("pear") + // The values also fit a DuckDB list, and an empty earlier result matches nothing instead of failing. + const more = await batch([ + { label: "pick", warehouse: "a", query: "SELECT id FROM items WHERE name = 'pear'" }, + { label: "none", warehouse: "a", query: "SELECT id FROM items WHERE name = 'kiwi'" }, + { label: "list", warehouse: "scratch", query: "SELECT list_contains([{{pick.id}}], 2) AS has_pear" }, + { label: "empty", warehouse: "a", query: "SELECT count(*) AS n FROM items WHERE id IN ({{none.id}})" }, + ]) + expect(block(more.body, "list")).toContain("true") + expect(block(more.body, "empty")).not.toContain("Failed") + expect(block(more.body, "empty")).toMatch(/\b0\b/) }) mcpTest("a batch step feeds another connection's IN list, and a scratch step joins stored step rows", async () => { const batch = await chainFixture() const { result, body } = await batch([ { label: "ids", warehouse: "a", query: "SELECT id, name FROM items WHERE name <> 'pear'" }, - { label: "price", warehouse: "b", query: "SELECT item_id, label, price FROM prices WHERE label IN {{ids.name}}" }, + { label: "price", warehouse: "b", query: "SELECT item_id, label, price FROM prices WHERE label IN ({{ids.name}})" }, { label: "joined", warehouse: "scratch", @@ -1232,9 +1242,9 @@ mcpTest("a value list from a step with more than the bound row cap fails that st const batch = await chainFixture() const { body } = await batch([ { label: "all", query: "SELECT id FROM big" }, - { label: "use", query: "SELECT count(*) AS n FROM big WHERE id IN {{all.id}}" }, + { label: "use", query: "SELECT count(*) AS n FROM big WHERE id IN ({{all.id}})" }, { label: "few", query: "SELECT id FROM big WHERE id < 3" }, - { label: "small", query: "SELECT count(*) AS n FROM big WHERE id IN {{few.id}}" }, + { label: "small", query: "SELECT count(*) AS n FROM big WHERE id IN ({{few.id}})" }, ]) expect(block(body, "use")).toContain("more than 1000 rows") expect(block(body, "small")).toMatch(/\b3\b/) @@ -1244,8 +1254,8 @@ mcpTest("a failing batch step fails its dependents and the remaining steps still const batch = await chainFixture() const { result, body } = await batch([ { label: "bad", query: "SELEC id FROM items" }, - { label: "dep", query: "SELECT * FROM items WHERE id IN {{bad.id}}" }, - { label: "missing", query: "SELECT * FROM items WHERE id IN {{nope.id}}" }, + { label: "dep", query: "SELECT * FROM items WHERE id IN ({{bad.id}})" }, + { label: "missing", query: "SELECT * FROM items WHERE id IN ({{nope.id}})" }, { label: "ok", query: "SELECT 42 AS answer" }, ]) expect(result.isError).toBe(false)