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:
2026-10-19 18:46:58 +08:00
parent f5c3cde2c2
commit 6a49d2209e
164 changed files with 12286 additions and 2368 deletions
+76
View File
@@ -0,0 +1,76 @@
import { z } from "zod";
import { config } from "../config.js";
import { requestWithSessionCredential } from "./credential-refresh.js";
import type { ProductCredential } from "./server-auth-client.js";
import { OperationalTaskClientError } from "./operational-task-client.js";
export const agentMessageSchema = z.object({
message_id: z.string().uuid(), project_id: z.string().uuid(),
task_id: z.string().uuid(), run_id: z.string().uuid(), request_id: z.string().uuid(),
source_session_id: z.string(), source_owner_id: z.string(), conversation_owner_id: z.string(),
target_session_id: z.string().nullable(), direction: z.enum(["to_task", "to_chat"]),
in_reply_to: z.string().uuid().nullable(), text: z.string(),
status: z.enum(["pending", "enqueued", "delivered", "failed"]),
native_message_id: z.string(), failure_detail: z.string().nullable(),
created_at: z.string(), updated_at: z.string(), delivered_at: z.string().nullable(),
});
export type AgentMessage = z.infer<typeof agentMessageSchema>;
export const agentMessagePageSchema = z.object({ items: z.array(agentMessageSchema), next_cursor: z.string().nullable() });
export type MessageCredential = string | ProductCredential;
/** Business records are Server-owned; credentials never become model arguments. */
export class AgentMessageClient {
constructor(private readonly baseUrl = config.TJWATER_SERVER_BASE_URL) {}
async request(credential: MessageCredential, path: string, method = "GET", body?: unknown): Promise<unknown> {
const execute = (value: ProductCredential) => fetch(new URL(path, this.baseUrl), {
method, signal: AbortSignal.timeout(config.TJWATER_SERVER_TIMEOUT_MS),
headers: { Authorization: value.authorization, "X-Project-Id": value.projectId, "Content-Type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const response = typeof credential === "string"
? await requestWithSessionCredential(credential, execute) : await execute(credential);
const value: unknown = await response.json();
if (!response.ok) {
const problem = z.object({ code: z.string().optional(), message: z.string().optional(), detail: z.unknown().optional() }).passthrough().safeParse(value);
const nested = problem.success ? z.object({ code: z.string().optional(), message: z.string().optional() }).safeParse(problem.data.detail) : null;
throw new OperationalTaskClientError(response.status,
(nested?.success ? nested.data.message : undefined) ?? (problem.success ? problem.data.message : undefined) ?? "Agent message request failed",
(nested?.success ? nested.data.code : undefined) ?? (problem.success ? problem.data.code : undefined));
}
return value;
}
async list(credential: MessageCredential, sessionId: string, cursor?: string, limit = 50, pendingOnly = false) {
const query = new URLSearchParams({ session_id: sessionId, limit: String(limit) });
if (cursor) query.set("cursor", cursor);
if (pendingOnly) query.set("pending_only", "true");
return agentMessagePageSchema.parse(await this.request(credential, `/api/v1/agent-messages?${query}`));
}
async get(credential: MessageCredential, id: string) {
return agentMessageSchema.parse(await this.request(credential, `/api/v1/agent-messages/${encodeURIComponent(id)}`));
}
async send(sessionId: string, input: { request_id: string; task_id: string; run_id: string; message: string }) {
return agentMessageSchema.parse(await this.request(sessionId, "/api/v1/agent-messages", "POST", {
request_id: input.request_id, task_id: input.task_id, run_id: input.run_id,
source_session_id: sessionId, text: input.message,
}));
}
async reply(sessionId: string, input: { request_id: string; message_id: string; message: string }) {
return agentMessageSchema.parse(await this.request(sessionId, `/api/v1/agent-messages/${input.message_id}/reply`, "POST", {
request_id: input.request_id, source_session_id: sessionId, text: input.message,
}));
}
async receipt(credential: MessageCredential, item: AgentMessage, status: AgentMessage["status"], target: string | null, failure?: string) {
return agentMessageSchema.parse(await this.request(credential, `/api/v1/agent-messages/${item.message_id}/delivery`, "POST", {
status, target_session_id: target, native_message_id: item.native_message_id,
failure_detail: failure ?? null,
}));
}
}
export const agentMessageClient = new AgentMessageClient();
+7
View File
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { type ProductCredential } from "./server-auth-client.js";
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
export type CredentialRefreshReason =
| "access_token_expired"
@@ -245,7 +246,10 @@ export const credentialRefreshCoordinator = new CredentialRefreshCoordinator(45_
export const bindSessionCredential = (sessionId: string, credential: ProductCredential | null) => {
if (credential) { credentialVault.set(sessionId, { ...credential }); credentialFailures.delete(sessionId); }
};
const credentialReleaseListeners = new Set<(id: string) => void>();
export const onSessionCredentialReleased = (listener: (id: string) => void) => { credentialReleaseListeners.add(listener); return () => credentialReleaseListeners.delete(listener); };
export const releaseSessionCredential = (sessionId: string) => {
for (const listener of credentialReleaseListeners) listener(sessionId);
credentialRefreshCoordinator.cancelSession(sessionId);
credentialRefreshCoordinator.disableBackground(sessionId);
credentialVault.delete(sessionId);
@@ -264,6 +268,9 @@ export const requestWithSessionCredential = async (
sessionId: string, execute: (credential: ProductCredential) => Promise<Response>,
) => {
const credential = credentialVault.get(sessionId);
if (!credential && getRuntimeSessionContext(sessionId)?.projectKey.startsWith("personal-")) {
throw Object.assign(new Error("个人会话未关联项目,请选择项目会话后访问业务数据"), { status: 403, code: "PROJECT_CONTEXT_REQUIRED" });
}
if (!credential) throw Object.assign(new Error("task access requires a current product credential"), { status: 401 });
if (credentialFailures.has(sessionId)) throw Object.assign(new CredentialRefreshError("credential renewal failed"), { status: 401 });
try {
@@ -3,27 +3,11 @@ import { config } from "../config.js";
import { requestWithSessionCredential } from "./credential-refresh.js";
import type { components } from "../generated/server-api.js";
const domain = z.string().trim().min(1).max(253).regex(/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/);
export const webSearchInput = z.object({
query: z.string().trim().min(1).max(2000),
freshness: z.enum(["no_limit", "one_day", "one_week", "one_month", "one_year"]).default("no_limit"),
summary: z.boolean().default(true),
count: z.number().int().min(1).max(50).default(10),
include: z.array(domain).max(20).optional(),
exclude: z.array(domain).max(20).optional(),
}).strict();
export const geocodeInput = z.object({ keyword: z.string().trim().min(1).max(500) }).strict();
const freshness = { no_limit: "noLimit", one_day: "oneDay", one_week: "oneWeek", one_month: "oneMonth", one_year: "oneYear" } as const;
export class ExternalInformationClient {
constructor(private readonly baseUrl = config.TJWATER_SERVER_BASE_URL) {}
async webSearch(sessionId: string, input: z.infer<typeof webSearchInput>, signal?: AbortSignal) {
const parsed = webSearchInput.parse(input);
const body = { ...parsed, freshness: freshness[parsed.freshness] } satisfies components["schemas"]["WebSearchRequest"];
return this.request(sessionId, "/api/v1/web-searches", body, signal);
}
async geocode(sessionId: string, input: z.infer<typeof geocodeInput>, signal?: AbortSignal) {
const body = geocodeInput.parse(input) satisfies components["schemas"]["TiandituGeocodeRequest"];
const response = await this.request(sessionId, "/api/v1/geocoding-requests", body, signal);
+47
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import type { CallToolResult } from "@modelcontextprotocol/client";
const historyResponse = z.object({ series: z.array(z.object({
element_id: z.string(), metric: z.string(), source: z.string(), source_unit: z.string(),
@@ -16,3 +17,49 @@ export const historyChart = (value: unknown, elementId: string) => {
return { title: `${elementId} ${first.metric} (${first.source_unit}) · 最多100点`, chart_type: "line" as const,
x_data: points.map((point) => point.time), series: aligned.map(({ item, values }) => ({ name: `${item.device_id ?? elementId} ${item.source}`, data: points.map((point) => values.get(point.time) as number) })) };
};
export class HistoryQueryError extends Error {
constructor(readonly status: number, message: string) { super(message); }
}
export async function queryHistoryChart(elementId: string, readPage: (limit: number, offset: number) => Promise<CallToolResult>) {
const merged = new Map<string, z.infer<typeof historyResponse>["series"][number]>();
const paging = z.object({ limit: z.number().int().positive(), offset: z.number().int().nonnegative(), has_more: z.boolean() });
const envelope = z.object({ page: z.unknown().optional(), data: z.unknown().optional(), error: z.object({ code: z.string() }).optional() });
let hasMore = true;
let pageSize = 500;
let offset = 0;
// Bound both accepted rows and retries; the route also supplies one overall timeout.
for (let request = 0; request < 16 && offset < 1000; request++) {
const limit = Math.min(pageSize, 1000 - offset);
const result = await readPage(limit, offset);
const parsed = envelope.safeParse(result.structuredContent);
if (result.isError && parsed.success && parsed.data.error?.code === "RESULT_TOO_LARGE" && limit > 1) {
pageSize = Math.max(1, Math.floor(limit / 2));
continue;
}
if (result.isError) throw new HistoryQueryError(502, "历史查询暂不可用,请缩小时间范围后重试");
if (!parsed.success) throw new HistoryQueryError(502, "历史分页响应格式不正确");
const body = parsed.data;
const page = paging.parse(body.page);
if (page.offset !== offset || page.limit !== limit) throw new HistoryQueryError(502, "历史分页位置不一致");
hasMore = page.has_more;
for (const item of historyResponse.parse(body.data).series) {
const key = JSON.stringify([item.element_id, item.device_id, item.metric, item.source, item.source_unit]);
const previous = merged.get(key);
if (previous) previous.points.push(...item.points);
else merged.set(key, { ...item, points: [...item.points] });
}
offset += limit;
const usable = [...merged.values()].find((item) => item.element_id === elementId && item.points.some((point) => point.value !== null));
if (!hasMore || usable && usable.points.filter((point) => point.value !== null).length >= 100) break;
}
const values = [...merged.values()];
if (!values.some((item) => item.element_id === elementId && item.points.some((point) => point.value !== null))) {
throw new HistoryQueryError(hasMore ? 409 : 404, hasMore
? "查询预算内未找到有效样本,仍有未读取数据,请缩小时间范围"
: "所选时间窗没有可用监测样本");
}
const chart = historyChart({ series: values }, elementId);
return hasMore ? { ...chart, title: `${chart.title} · 未覆盖完整时间窗` } : chart;
}
+12
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { supplyLayerSchema } from "./task-presentation.js";
const text = z.string().trim().min(1);
export const investigationDiagramSchema = z.object({
@@ -12,12 +13,23 @@ export const investigationDiagramSchema = z.object({
label: text,
evidence_refs: z.array(text).default([]),
action_refs: z.array(z.string().uuid()).default([]),
status: z.enum(["pending", "running", "completed", "waiting_human", "failed", "skipped", "cancelled"]).nullish(),
map_targets: z.array(z.object({
source_id: supplyLayerSchema,
feature_id: text.max(128),
}).strict()).max(100).default([]),
}).strict()).max(100).default([]),
}).strict().superRefine((diagram, ctx) => {
if (new Set(diagram.node_refs.map((node) => node.node_id)).size !== diagram.node_refs.length) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "调查图节点引用重复" });
}
});
export const taskProgressSchema = z.object({
summary: text.max(20000),
diagram: investigationDiagramSchema.nullable().default(null),
}).strict();
export type TaskProgress = z.infer<typeof taskProgressSchema>;
const dataGapSchema = z.object({
gap_id: text,
node_id: text.nullish(),
+114
View File
@@ -0,0 +1,114 @@
import { discoverAuthorizationServerMetadata, discoverOAuthProtectedResourceMetadata, extractWWWAuthenticateParams } from "@modelcontextprotocol/client";
import { secureMcpUrl, validateDevHttpIssuer, type McpUrlPolicy } from "./mcp-url-policy.js";
export type McpHttpFetch = (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => ReturnType<typeof fetch>;
type Settings = McpUrlPolicy & { resource: string; issuer: string };
type Endpoint = { tokenEndpoint: string; authMethod: "client_secret_basic" | "client_secret_post" };
const unavailable = (message: string) => Object.assign(new Error(message), { status: 503 });
function oauthChallenge(response: Response) {
const header = response.headers.get("WWW-Authenticate") ?? "";
const challenges: string[] = [];
let start = 0, quoted = false, escaped = false;
for (let i = 0; i < header.length; i++) {
const char = header[i];
if (escaped) { escaped = false; continue; }
if (quoted && char === "\\") { escaped = true; continue; }
if (char === '"') quoted = !quoted;
if (!quoted && char === "," && /^\s*[^\s=,]+\s+(?!=)/.test(header.slice(i + 1))) {
challenges.push(header.slice(start, i).trim()); start = i + 1;
}
}
challenges.push(header.slice(start).trim());
for (const value of challenges) {
if (!/^(Bearer|DPoP)\s/i.test(value)) continue;
const parsed = extractWWWAuthenticateParams(new Response(null, { headers: { "WWW-Authenticate": value } }));
if (parsed.resourceMetadataUrl) return parsed;
}
return {};
}
/** Public metadata only. Configured issuer owns the pre-registered credentials. */
export class McpAuthDiscovery {
private cached?: { endpoint: Endpoint; expires: number };
private pending?: Promise<Endpoint>;
private epoch = 0;
constructor(private readonly settings: Settings, private readonly http: McpHttpFetch = fetch) {}
invalidate() { this.epoch++; this.cached = undefined; }
resolve(): Promise<Endpoint> {
if (this.cached && this.cached.expires > Date.now()) return Promise.resolve(this.cached.endpoint);
if (!this.pending) {
const epoch = this.epoch;
this.pending = this.discover().then((endpoint) => {
if (epoch === this.epoch) this.cached = { endpoint, expires: Date.now() + 300_000 };
return endpoint;
}).finally(() => { this.pending = undefined; });
}
return this.pending;
}
private async discover(): Promise<Endpoint> {
validateDevHttpIssuer(this.settings);
if (this.settings.devHttpIssuer && this.settings.devHttpIssuer !== this.settings.issuer) throw unavailable("MCP development issuer must match the configured issuer");
secureMcpUrl(this.settings.resource, this.settings);
secureMcpUrl(this.settings.issuer, this.settings, true);
const deadline = AbortSignal.timeout(10_000);
const publicFetch: McpHttpFetch = async (input, init) => {
const url = secureMcpUrl(input instanceof Request ? input.url : String(input), this.settings, true);
const headers = new Headers(init?.headers);
headers.delete("Authorization"); headers.delete("Cookie");
const response = await this.http(url, { ...init, headers, credentials: "omit", redirect: "error", signal: deadline });
// SDK parsers receive bounded public documents; no token or client secret is attached.
const reader = response.body?.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
if (reader) {
try {
for (;;) {
deadline.throwIfAborted();
const part = await reader.read();
if (part.done) break;
size += part.value.length;
if (size > 64 * 1024) throw unavailable("MCP OAuth metadata exceeds its size budget");
chunks.push(part.value);
}
} finally { await reader.cancel().catch(() => {}); }
}
return new Response(size ? Buffer.concat(chunks) : null, { status: response.status, headers: response.headers });
};
try {
const probe = await publicFetch(this.settings.resource, {
method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2026-07-28", "Mcp-Method": "server/discover" },
body: JSON.stringify({ jsonrpc: "2.0", id: "auth-discovery", method: "server/discover", params: { _meta: {
"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": { name: "tjwater-agent-host", version: "1.0.0" }, "io.modelcontextprotocol/clientCapabilities": {},
} } }),
});
const challenge = probe.status === 401 ? oauthChallenge(probe) : {};
await probe.body?.cancel();
const resource = await discoverOAuthProtectedResourceMetadata(this.settings.resource, {
protocolVersion: "2026-07-28", resourceMetadataUrl: challenge.resourceMetadataUrl,
}, publicFetch);
if (new URL(resource.resource).href !== new URL(this.settings.resource).href || !resource.authorization_servers?.includes(this.settings.issuer)) {
throw unavailable("MCP resource metadata differs from the configured resource or issuer");
}
const metadata = await discoverAuthorizationServerMetadata(this.settings.issuer, { fetchFn: publicFetch, protocolVersion: "2026-07-28" });
// Enforce exact issuer equality even where an SDK permits trailing-slash normalization.
if (!metadata || metadata.issuer !== this.settings.issuer || !metadata.token_endpoint) {
throw unavailable("MCP OAuth issuer metadata is incompatible with its registration");
}
const tokenEndpoint = secureMcpUrl(metadata.token_endpoint, this.settings, true);
if (new URL(this.settings.issuer).protocol === "https:" && tokenEndpoint.protocol !== "https:") {
throw unavailable("MCP OAuth token endpoint cannot downgrade HTTPS");
}
const methods = metadata.token_endpoint_auth_methods_supported ?? ["client_secret_basic"];
const authMethod = methods.includes("client_secret_post") ? "client_secret_post" : methods.includes("client_secret_basic") ? "client_secret_basic" : undefined;
if (!authMethod) throw unavailable("MCP OAuth server does not support the configured client secret");
return { tokenEndpoint: tokenEndpoint.href, authMethod };
} catch {
throw unavailable("MCP OAuth discovery failed or is incompatible with the configured resource and issuer");
}
}
}
+229
View File
@@ -0,0 +1,229 @@
import { Client, StreamableHTTPClientTransport, type Tool, UnauthorizedError } from "@modelcontextprotocol/client";
import { queryTimeouts } from "../mcp/query-timeouts.js";
import type { components } from "../generated/server-api.js";
import { config } from "../config.js";
import { requestWithSessionCredential, onSessionCredentialReleased } from "./credential-refresh.js";
import type { RuntimeSessionContext } from "../runtime/sessionContext.js";
import { PRODUCT_MCP_TOOLS } from "../runtime/product-mcp-tools.js";
import { McpAuthDiscovery, type McpHttpFetch } from "./mcp-auth-discovery.js";
import { secureMcpUrl, validateDevHttpIssuer, type McpUrlPolicy } from "./mcp-url-policy.js";
export type { McpHttpFetch } from "./mcp-auth-discovery.js";
export type QueryOwner = { context: RuntimeSessionContext; runtimeSessionId: string; taskId?: string };
export type McpQuerySettings = McpUrlPolicy & { issuer: string; resource: string; audience: string; clientId: string; clientSecret: string };
type Token = { token: string; expires: number; source: string };
const error = (status: number, message: string) => Object.assign(new Error(message), { status });
export function assertExchangedIdentity(source: string, exchanged: string, settings: McpQuerySettings) {
const payload = (token: string): Record<string, unknown> => {
const parts = token.replace(/^Bearer /i, "").split(".");
if (parts.length !== 3) throw error(503, "MCP exchange identity invalid");
try { return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record<string, unknown>; }
catch { throw error(503, "MCP exchange identity invalid"); }
};
const original = payload(source), target = payload(exchanged);
const audience = typeof target.aud === "string" ? [target.aud] : target.aud;
if (typeof original.sub !== "string" || !original.sub || target.sub !== original.sub || target.iss !== settings.issuer ||
!Array.isArray(audience) || !audience.includes(settings.audience) || target.azp !== settings.clientId) throw error(503, "MCP exchange changed identity or resource");
// This host check catches a misconfigured exchange. Signature/scopes/lifetime remain
// independently verified by the resource server; unverified claims never grant access.
}
export function validateMcpSettings(settings: McpQuerySettings) {
validateDevHttpIssuer(settings);
if (settings.devHttpIssuer && settings.devHttpIssuer !== settings.issuer) throw error(503, "MCP development issuer must match the configured issuer");
secureMcpUrl(settings.issuer, settings, true);
secureMcpUrl(settings.resource, settings);
if (new URL(settings.resource).pathname !== "/mcp" || !settings.clientId || !settings.clientSecret || !settings.audience) throw error(503, "MCP OAuth configuration is incomplete");
}
export class McpQueryClient {
private readonly tokens = new Map<string, Token>();
private readonly pending = new Map<string, Promise<Token>>();
private readonly epochs = new Map<string, number>();
private tools: Tool[] = [];
private readonly active = new Map<string, Set<AbortController>>();
private readonly authDiscovery: McpAuthDiscovery;
constructor(private readonly settings: McpQuerySettings, private readonly http: McpHttpFetch = fetch) {
validateMcpSettings(settings);
this.authDiscovery = new McpAuthDiscovery(settings, http);
}
dropSession(sessionId: string) {
this.tokens.delete(sessionId);
for (const controller of this.active.get(sessionId) ?? []) controller.abort(new Error("MCP session released"));
this.epochs.set(sessionId, (this.epochs.get(sessionId) ?? 0) + 1);
}
private async grant(body: URLSearchParams, signal?: AbortSignal): Promise<Response> {
const endpoint = await this.authDiscovery.resolve();
signal?.throwIfAborted();
body.set("resource", this.settings.resource);
const headers = new Headers();
if (endpoint.authMethod === "client_secret_post") {
body.set("client_id", this.settings.clientId);
body.set("client_secret", this.settings.clientSecret);
} else {
const encode = (value: string) => new URLSearchParams([["", value]]).toString().slice(1);
headers.set("Authorization", `Basic ${Buffer.from(`${encode(this.settings.clientId)}:${encode(this.settings.clientSecret)}`).toString("base64")}`);
}
const response = await this.http(endpoint.tokenEndpoint, {
method: "POST", headers, body, redirect: "error", signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(5000)]) : AbortSignal.timeout(5000),
});
if (response.status === 400) {
const rejected = await response.clone().json().catch(() => ({})) as { error?: string };
if (rejected.error === "invalid_target" || rejected.error === "invalid_resource") {
await response.body?.cancel();
throw error(503, "MCP OAuth server rejected the required resource indicator; check its RFC 8707 configuration");
}
}
return response;
}
private async parseToken(response: Response, source: string): Promise<Token> {
if (!response.ok) { await response.body?.cancel(); throw error(503, "MCP token exchange unavailable"); }
const data = await response.json() as Record<string, unknown>;
if (typeof data.access_token !== "string" || !data.access_token || data.access_token.length > 16384 ||
typeof data.expires_in !== "number" || data.expires_in < 30 || typeof data.token_type !== "string" || data.token_type.toLowerCase() !== "bearer") throw error(502, "MCP token response invalid");
return { token: data.access_token, expires: Date.now() + Math.min(data.expires_in, 300) * 1000, source };
}
private async userToken(ownerId: string, project: string, signal?: AbortSignal): Promise<Token> {
// Shared exchange is not cancelled by one caller; its five-second request is bounded.
// Credential renewal still uses the existing browser/background coordinator.
signal?.throwIfAborted();
const inFlight = this.pending.get(ownerId);
if (inFlight) return this.waitForToken(inFlight, signal);
const epoch = this.epochs.get(ownerId) ?? 0;
const promise = (async () => {
let result: Token | undefined;
const response = await requestWithSessionCredential(ownerId, async (credential) => {
if (credential.projectId !== project) throw error(403, "MCP credential project differs from its session");
const cached = this.tokens.get(ownerId);
if (cached?.source === credential.authorization && cached.expires > Date.now() + 30_000) {
result = cached; return new Response(null, { status: 204 });
}
const subject = credential.authorization.replace(/^Bearer /i, "");
const body = new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
subject_token: subject, subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
requested_token_type: "urn:ietf:params:oauth:token-type:access_token", audience: this.settings.audience,
scope: "mcp.catalog.read mcp.query mcp.context.bind" });
const exchanged = await this.grant(body);
if (exchanged.status === 400) {
const data = await exchanged.json().catch(() => ({})) as { error?: string };
if (data.error === "invalid_grant") return new Response(null, { status: 401 });
throw error(503, "MCP token exchange configuration rejected");
}
result = await this.parseToken(exchanged, credential.authorization);
assertExchangedIdentity(credential.authorization, result.token, this.settings);
return new Response(null, { status: 204 });
});
if (!response.ok || !result) throw error(401, "MCP credential renewal required");
if ((this.epochs.get(ownerId) ?? 0) !== epoch) throw error(403, "MCP session was released");
this.tokens.set(ownerId, result);
return result;
})();
this.pending.set(ownerId, promise);
void promise.finally(() => this.pending.delete(ownerId)).catch(() => {});
return this.waitForToken(promise, signal);
}
private waitForToken(promise: Promise<Token>, signal?: AbortSignal): Promise<Token> {
if (!signal) return promise;
return new Promise((resolve, reject) => {
const abort = () => reject(signal.reason ?? new Error("Query cancelled"));
signal.addEventListener("abort", abort, { once: true });
if (signal.aborted) abort();
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
});
}
private async withClient<T>(token: string, project: string | undefined, signal: AbortSignal | undefined, run: (client: Client) => Promise<T>, timeoutMs = 30_000) {
const client = new Client({ name: "tjwater-agent-host", version: "1.0.0" }, { versionNegotiation: { mode: { pin: "2026-07-28" } }, capabilities: { extensions: { "io.modelcontextprotocol/oauth-client-credentials": {} } } });
const transport = new StreamableHTTPClientTransport(new URL(this.settings.resource), {
requestInit: { headers: { Authorization: `Bearer ${token}`, ...(project ? { "X-Project-Id": project } : {}) }, redirect: "error" },
fetch: (input, init) => this.http(input, { ...init, redirect: "error", signal: AbortSignal.any([AbortSignal.timeout(timeoutMs), ...(signal ? [signal] : []), ...(init?.signal ? [init.signal] : [])]) }),
});
try { await client.connect(transport, { signal, timeout: 5000 }); return await run(client); }
finally { await client.close(); }
}
async prepare(): Promise<Tool[]> {
const token = await this.parseToken(await this.grant(new URLSearchParams({ grant_type: "client_credentials", scope: "mcp.catalog.read" })), "catalog");
const result = await this.withClient(token.token, undefined, undefined, (client) => client.listTools());
const actual = result.tools.map((tool) => tool.name).sort();
if (JSON.stringify(actual) !== JSON.stringify([...PRODUCT_MCP_TOOLS].sort())) throw error(503, "MCP product catalog does not match the host policy");
this.tools = result.tools;
return this.catalog();
}
catalog(): Tool[] {
return this.tools.map((tool) => {
const input = structuredClone(tool.inputSchema);
delete input.properties?.context_handle;
input.required = input.required?.filter((key) => key !== "context_handle");
return { ...tool, inputSchema: input };
});
}
async query(sessionId: string, owner: QueryOwner, name: string, args: Record<string, unknown>, callId: string, signal?: AbortSignal) {
const definition = this.tools.find((tool) => tool.name === name);
if (!definition) throw error(400, "Unknown product query");
const budget = queryTimeouts(definition, args);
const controller = new AbortController();
const group = this.active.get(owner.runtimeSessionId) ?? new Set<AbortController>();
group.add(controller); this.active.set(owner.runtimeSessionId, group);
const combined = AbortSignal.any([controller.signal, AbortSignal.timeout(budget.requestMs), ...(signal ? [signal] : [])]);
try {
for (let attempt = 0; attempt < 2; attempt++) {
try { return await this.queryOnce(sessionId, owner, name, args, callId, combined); }
catch (cause) {
const status = cause && typeof cause === "object" && "status" in cause ? cause.status : undefined;
const code = cause && typeof cause === "object" && "code" in cause ? cause.code : undefined;
if (attempt || !(status === 401 || code === "UNAUTHORIZED" || cause instanceof UnauthorizedError)) throw cause;
this.tokens.delete(owner.runtimeSessionId);
this.authDiscovery.invalidate();
}
}
throw error(401, "MCP authentication required");
} finally {
group.delete(controller);
if (!group.size) this.active.delete(owner.runtimeSessionId);
}
}
private async queryOnce(sessionId: string, owner: QueryOwner, name: string, args: Record<string, unknown>, callId: string, signal?: AbortSignal) {
if (!(PRODUCT_MCP_TOOLS as readonly string[]).includes(name) || !this.tools.length) throw error(400, "Unknown product query");
if (!owner.context.projectId || owner.context.projectKey.startsWith("personal-") || !owner.context.purpose || !["ordinary_chat", "task_execution", "task_collaboration"].includes(owner.context.purpose)) throw error(403, "Project query context required");
// No model-supplied identity, scope, bearer or Server URL can pass this boundary.
const schema = this.catalog().find((tool) => tool.name === name)!.inputSchema;
const budget = queryTimeouts(this.tools.find((tool) => tool.name === name)!, args);
const reserved = new Set(["context_handle", "authorization", "headers", "project_id", "projectId", "network", "url", "server_url", "user_id", "subject", "client_id", "purpose", "native_session_id", "parent_session_id", "task_id", "scope", "scopes"]);
if (Object.keys(args).some((key) => reserved.has(key) || !Object.hasOwn(schema.properties ?? {}, key))) throw error(400, "Unknown query parameter");
const token = await this.userToken(owner.runtimeSessionId, owner.context.projectId, signal);
signal?.throwIfAborted();
const project = owner.context.projectId;
const headers = { Authorization: `Bearer ${token.token}`, "X-Project-Id": project, "Content-Type": "application/json" };
const bindUrl = new URL("/api/v1/mcp-query-contexts", this.settings.resource);
const command: components["schemas"]["BindInput"] = { native_session_id: sessionId,
purpose: sessionId === owner.runtimeSessionId ? owner.context.purpose : "task_reader",
...(owner.taskId ? { task_id: owner.taskId } : {}), ...(sessionId !== owner.runtimeSessionId ? { parent_session_id: owner.runtimeSessionId } : {}) };
const binding = await this.http(bindUrl, { method: "POST", headers, redirect: "error",
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(5000)]) : AbortSignal.timeout(5000),
body: JSON.stringify(command) });
if (!binding.ok) { await binding.body?.cancel(); if (binding.status === 401) this.tokens.delete(owner.runtimeSessionId); throw error(binding.status, "MCP query context rejected"); }
const body = await binding.json() as { context_handle?: unknown };
if (typeof body.context_handle !== "string" || !/^[A-Za-z0-9_-]{32,128}$/.test(body.context_handle)) throw error(502, "MCP query context response invalid");
const handle = body.context_handle;
try {
return await this.withClient(token.token, project, signal, async (client) => {
await client.listTools(undefined, { signal, timeout: 5000 });
return client.callTool({ name, arguments: { ...args, context_handle: handle }, _meta: { "io.tjwater/callId": callId } }, { signal, timeout: budget.callMs });
}, budget.callMs);
} finally {
// Expiry remains authoritative if transport cancellation prevents revocation.
await this.http(new URL(`/api/v1/mcp-query-contexts/${handle}`, this.settings.resource), {
method: "DELETE", headers, redirect: "error", signal: AbortSignal.timeout(2000),
}).then((response) => response.body?.cancel()).catch(() => {});
}
}
}
export function createProductMcpClient() {
if (!config.TJWATER_MCP_ENABLED) return null;
const client = new McpQueryClient({ issuer: config.TJWATER_MCP_ISSUER_URI ?? "", resource: config.TJWATER_MCP_RESOURCE_URI ?? "",
audience: config.TJWATER_MCP_AUDIENCE, clientId: config.TJWATER_MCP_CLIENT_ID, clientSecret: config.TJWATER_MCP_CLIENT_SECRET ?? "",
runtimeEnvironment: config.NODE_ENV, devHttpIssuer: config.TJWATER_MCP_DEV_HTTP_ISSUER_URI });
onSessionCredentialReleased((id) => client.dropSession(id));
return client;
}
+23
View File
@@ -0,0 +1,23 @@
export type McpUrlPolicy = { runtimeEnvironment?: string; devHttpIssuer?: string };
const unavailable = () => Object.assign(new Error("MCP requires HTTPS, loopback, or an explicitly configured development issuer"), { status: 503 });
export function validateDevHttpIssuer(policy: McpUrlPolicy): URL | undefined {
if (!policy.devHttpIssuer) return undefined;
const url = new URL(policy.devHttpIssuer);
const parts = url.hostname.split(".").map(Number);
const privateIpv4 = parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) &&
(parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
if (policy.runtimeEnvironment !== "development" || url.protocol !== "http:" || !privateIpv4 ||
url.username || url.password || url.search || url.hash) throw unavailable();
return url;
}
export function secureMcpUrl(value: string | URL, policy: McpUrlPolicy = {}, allowIssuerHttp = false): URL {
const url = new URL(value);
const devIssuer = validateDevHttpIssuer(policy);
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
if (url.username || url.password || url.search || url.hash || !(url.protocol === "https:" ||
url.protocol === "http:" && (loopback || allowIssuerHttp && devIssuer?.origin === url.origin))) throw unavailable();
return url;
}
+30 -73
View File
@@ -1,5 +1,6 @@
import { bindSessionCredential, releaseSessionCredential, requestWithSessionCredential } from "./credential-refresh.js";
import { taskReportSchema } from "./investigation-report.js";
import { taskReportSchema, taskProgressSchema, type TaskProgress } from "./investigation-report.js";
import { taskPresentationCommandSchema, taskPresentationResultSchema, type TaskPresentationCommand } from "./task-presentation.js";
export { taskReportSchema } from "./investigation-report.js";
import { z } from "zod";
@@ -71,33 +72,11 @@ const agentTaskContextSchema = z.object({
}).passthrough().nullish(),
});
const receivedEvidenceSchema = z.object({
evidence_id: z.string().min(1),
kind: z.enum(["system_data", "external_feedback", "human_input", "synthetic"]),
summary: z.string().min(1),
source_label: z.string().min(1),
source_ref: z.string().min(1).nullable().optional(),
observed_at: z.string().datetime({ offset: true }),
}).passthrough();
const taskDataSnapshotSchema = z.object({
source: taskDataSourceSchema,
source_ref: z.string().min(1),
queried_at: z.string().datetime({ offset: true }),
origin: z.enum(["system_data", "synthetic"]),
data: z.unknown(),
truncated: z.boolean(),
});
const taskDataQueryResultSchema = z.object({
evidence: receivedEvidenceSchema,
snapshot: taskDataSnapshotSchema,
});
const taskArtifactsResultSchema = z.object({
task: operationalTaskSummarySchema,
plan_revision: z.number().int().positive().nullable(),
report_revision: z.number().int().positive().nullable(),
progress_revision: z.number().int().positive().nullable().optional(),
});
export const taskEvidenceSchema = z.object({
@@ -109,31 +88,6 @@ export const taskEvidenceSchema = z.object({
observed_at: z.string().datetime({ offset: true }),
});
export const operationalTaskDataQuerySchema = z.object({
source: taskDataSourceSchema,
device_ids: z.array(z.string().trim().min(1).max(160)).max(20).optional(),
point_id: z.string().trim().min(1).max(160).optional(),
start: z.string().datetime({ offset: true }).optional(),
end: z.string().datetime({ offset: true }).optional(),
}).superRefine((query, context) => {
const isSeries = query.source === "scada_readings" || query.source === "peripheral_observations";
if (isSeries && (!query.start || !query.end)) {
context.addIssue({ code: "custom", message: "time series queries require start and end" });
}
if (query.source === "scada_readings" && !query.device_ids?.length) {
context.addIssue({ code: "custom", message: "SCADA readings require device_ids" });
}
if (query.source === "peripheral_observations" && !query.point_id) {
context.addIssue({ code: "custom", message: "peripheral observations require point_id" });
}
if (query.start && query.end) {
const duration = Date.parse(query.end) - Date.parse(query.start);
if (duration <= 0 || duration > 86_400_000) {
context.addIssue({ code: "custom", message: "query window must be positive and at most 24 hours" });
}
}
});
export const humanTaskEvidenceCommandSchema = z.object({
request_id: z.string().uuid(),
evidence: z.object({
@@ -148,7 +102,6 @@ export const humanTaskEvidenceCommandSchema = z.object({
export type TaskEvidence = z.infer<typeof taskEvidenceSchema>;
export type TaskReport = z.infer<typeof taskReportSchema>;
export type OperationalTaskDataQuery = z.infer<typeof operationalTaskDataQuerySchema>;
export const expressAccessRequestSchema = z.object({
requestId: z.string().uuid(),
nodeId: z.string().min(1),
@@ -165,6 +118,7 @@ type TaskArtifactInput = {
evidence?: TaskEvidence[];
executionPlan?: Record<string, unknown>;
report?: TaskReport;
progress?: TaskProgress;
};
export const taskArtifactBridgeSchema = z.object({
@@ -172,13 +126,15 @@ export const taskArtifactBridgeSchema = z.object({
evidence: z.array(taskEvidenceSchema).max(40).optional(),
execution_plan: jsonRecordSchema.optional(),
report: taskReportSchema.optional(),
progress: taskProgressSchema.optional(),
}).refine(
(value) => value.evidence !== undefined || value.execution_plan !== undefined || value.report !== undefined,
(value) => value.evidence !== undefined || value.execution_plan !== undefined || value.report !== undefined || value.progress !== undefined,
{ message: "at least one task artifact is required" },
);
const taskRunStates = new Map<string, { runId: string; revision: number }>();
const taskRunMutationQueues = new Map<string, Promise<void>>();
export const hasOperationalTaskRun = (runtimeSessionId: string) => taskRunStates.has(runtimeSessionId);
export const bindOperationalTaskCredential = (
runtimeSessionId: string,
@@ -267,6 +223,7 @@ export class OperationalTaskClient {
...(input.evidence ? { evidence: input.evidence } : {}),
...(input.executionPlan ? { plan: toServerPlan(input.executionPlan) } : {}),
...(input.report ? { report: input.report } : {}),
...(input.progress ? { progress: taskProgressSchema.parse(input.progress) } : {}),
}),
});
const result = taskArtifactsResultSchema.parse(raw);
@@ -279,6 +236,25 @@ export class OperationalTaskClient {
return this.finishTaskRun(runtimeSessionId, taskId, runId, "complete", sourceSessionId);
}
async publishPresentation(runtimeSessionId: string, taskId: string, input: {
requestId: string; sourceSessionId: string; command: TaskPresentationCommand;
}) {
return this.mutateRun(runtimeSessionId, async () => {
const state = taskRunStates.get(runtimeSessionId);
if (!state) throw new OperationalTaskClientError(409, "task run context unavailable");
const raw = await this.request(runtimeSessionId, `/api/v1/operational-tasks/${encodeURIComponent(taskId)}/presentation-events`, {
method: "POST",
body: JSON.stringify({
request_id: input.requestId,
run_id: state.runId,
source_session_id: input.sourceSessionId,
command: taskPresentationCommandSchema.parse(input.command),
}),
});
return taskPresentationResultSchema.parse(raw);
});
}
async failTaskRun(
runtimeSessionId: string,
taskId: string,
@@ -337,29 +313,8 @@ export class OperationalTaskClient {
}
}
async queryData(runtimeSessionId: string, taskId: string, query: OperationalTaskDataQuery) {
const raw = await this.request(runtimeSessionId, `/api/v1/operational-tasks/${encodeURIComponent(taskId)}/data-query`, {
method: "POST",
body: JSON.stringify(operationalTaskDataQuerySchema.parse(query)),
});
const combined = taskDataQueryResultSchema.safeParse(raw);
if (combined.success) {
return combined.data;
}
const evidence = receivedEvidenceSchema.parse(raw);
if (typeof evidence.evidence_id !== "string" || !evidence.evidence_id.trim()) {
throw new OperationalTaskClientError(502, "task data query returned no evidence id");
}
const snapshot = taskDataSnapshotSchema.parse(await this.request(
runtimeSessionId,
`/api/v1/operational-tasks/${encodeURIComponent(taskId)}/evidence/${encodeURIComponent(evidence.evidence_id)}/snapshot`,
{ method: "GET" },
));
return { evidence, snapshot };
}
async requestDataAccess(runtimeSessionId: string, taskId: string, input: {
requestId: string; sourceSessionId: string; nodeId: string; scopes: OperationalTaskDataQuery["source"][]; reason: string;
requestId: string; sourceSessionId: string; nodeId: string; scopes: z.infer<typeof taskDataSourceSchema>[]; reason: string;
}) {
return this.request(runtimeSessionId, `/api/v1/operational-tasks/${encodeURIComponent(taskId)}/access-requests`, {
method: "POST",
@@ -479,6 +434,8 @@ const rememberLeaseRevision = (runtimeSessionId: string, value: Record<string, u
runId: parsed.data.run_id,
revision: parsed.data.revision,
});
} else if (Object.hasOwn(task, "active_run") && activeRun === null) {
taskRunStates.delete(runtimeSessionId);
}
};
-56
View File
@@ -1,56 +0,0 @@
import { z } from "zod";
import { config } from "../config.js";
import { requestWithSessionCredential } from "./credential-refresh.js";
import type { components } from "../generated/server-api.js";
const id = z.string().trim().min(1).max(128);
const windowFields = { start: z.string().datetime({ offset: true }), end: z.string().datetime({ offset: true }) };
export const projectDataQuerySchema = z.discriminatedUnion("source", [
z.object({ source: z.literal("element_properties"), element_type: z.enum(["junction", "pipe", "valve", "pump", "tank", "reservoir"]), element_id: id }).strict(),
z.object({ source: z.literal("scada_devices") }).strict(),
z.object({ source: z.literal("scada_history"), device_ids: z.array(id).min(1).max(20), field: z.enum(["monitored_value", "cleaned_value"]), ...windowFields }).strict(),
z.object({ source: z.literal("element_history"), element_type: z.enum(["junction", "pipe"]), element_id: id, ...windowFields }).strict(),
z.object({ source: z.literal("analysis_runs"), limit: z.number().int().min(1).max(100).default(20), offset: z.number().int().nonnegative().default(0) }).strict(),
z.object({ source: z.literal("analysis_run"), run_id: z.string().uuid() }).strict(),
z.object({ source: z.literal("analysis_results"), run_id: z.string().uuid(), limit: z.number().int().min(1).max(100).default(20), offset: z.number().int().nonnegative().default(0) }).strict(),
]).superRefine((query, context) => {
if (!("start" in query)) return;
const duration = Date.parse(query.end) - Date.parse(query.start);
if (duration <= 0 || duration > 24 * 60 * 60 * 1000) context.addIssue({ code: z.ZodIssueCode.custom, message: "time window must be positive and at most 24 hours" });
});
export type ProjectDataQuery = z.infer<typeof projectDataQuerySchema>;
export class ProjectDataClient {
constructor(private readonly baseUrl = config.TJWATER_SERVER_BASE_URL) {}
async query(sessionId: string, input: ProjectDataQuery) {
const query = projectDataQuerySchema.parse(input);
let path: string;
let init: RequestInit = { method: "GET" };
switch (query.source) {
case "element_properties":
path = `/api/v1/${query.element_type}s/properties?${new URLSearchParams({ [query.element_type]: query.element_id })}`;
break;
case "scada_devices": path = "/api/v1/scada-devices"; break;
case "scada_history":
path = `/api/v1/timeseries/scada-readings/fields?${new URLSearchParams({ device_ids: query.device_ids.join(","), field: query.field, start_time: query.start, end_time: query.end })}`;
break;
case "element_history": {
const body = { start_time: query.start, end_time: query.end, mode: "observed", elements: [{ element_type: query.element_type, element_id: query.element_id }] } satisfies components["schemas"]["ElementHistoryQuery"];
path = "/api/v1/timeseries/views/element-history/query";
init = { method: "POST", body: JSON.stringify(body) };
break;
}
case "analysis_runs": path = `/api/v1/analysis/runs?limit=${query.limit}&offset=${query.offset}`; break;
case "analysis_run": path = `/api/v1/analysis/runs/${query.run_id}`; break;
case "analysis_results": path = `/api/v1/analysis/runs/${query.run_id}/results?limit=${query.limit}&offset=${query.offset}`; break;
}
const response = await requestWithSessionCredential(sessionId, (credential) => fetch(new URL(path, this.baseUrl), {
...init,
signal: AbortSignal.timeout(config.TJWATER_SERVER_TIMEOUT_MS),
headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: credential.authorization, "X-Project-Id": credential.projectId },
}));
if (!response.ok) throw Object.assign(new Error(`project data query rejected (${response.status})`), { status: response.status });
return response.json() as Promise<unknown>;
}
}
+15
View File
@@ -12,6 +12,21 @@ export type AgentAuthContext = components["schemas"]["AgentAuthContextResponse"]
export class ProductServerClient {
constructor(private readonly baseUrl = config.TJWATER_SERVER_BASE_URL) {}
async authenticateUser(authorization: string): Promise<{ user_id: string }> {
let response: Response;
try {
response = await fetch(new URL("/api/v1/access-context", this.baseUrl), {
headers: { Accept: "application/json", Authorization: authorization },
});
} catch { throw new ProductServerClientError(503, "TJWater Server unavailable"); }
const parsed = parseJson(await response.text());
if (!response.ok) throw new ProductServerClientError(response.status, "TJWater Server rejected identity verification");
if (!parsed || typeof parsed !== "object" || !("user_id" in parsed) || typeof parsed.user_id !== "string" || !parsed.user_id.trim()) {
throw new ProductServerClientError(502, "TJWater Server returned invalid identity");
}
return { user_id: parsed.user_id };
}
async authenticate(credential: ProductCredential): Promise<AgentAuthContext> {
const url = new URL("/api/v1/agent-auth-context", this.baseUrl);
let response: Response;
+46
View File
@@ -0,0 +1,46 @@
import { z } from "zod";
import { safeChartInput } from "../frontendAction/registry.js";
export const supplyLayerSchema = z.enum(["junctions", "pipes", "valves", "pumps", "tanks", "reservoirs", "scada"]);
const identifier = z.string().trim().min(1).max(128);
export const taskPresentationCommandSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("highlight_features"),
layer: supplyLayerSchema,
feature_ids: z.array(identifier).min(1).max(100),
effect: z.enum(["steady", "pulse"]).default("steady"),
}).strict(),
z.object({ action: z.literal("clear_highlights") }).strict(),
z.object({
action: z.literal("open_chart"),
chart_id: identifier,
chart: safeChartInput,
evidence_refs: z.array(z.string().trim().min(1)).min(1).max(100),
}).strict(),
z.object({ action: z.literal("close_chart"), chart_id: identifier }).strict(),
z.object({
action: z.literal("set_overlay"),
overlay_id: identifier,
title: z.string().trim().min(1).max(200),
mode: z.enum(["geometry", "heatmap"]),
evidence_id: z.string().trim().min(1),
}).strict(),
z.object({ action: z.literal("clear_overlay"), overlay_id: identifier }).strict(),
]);
export type TaskPresentationCommand = z.infer<typeof taskPresentationCommandSchema>;
export const taskPresentationResultSchema = z.object({
recorded: z.literal(true),
event_seq: z.number().int().positive(),
run_id: z.string().uuid(),
execution_generation: z.number().int().positive(),
}).strict();
export const taskPresentationBridgeSchema = z.object({
session_id: z.string().trim().min(1).max(128),
request_id: z.string().uuid(),
command: taskPresentationCommandSchema,
}).strict();