feat(agent): unify MCP tools and adopt Responses delivery
Move project and host queries into native MCP with host-owned OAuth/context, while retaining memory, skills, actions and publication plugins. Add nearby-model queries and explicit chat/task/learning model defaults. Adopt Responses HTTP/SSE and native steer/queue inbox delivery, preserving scoped session ownership, pending input edits and investigation communication. Disable local-provider discovery: Server /health allowed native vLLM to probe /v1/models on port 8000. A native before/after regression prevents recurrence. Validation: 170 focused Bun tests; service/plugin typechecks; native discovery, provider-discovery and input-inbox smoke checks; no paid model inference.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import type { CallToolResult } from "@modelcontextprotocol/client";
|
||||
import { queryMcpToolId } from "../runtime/product-mcp-tools.js";
|
||||
|
||||
// The local server exposes tools only. Retain the saved Server evidence, but
|
||||
// replace its upstream-only resource link with this server's paging tool.
|
||||
export function localEvidenceResult(result: CallToolResult): CallToolResult {
|
||||
if (result.isError || !result.content.some((item) => item.type === "resource_link" && item.uri.startsWith("tjwater://"))) return result;
|
||||
const raw = result.structuredContent;
|
||||
const output = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : undefined;
|
||||
const evidenceId = output?.evidence_id;
|
||||
if (!output || typeof evidenceId !== "string" || !evidenceId || evidenceId.length > 160) throw new Error("Saved evidence response has no identifier");
|
||||
const originalData = output.data && typeof output.data === "object" && !Array.isArray(output.data) ? output.data as Record<string, unknown> : {};
|
||||
const { resource_uri: _uri, ...data } = originalData;
|
||||
const next = { ...output,
|
||||
data: { ...data, evidence_read: { tool: queryMcpToolId("get_task_evidence"), arguments: { evidence_id: evidenceId, limit: 20, offset: 0 } } },
|
||||
data_gaps: [...(Array.isArray(output.data_gaps) ? output.data_gaps.filter((gap) => gap !== "Inline result abbreviated; read the saved evidence resource") : []),
|
||||
"Inline result abbreviated; use evidence_read with a small page and select deeper data_path values when needed"],
|
||||
};
|
||||
return { ...result, structuredContent: next,
|
||||
content: [{ type: "text", text: JSON.stringify(next) }, ...result.content.filter((item) => item.type !== "text" && item.type !== "resource_link")],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as z from "zod/v4";
|
||||
import { ExternalInformationClient, geocodeInput } from "../product/external-information-client.js";
|
||||
import type { SessionTranscriptStore } from "../sessions/transcriptStore.js";
|
||||
import type { RuntimeSessionContext } from "../runtime/sessionContext.js";
|
||||
import type { LocalQueryName } from "./query-server.js";
|
||||
|
||||
const paging = { limit: z.number().int().min(1).max(50).default(20), offset: z.number().int().min(0).max(10000).default(0) };
|
||||
export const localQueryInputs = {
|
||||
geocode: z.object({ keyword: z.string().trim().min(1).max(500) }).strict(),
|
||||
session_search: z.object({ reason: z.string().max(2000).optional(), query: z.string().trim().min(1).max(2000), max_results: z.number().int().min(1).max(50).default(20) }).strict(),
|
||||
list_investigation_tasks: z.object(paging).strict(),
|
||||
get_investigation_task: z.object({ task_id: z.string().uuid(), run_id: z.string().uuid().optional() }).strict(),
|
||||
read_agent_messages: z.object({ cursor: z.string().max(200).optional(), limit: paging.limit }).strict(),
|
||||
todoread: z.object(paging).strict(),
|
||||
};
|
||||
export const localQueryDescriptions: Record<LocalQueryName, string> = {
|
||||
geocode: "查询中国地址的经纬度,返回EPSG:4326坐标;不移动地图。",
|
||||
session_search: "搜索当前用户和个人或项目范围内的历史会话,默认20条。身份和范围由宿主绑定。",
|
||||
list_investigation_tasks: "分页读取当前验证项目可见的调查任务,返回真实task_id和run_id。不会启动或恢复调查。",
|
||||
get_investigation_task: "读取当前项目可见调查任务及其运行,task_id和run_id使用列表返回的值。",
|
||||
read_agent_messages: "按游标读取当前会话或绑定调查运行收到的消息,默认20条。",
|
||||
todoread: "分页读取当前会话的任务清单。",
|
||||
};
|
||||
const page = (items: unknown[], limit: number, offset: number) => ({ items: items.slice(offset, offset + limit), page: { limit, offset, total: items.length, has_more: offset + limit < items.length } });
|
||||
|
||||
export function createLocalQueryExecutor(options: {
|
||||
transcripts: SessionTranscriptStore;
|
||||
messageQuery: (id: string, operation: "list" | "get" | "read", input: unknown) => Promise<unknown>;
|
||||
readTodos: (id: string, context: RuntimeSessionContext, signal: AbortSignal) => Promise<unknown[]>;
|
||||
external?: ExternalInformationClient;
|
||||
}) {
|
||||
const external = options.external ?? new ExternalInformationClient();
|
||||
return async (name: LocalQueryName, args: Record<string, unknown>, context: RuntimeSessionContext, signal: AbortSignal): Promise<unknown> => {
|
||||
signal.throwIfAborted();
|
||||
args = localQueryInputs[name].parse(args);
|
||||
if (name === "geocode") return external.geocode(context.sessionId, geocodeInput.parse(args), signal);
|
||||
if (name === "session_search") {
|
||||
const input = localQueryInputs.session_search.parse(args);
|
||||
return { query: input.query, hits: await options.transcripts.search({ actorKey: context.actorKey, projectKey: context.projectKey }, input.query, input.max_results) };
|
||||
}
|
||||
if (name === "todoread") {
|
||||
const input = localQueryInputs.todoread.parse(args);
|
||||
return page(await options.readTodos(context.sessionId, context, signal), input.limit, input.offset);
|
||||
}
|
||||
return options.messageQuery(context.sessionId, name === "list_investigation_tasks" ? "list" : name === "get_investigation_task" ? "get" : "read", args);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { createMcpHandler, McpServer, fromJsonSchema, type Tool } from "@modelcontextprotocol/server";
|
||||
import * as z from "zod/v4";
|
||||
import { getRuntimeSessionContext, type RuntimeSessionContext } from "../runtime/sessionContext.js";
|
||||
import { LOCAL_QUERY_TOOLS, PRODUCT_MCP_TOOLS, queryMcpToolId } from "../runtime/product-mcp-tools.js";
|
||||
import type { McpQueryClient, QueryOwner } from "../product/mcp-query-client.js";
|
||||
import { localQueryDescriptions, localQueryInputs } from "./local-query-tools.js";
|
||||
import { allowedQueryTools } from "../runtime/query-mcp-policy.js";
|
||||
import { localEvidenceResult } from "./evidence-result.js";
|
||||
import { nativeQueryTimeout, queryTimeouts } from "./query-timeouts.js";
|
||||
|
||||
export type LocalQueryName = typeof LOCAL_QUERY_TOOLS[number];
|
||||
const productNames = new Set<string>(PRODUCT_MCP_TOOLS);
|
||||
|
||||
export function createQueryMcpHandler(options: {
|
||||
internalToken: string; productClient: Pick<McpQueryClient, "query"> | null; productTools: Tool[];
|
||||
resolveReader: (id: string) => Promise<QueryOwner | null>;
|
||||
executeLocal: (name: LocalQueryName, args: Record<string, unknown>, context: RuntimeSessionContext, signal: AbortSignal) => Promise<unknown>;
|
||||
}) {
|
||||
const definitions: Tool[] = [...options.productTools, ...LOCAL_QUERY_TOOLS.map((name) => ({ name, description: localQueryDescriptions[name],
|
||||
inputSchema: z.toJSONSchema(localQueryInputs[name], { io: "input" }) as Tool["inputSchema"], annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: name === "geocode" } }))];
|
||||
const handler = createMcpHandler(() => {
|
||||
const server = new McpServer({ name: "tjwater-agent-queries", version: "1.0.0" });
|
||||
for (const definition of definitions) {
|
||||
server.registerTool(definition.name, { ...definition, inputSchema: fromJsonSchema<Record<string, unknown>>(definition.inputSchema as Parameters<typeof fromJsonSchema>[0]), outputSchema: definition.outputSchema ? fromJsonSchema(definition.outputSchema) : undefined }, async (args, request) => {
|
||||
const id = request.mcpReq._meta?.["ai.opencode/sessionID"];
|
||||
if (typeof id !== "string" || !id || id.length > 128) return rejected("Authenticated native session metadata required");
|
||||
const direct = getRuntimeSessionContext(id);
|
||||
const owner = direct ? { context: direct, runtimeSessionId: id, taskId: direct.taskId } : await options.resolveReader(id);
|
||||
if (!owner || !allowedQueryTools(owner.context, !direct).includes(queryMcpToolId(definition.name))) return rejected("Query unavailable for this session");
|
||||
try {
|
||||
const budget = queryTimeouts(definition, args);
|
||||
const signal = AbortSignal.any([request.mcpReq.signal, AbortSignal.timeout(budget.requestMs)]);
|
||||
if (productNames.has(definition.name)) {
|
||||
if (!options.productClient) return rejected("Product MCP is disabled");
|
||||
const query = definition.name === "get_task_context" && args.report_revision === undefined && owner.context.reportRevision
|
||||
? { ...args, report_revision: owner.context.reportRevision } : args;
|
||||
// JSON-RPC IDs may restart on a native MCP reconnect. Use a host
|
||||
// request key, stable across this client's auth/transport retries,
|
||||
// so a later query cannot collide with a saved evidence receipt.
|
||||
return localEvidenceResult(await options.productClient.query(id, owner, definition.name, query, randomUUID(), signal));
|
||||
}
|
||||
const value = await options.executeLocal(definition.name as LocalQueryName, args, owner.context, signal);
|
||||
signal.throwIfAborted();
|
||||
return { content: [{ type: "text", text: JSON.stringify(value) }] };
|
||||
} catch (cause) {
|
||||
// Never return upstream bodies, headers or credentials across MCP.
|
||||
const status = cause && typeof cause === "object" && "status" in cause ? cause.status : undefined;
|
||||
return rejected(`Query unavailable${typeof status === "number" ? ` (${status})` : ""}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
return server;
|
||||
}, { legacy: "reject", maxRequestBodySize: 1024 * 1024 });
|
||||
return {
|
||||
executionTimeoutMs: nativeQueryTimeout(definitions),
|
||||
async fetch(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
if (url.hostname !== "127.0.0.1" || url.pathname !== "/mcp" || request.headers.has("Origin") || request.headers.has("Sec-Fetch-Site")) return new Response(null, { status: 403 });
|
||||
const provided = Buffer.from(request.headers.get("x-agent-internal-token") ?? "");
|
||||
const expected = Buffer.from(options.internalToken);
|
||||
if (!expected.length || provided.length !== expected.length || !timingSafeEqual(provided, expected)) return new Response(null, { status: 403 });
|
||||
return handler.fetch(request);
|
||||
},
|
||||
close: handler.close,
|
||||
};
|
||||
}
|
||||
const rejected = (message: string) => ({ content: [{ type: "text" as const, text: message }], isError: true });
|
||||
|
||||
export function startQueryMcpServer(options: Parameters<typeof createQueryMcpHandler>[0]) {
|
||||
const handler = createQueryMcpHandler(options);
|
||||
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, idleTimeout: 60,
|
||||
fetch(request, server) {
|
||||
// Native calls enforce a per-request deadline; an idle socket must not
|
||||
// disconnect a calculation before that deadline while no result is ready.
|
||||
server.timeout(request, 0);
|
||||
return handler.fetch(request);
|
||||
},
|
||||
});
|
||||
return { url: `http://127.0.0.1:${server.port}/mcp`, executionTimeoutMs: handler.executionTimeoutMs,
|
||||
async close() { await handler.close(); await server.stop(true); } };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Tool } from "@modelcontextprotocol/client";
|
||||
|
||||
// Absolute validation bound; the product catalog advertises the administrator's
|
||||
// effective maximum (one hour by default), never an identity or authorization grant.
|
||||
const ABSOLUTE_CALCULATION_SECONDS = 86_400;
|
||||
const CALCULATION_OVERHEAD_MS = 60_000;
|
||||
export const DEFAULT_QUERY_REQUEST_TIMEOUT_MS = 35_000;
|
||||
export const MAX_NATIVE_QUERY_TIMEOUT_MS = ABSOLUTE_CALCULATION_SECONDS * 1000 + CALCULATION_OVERHEAD_MS + 5000;
|
||||
|
||||
function timeoutSchema(tool: Tool) {
|
||||
const property = tool.inputSchema.properties?.timeout_seconds;
|
||||
return property && typeof property === "object" && !Array.isArray(property) ? property as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
export function queryTimeouts(tool: Tool, args: Record<string, unknown>) {
|
||||
const schema = timeoutSchema(tool);
|
||||
if (!schema) return { executionMs: 30_000, callMs: 30_000, requestMs: DEFAULT_QUERY_REQUEST_TIMEOUT_MS };
|
||||
const seconds = args.timeout_seconds === undefined ? schema.default ?? 120 : args.timeout_seconds;
|
||||
const maximum = schema.maximum;
|
||||
if (typeof maximum !== "number" || !Number.isInteger(maximum) || maximum < 1 || maximum > ABSOLUTE_CALCULATION_SECONDS ||
|
||||
typeof seconds !== "number" || !Number.isInteger(seconds) || seconds < 1 || seconds > maximum) {
|
||||
throw Object.assign(new Error("Calculation timeout must be an integer within the advertised server limit"), { status: 400 });
|
||||
}
|
||||
const executionMs = seconds * 1000;
|
||||
return { executionMs, callMs: executionMs + 5000, requestMs: executionMs + CALCULATION_OVERHEAD_MS };
|
||||
}
|
||||
|
||||
export function nativeQueryTimeout(tools: Tool[]) {
|
||||
return Math.max(DEFAULT_QUERY_REQUEST_TIMEOUT_MS, ...tools.map((tool) => {
|
||||
const schema = timeoutSchema(tool);
|
||||
return schema ? queryTimeouts(tool, { timeout_seconds: schema.maximum }).requestMs + 5000 : DEFAULT_QUERY_REQUEST_TIMEOUT_MS;
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user