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,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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user