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:
@@ -1,7 +1,18 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { CredentialRefreshCoordinator, runWithCredentialRefresh, bindSessionCredential, credentialRefreshCoordinator, refreshSessionCredential, releaseSessionCredential, requestWithSessionCredential } from "../../src/product/credential-refresh.js";
|
||||
import { removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||
|
||||
describe("credential renewal", () => {
|
||||
it("requires a project before personal chats can call business APIs", async () => {
|
||||
const sessionId = "personal-business-access";
|
||||
let calls = 0;
|
||||
setRuntimeSessionContext({ actorKey: "actor-personal", projectKey: "personal-actor-personal", sessionId,
|
||||
clientSessionId: sessionId, traceId: "test" });
|
||||
try {
|
||||
await expect(requestWithSessionCredential(sessionId, async () => { calls++; return Response.json({}); })).rejects.toMatchObject({ status: 403, code: "PROJECT_CONTEXT_REQUIRED" });
|
||||
expect(calls).toBe(0);
|
||||
} finally { removeRuntimeSessionContext(sessionId); }
|
||||
});
|
||||
it("rejects stale replies and stops repeated requests when the renewed credential is still rejected", async () => {
|
||||
const session = "rejected-credential-session";
|
||||
bindSessionCredential(session, { authorization: "Bearer OLD", projectId: "p" });
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { ExternalInformationClient, geocodeInput, webSearchInput } from "../../src/product/external-information-client.js";
|
||||
import { ExternalInformationClient, geocodeInput } from "../../src/product/external-information-client.js";
|
||||
import { bindSessionCredential, credentialRefreshCoordinator, refreshSessionCredential, releaseSessionCredential } from "../../src/product/credential-refresh.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const session = "external-information-test";
|
||||
afterEach(() => { globalThis.fetch = originalFetch; releaseSessionCredential(session); });
|
||||
describe("typed external information", () => {
|
||||
it("uses the fixed Server endpoint and trusted project, translating freshness", async () => {
|
||||
bindSessionCredential(session, { authorization: "Bearer TEST", projectId: "trusted" });
|
||||
globalThis.fetch = (async (url: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
expect(String(url)).toBe("http://server.test/api/v1/web-searches");
|
||||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer TEST");
|
||||
expect(new Headers(init?.headers).get("X-Project-Id")).toBe("trusted");
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ query: "供水政策", freshness: "oneWeek", summary: true, count: 10, include: ["gov.cn"] });
|
||||
return Response.json({ data: { webPages: { value: [{ url: "https://gov.cn/example", name: "example" }] } } });
|
||||
}) as unknown as typeof fetch;
|
||||
const result = await new ExternalInformationClient("http://server.test").webSearch(session, webSearchInput.parse({ query: "供水政策", freshness: "one_week", include: ["gov.cn"] }));
|
||||
expect(result).toHaveProperty("data.webPages.value");
|
||||
});
|
||||
|
||||
it("normalizes valid Tianditu coordinates for zoom_to_map and rejects fabricated coordinates", async () => {
|
||||
bindSessionCredential(session, { authorization: "Bearer TEST", projectId: "trusted" });
|
||||
globalThis.fetch = (async (url: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
expect(new URL(String(url)).pathname).toBe("/api/v1/geocoding-requests");
|
||||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer TEST");
|
||||
expect(new Headers(init?.headers).get("X-Project-Id")).toBe("trusted");
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ keyword: "北京市人民政府" });
|
||||
return Response.json({ status: "0", location: { lon: "116.4", lat: "39.9", level: 1 } });
|
||||
}) as unknown as typeof fetch;
|
||||
@@ -52,11 +41,9 @@ describe("typed external information", () => {
|
||||
} finally { unsubscribe(); }
|
||||
});
|
||||
|
||||
it("rejects scope, URL overrides, empty queries and invalid counts before dispatch", async () => {
|
||||
expect(webSearchInput.safeParse({ query: "test", url: "http://untrusted" }).success).toBeFalse();
|
||||
expect(webSearchInput.safeParse({ query: "test", project_id: "other" }).success).toBeFalse();
|
||||
expect(webSearchInput.safeParse({ query: "test", count: 51 }).success).toBeFalse();
|
||||
expect(webSearchInput.safeParse({ query: "test", include: ["https://example.com/path"] }).success).toBeFalse();
|
||||
it("rejects scope, URL overrides and empty keywords before dispatch", async () => {
|
||||
expect(geocodeInput.safeParse({ keyword: "北京", url: "http://untrusted" }).success).toBeFalse();
|
||||
expect(geocodeInput.safeParse({ keyword: "北京", project_id: "other" }).success).toBeFalse();
|
||||
expect(geocodeInput.safeParse({ keyword: " " }).success).toBeFalse();
|
||||
await expect(new ExternalInformationClient().geocode(session, { keyword: "北京" })).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { historyChart, queryHistoryChart } from "../../src/product/history-chart.js";
|
||||
describe("history chart", () => {
|
||||
it("keeps returned units, drops null samples and never manufactures missing history", () => {
|
||||
expect(() => historyChart({ series: [] }, "J1")).toThrow("NO_HISTORY_DATA");
|
||||
const chart = historyChart({ series: [{ element_id: "J1", metric: "pressure", source: "scada_raw", source_unit: "m", points: [{ time: "t1", value: null }, { time: "t2", value: 20 }] }] }, "J1");
|
||||
expect(chart.title).toContain("(m)");
|
||||
expect(chart.x_data).toEqual(["t2"]);
|
||||
expect(chart.series[0].data).toEqual([20]);
|
||||
});
|
||||
});
|
||||
|
||||
const series = { element_id: "J1", metric: "pressure", source: "raw", source_unit: "m", device_id: "D1" };
|
||||
it("continues past a null first page to use real later samples", async () => {
|
||||
const pages: number[][] = [];
|
||||
const chart = await queryHistoryChart("J1", async (limit, offset) => {
|
||||
pages.push([limit, offset]);
|
||||
return { content: [], structuredContent: { page: { limit, offset, has_more: offset === 0 }, truncated: offset === 0,
|
||||
data: { series: [{ ...series, points: offset === 0 ? Array.from({ length: limit }, (_, i) => ({ time: String(i), value: null })) : [{ time: "500", value: 25 }] }] } } };
|
||||
});
|
||||
expect(pages).toEqual([[500, 0], [500, 500]]);
|
||||
expect(chart.series[0].data).toEqual([25]);
|
||||
});
|
||||
|
||||
it("stops within a fixed budget and distinguishes unread history from absent history", async () => {
|
||||
let calls = 0;
|
||||
await expect(queryHistoryChart("J1", async (limit, offset) => {
|
||||
calls++;
|
||||
return { content: [], structuredContent: { page: { limit, offset, has_more: true }, data: { series: [{ ...series, points: [{ time: String(offset), value: null }] }] } } };
|
||||
})).rejects.toThrow("仍有未读取数据");
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it("shrinks oversized pages without skipping data or retrying unrelated failures", async () => {
|
||||
const calls: number[][] = [];
|
||||
const chart = await queryHistoryChart("J1", async (limit, offset) => {
|
||||
calls.push([limit, offset]);
|
||||
if (limit > 125) return { isError: true, content: [], structuredContent: { error: { code: "RESULT_TOO_LARGE" } } };
|
||||
return { content: [], structuredContent: { page: { limit, offset, has_more: offset === 0 }, data: {
|
||||
series: [{ ...series, points: [{ time: String(offset), value: offset === 0 ? null : 25 }] }],
|
||||
} } };
|
||||
});
|
||||
expect(calls).toEqual([[500, 0], [250, 0], [125, 0], [125, 125]]);
|
||||
expect(chart.series[0].data).toEqual([25]);
|
||||
let failures = 0;
|
||||
await expect(queryHistoryChart("J1", async () => {
|
||||
failures++;
|
||||
return { isError: true, content: [], structuredContent: { error: { code: "ACCESS_DENIED" } } };
|
||||
})).rejects.toThrow("历史查询暂不可用");
|
||||
expect(failures).toBe(1);
|
||||
});
|
||||
|
||||
it("bounds displayed values and identifies a partial query", async () => {
|
||||
let calls = 0;
|
||||
const chart = await queryHistoryChart("J1", async (limit, offset) => {
|
||||
calls++;
|
||||
return { content: [], structuredContent: { page: { limit, offset, has_more: true },
|
||||
data: { series: [{ ...series, points: Array.from({ length: 100 }, (_, i) => ({ time: String(i), value: i })) }] } } };
|
||||
});
|
||||
expect(calls).toBe(1);
|
||||
expect(chart.series[0].data).toHaveLength(100);
|
||||
expect(chart.title).toContain("未覆盖完整时间窗");
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { taskReportSchema } from "../../src/product/investigation-report.js";
|
||||
import { taskReportSchema, taskProgressSchema } from "../../src/product/investigation-report.js";
|
||||
|
||||
const report = { title: "调查报告", current_conclusion: "无法确定原因", conclusion_boundary: "仅有上游数据",
|
||||
evidence_refs: ["query-1"], recommendations: [], pending_items: [], stage: "final", outcome: "insufficient_data",
|
||||
@@ -34,4 +34,35 @@ describe("new investigation report writes", () => {
|
||||
expect(() => taskReportSchema.parse({ ...report, ...changes })).toThrow();
|
||||
}
|
||||
});
|
||||
it("shares explicit node activity and supply feature targets with progress", () => {
|
||||
const diagram = { title: "当前调查", mermaid: "flowchart LR\nQUERY --> HUMAN",
|
||||
node_refs: [{ node_id: "QUERY", label: "核对监测", status: "running", evidence_refs: ["query-1"],
|
||||
map_targets: [{ source_id: "scada", feature_id: "MP01" }] },
|
||||
{ node_id: "HUMAN", label: "现场核验", status: "waiting_human" }] };
|
||||
const progress = taskProgressSchema.parse({ summary: "正在核对投诉时段的实际监测记录", diagram });
|
||||
expect(progress.diagram?.node_refs[0]).toMatchObject({ status: "running", map_targets: [{ source_id: "scada", feature_id: "MP01" }] });
|
||||
expect(taskReportSchema.parse({ ...report, diagram }).diagram).toEqual(progress.diagram);
|
||||
expect(taskProgressSchema.parse({ summary: "调整调查方向", diagram: null }).diagram).toBeNull();
|
||||
for (const change of [{ status: "waiting" }, { map_targets: [{ source_id: "drainage", feature_id: "X" }] },
|
||||
{ map_targets: [{ source_id: "scada", feature_id: "" }] }]) {
|
||||
expect(() => taskProgressSchema.parse({ summary: "核对", diagram: {
|
||||
...diagram, node_refs: [{ ...diagram.node_refs[0], ...change }],
|
||||
} })).toThrow();
|
||||
}
|
||||
});
|
||||
it("preserves parallel branches with independent activity and a pending join", () => {
|
||||
const mermaid = 'flowchart TD\nSTART[受理投诉] --> SPLIT{并行核查}\nSPLIT --> TICKETS[工单核查]\nSPLIT --> SCADA[监测核查]\nTICKETS --> JOIN[汇总证据]\nSCADA --> JOIN';
|
||||
const diagram = { title: "并行调查", mermaid, node_refs: [
|
||||
{ node_id: "TICKETS", label: "工单核查", status: "running" },
|
||||
{ node_id: "SCADA", label: "监测核查", status: "running" },
|
||||
{ node_id: "JOIN", label: "汇总证据", status: "pending" },
|
||||
] };
|
||||
const progress = taskProgressSchema.parse({ summary: "两个独立核查正在执行", diagram });
|
||||
expect(progress.diagram?.mermaid).toBe(mermaid);
|
||||
expect(progress.diagram?.node_refs.map((node) => node.status)).toEqual(["running", "running", "pending"]);
|
||||
const updated = taskProgressSchema.parse({ summary: "工单核查已完成,监测核查仍在执行", diagram: {
|
||||
...diagram, node_refs: [{ ...diagram.node_refs[0], status: "completed", evidence_refs: ["query-1"] }, ...diagram.node_refs.slice(1)],
|
||||
} });
|
||||
expect(updated.diagram?.node_refs.map((node) => node.status)).toEqual(["completed", "running", "pending"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { expect, it } from "bun:test";
|
||||
import { McpAuthDiscovery, type McpHttpFetch } from "../../src/product/mcp-auth-discovery.js";
|
||||
|
||||
const settings = { resource: "https://water.example/mcp", issuer: "https://identity.example/realm/water" };
|
||||
const metadata = { issuer: settings.issuer, authorization_endpoint: "https://identity.example/authorize", response_types_supported: ["code"], token_endpoint: "https://identity.example/custom/token", token_endpoint_auth_methods_supported: ["client_secret_post"] };
|
||||
|
||||
it("uses the challenge resource metadata and discovers a non-Keycloak endpoint", async () => {
|
||||
const requests: string[] = [];
|
||||
const discovery = new McpAuthDiscovery(settings, (async (url, init) => {
|
||||
requests.push(String(url));
|
||||
expect(new Headers(init?.headers).has("Authorization")).toBeFalse();
|
||||
expect(init?.body ? String(init.body).includes("secret") : false).toBeFalse();
|
||||
if (String(url) === settings.resource) return new Response(null, { status: 401, headers: { "WWW-Authenticate": 'Basic realm="gateway", Bearer resource_metadata="https://water.example/auth/metadata", scope="mcp.catalog.read"' } });
|
||||
if (String(url).endsWith("/auth/metadata")) return Response.json({ resource: settings.resource, authorization_servers: [settings.issuer] });
|
||||
if (String(url).includes("oauth-authorization-server")) return Response.json(metadata);
|
||||
throw new Error("unexpected endpoint");
|
||||
}) as McpHttpFetch);
|
||||
expect(await discovery.resolve()).toEqual({ tokenEndpoint: metadata.token_endpoint, authMethod: "client_secret_post" });
|
||||
await discovery.resolve();
|
||||
expect(requests).toEqual([settings.resource, "https://water.example/auth/metadata", "https://identity.example/.well-known/oauth-authorization-server/realm/water"]);
|
||||
});
|
||||
|
||||
it("falls back through resource path, resource root and OAuth/OIDC issuer metadata", async () => {
|
||||
const requests: string[] = [];
|
||||
const discovery = new McpAuthDiscovery(settings, (async (url) => {
|
||||
const path = String(url); requests.push(path);
|
||||
if (path === settings.resource) return new Response(null, { status: 401 });
|
||||
if (path === "https://water.example/.well-known/oauth-protected-resource") return Response.json({ resource: settings.resource, authorization_servers: [settings.issuer] });
|
||||
if (path.endsWith("/realm/water/.well-known/openid-configuration")) return Response.json({ ...metadata, authorization_endpoint: "https://identity.example/authorize", jwks_uri: "https://identity.example/keys", response_types_supported: ["code"], subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["RS256"] });
|
||||
return new Response(null, { status: 404 });
|
||||
}) as McpHttpFetch);
|
||||
expect((await discovery.resolve()).tokenEndpoint).toBe(metadata.token_endpoint);
|
||||
expect(requests).toEqual([
|
||||
settings.resource, "https://water.example/.well-known/oauth-protected-resource/mcp", "https://water.example/.well-known/oauth-protected-resource",
|
||||
"https://identity.example/.well-known/oauth-authorization-server/realm/water", "https://identity.example/.well-known/openid-configuration/realm/water", "https://identity.example/realm/water/.well-known/openid-configuration",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["resource", "authority", "issuer", "endpoint", "method"])("rejects invalid %s metadata before credentials can be sent", async (kind) => {
|
||||
const discovery = new McpAuthDiscovery(settings, (async (url) => {
|
||||
if (String(url) === settings.resource) return new Response(null, { status: 401 });
|
||||
if (String(url).includes("oauth-protected-resource")) return Response.json({ resource: kind === "resource" ? "https://other.example/mcp" : settings.resource, authorization_servers: [kind === "authority" ? "https://attacker.example" : settings.issuer] });
|
||||
expect(String(url).startsWith("https://identity.example/")).toBeTrue();
|
||||
return Response.json({ ...metadata, ...(kind === "issuer" ? { issuer: `${settings.issuer}/` } : {}), ...(kind === "endpoint" ? { token_endpoint: "http://attacker.example/token" } : {}), ...(kind === "method" ? { token_endpoint_auth_methods_supported: ["none"] } : {}) });
|
||||
}) as McpHttpFetch);
|
||||
await expect(discovery.resolve()).rejects.toMatchObject({ status: 503 });
|
||||
});
|
||||
|
||||
it("rechecks the configured issuer after metadata invalidation", async () => {
|
||||
let changed = false;
|
||||
const discovery = new McpAuthDiscovery(settings, (async (url) => {
|
||||
if (String(url) === settings.resource) return new Response(null, { status: 401 });
|
||||
if (String(url).includes("oauth-protected-resource")) return Response.json({ resource: settings.resource, authorization_servers: [changed ? "https://new.example" : settings.issuer] });
|
||||
return Response.json(metadata);
|
||||
}) as McpHttpFetch);
|
||||
await discovery.resolve();
|
||||
changed = true; discovery.invalidate();
|
||||
await expect(discovery.resolve()).rejects.toMatchObject({ status: 503 });
|
||||
});
|
||||
|
||||
it.each(["allowed", "other-host", "other-port", "production"])("bounds development HTTP discovery: %s", async (kind) => {
|
||||
const issuer = "http://192.168.1.114:8088/realms/tjwater";
|
||||
let requests = 0;
|
||||
const discovery = new McpAuthDiscovery({ ...settings, issuer, devHttpIssuer: issuer,
|
||||
runtimeEnvironment: kind === "production" ? "production" : "development" }, (async (url, init) => {
|
||||
requests++;
|
||||
expect(new Headers(init?.headers).has("Authorization")).toBeFalse();
|
||||
expect(init?.redirect).toBe("error");
|
||||
if (String(url) === settings.resource) return new Response(null, { status: 401 });
|
||||
if (String(url).includes("oauth-protected-resource")) return Response.json({ resource: settings.resource, authorization_servers: [issuer] });
|
||||
expect(String(url).startsWith("http://192.168.1.114:8088/")).toBeTrue();
|
||||
return Response.json({ ...metadata, issuer, token_endpoint: kind === "other-host" ? "http://192.168.1.115:8088/token" :
|
||||
kind === "other-port" ? "http://192.168.1.114:8089/token" : `${issuer}/protocol/openid-connect/token` });
|
||||
}) as McpHttpFetch);
|
||||
if (kind === "allowed") expect((await discovery.resolve()).tokenEndpoint).toBe(`${issuer}/protocol/openid-connect/token`);
|
||||
else await expect(discovery.resolve()).rejects.toMatchObject({ status: 503 });
|
||||
if (kind === "production") expect(requests).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { McpQueryClient, validateMcpSettings, type McpHttpFetch, assertExchangedIdentity } from "../../src/product/mcp-query-client.js";
|
||||
import { bindSessionCredential, credentialRefreshCoordinator, refreshSessionCredential, releaseSessionCredential } from "../../src/product/credential-refresh.js";
|
||||
import { PRODUCT_MCP_TOOLS } from "../../src/runtime/product-mcp-tools.js";
|
||||
|
||||
const sessions: string[] = [];
|
||||
afterEach(() => { for (const id of sessions.splice(0)) releaseSessionCredential(id); });
|
||||
const settings = { issuer: "http://localhost/realms/test", resource: "http://localhost/mcp", audience: "tjwater-mcp", clientId: "tjwater-agent-mcp", clientSecret: "fixture-secret" };
|
||||
const owner = (id: string) => ({ context: { sessionId: id, actorKey: "actor", clientSessionId: id, projectId: "project", projectKey: "project", traceId: "test", purpose: "ordinary_chat" as const }, runtimeSessionId: id });
|
||||
const catalog = PRODUCT_MCP_TOOLS.map((name) => ({ name, inputSchema: { type: "object" as const, additionalProperties: false, properties: { context_handle: { type: "string" }, element_type: { type: "string" }, ...(name === "run_simulation" ? { timeout_seconds: { type: "integer", default: 120, maximum: 3600 } } : {}) }, required: ["context_handle", "element_type"] } }));
|
||||
// Test through a transport-independent fake only where the concern is token ownership/renewal.
|
||||
function fixture(http: McpHttpFetch, authMethod = "client_secret_post") {
|
||||
const client = new McpQueryClient(settings, (async (url, init) => {
|
||||
if (String(url) === settings.resource && !new Headers(init?.headers).has("Authorization")) return new Response(null, {
|
||||
status: 401, headers: { "WWW-Authenticate": 'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/mcp"' },
|
||||
});
|
||||
if (String(url).includes("oauth-protected-resource")) return Response.json({ resource: settings.resource, authorization_servers: [settings.issuer] });
|
||||
if (String(url).includes(".well-known")) return Response.json({ issuer: settings.issuer, authorization_endpoint: `${settings.issuer}/authorize`, response_types_supported: ["code"], token_endpoint: `${settings.issuer}/protocol/openid-connect/token`, token_endpoint_auth_methods_supported: [authMethod] });
|
||||
return http(url, init);
|
||||
}) as McpHttpFetch);
|
||||
Object.assign(client, { tools: catalog, withClient: async (_token: string, _project: string, _signal: AbortSignal, run: (client: unknown) => Promise<unknown>) => run({ listTools: async () => ({ tools: catalog }), callTool: async (params: unknown) => ({ content: [], structuredContent: params }) }) });
|
||||
return client;
|
||||
}
|
||||
const jwt = (id: string, overrides: Record<string, unknown> = {}) => `e30.${Buffer.from(JSON.stringify({ sub: "test-subject", iss: settings.issuer, aud: settings.audience, azp: settings.clientId, jti: id, ...overrides })).toString("base64url")}.fixture`;
|
||||
const resourceToken = jwt("resource");
|
||||
const token = () => Response.json({ access_token: resourceToken, token_type: "Bearer", expires_in: 300 });
|
||||
function credential(id: string) { sessions.push(id); bindSessionCredential(id, { authorization: `Bearer ${jwt(id)}`, projectId: "project" }); }
|
||||
|
||||
it("forwards the selected simulation budget through HTTP and SDK while rejecting excess before OAuth", async () => {
|
||||
const id = "mcp-calculation-budget"; credential(id);
|
||||
let requests = 0, transportTimeout = 0, sdkTimeout = 0;
|
||||
const client = fixture((async (url, init) => {
|
||||
requests++;
|
||||
if (String(url).includes("/token")) return token();
|
||||
if (init?.method === "POST") return Response.json({ context_handle: "x".repeat(43) });
|
||||
return new Response(null, { status: 204 });
|
||||
}) as McpHttpFetch);
|
||||
Object.assign(client, { withClient: async (_token: string, _project: string, _signal: AbortSignal, run: (client: unknown) => Promise<unknown>, timeout: number) => {
|
||||
transportTimeout = timeout;
|
||||
return run({ listTools: async () => ({ tools: catalog }), callTool: async (params: unknown, options: { timeout: number }) => {
|
||||
sdkTimeout = options.timeout;
|
||||
return { content: [], structuredContent: params };
|
||||
} });
|
||||
} });
|
||||
await expect(client.query(id, owner(id), "run_simulation", { timeout_seconds: 3601 }, "excess")).rejects.toMatchObject({ status: 400 });
|
||||
expect(requests).toBe(0);
|
||||
const result = await client.query(id, owner(id), "run_simulation", { timeout_seconds: 900 }, "long");
|
||||
expect(transportTimeout).toBe(905_000);
|
||||
expect(sdkTimeout).toBe(905_000);
|
||||
expect(result.structuredContent).toMatchObject({ arguments: { timeout_seconds: 900, context_handle: "x".repeat(43) } });
|
||||
});
|
||||
|
||||
it("requires HTTPS except loopback, and fixed resource paths", () => {
|
||||
expect(() => validateMcpSettings({ ...settings, issuer: "http://remote/realm" })).toThrow();
|
||||
expect(() => validateMcpSettings({ ...settings, resource: "http://localhost/api" })).toThrow();
|
||||
expect(() => validateMcpSettings({ ...settings, resource: "https://user:password@example/mcp" })).toThrow();
|
||||
});
|
||||
|
||||
it("allows only an explicit private development issuer while keeping MCP resources protected", () => {
|
||||
const issuer = "http://192.168.1.114:8088/realms/tjwater";
|
||||
const local = { ...settings, issuer, devHttpIssuer: issuer, runtimeEnvironment: "development" };
|
||||
expect(() => validateMcpSettings(local)).not.toThrow();
|
||||
expect(() => validateMcpSettings({ ...local, runtimeEnvironment: "production" })).toThrow();
|
||||
expect(() => validateMcpSettings({ ...local, runtimeEnvironment: undefined })).toThrow();
|
||||
expect(() => validateMcpSettings({ ...local, devHttpIssuer: undefined })).toThrow();
|
||||
expect(() => validateMcpSettings({ ...local, issuer: `${issuer}/other` })).toThrow();
|
||||
expect(() => validateMcpSettings({ ...local, resource: "http://192.168.1.114:8088/mcp" })).toThrow();
|
||||
expect(() => validateMcpSettings({ ...local, issuer: "http://example.com/realm", devHttpIssuer: "http://example.com/realm" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects personal queries and model supplied scope before any network call", async () => {
|
||||
let count = 0;
|
||||
const client = fixture((async () => { count++; return token(); }) as McpHttpFetch);
|
||||
await expect(client.query("p", { ...owner("p"), context: { ...owner("p").context, projectKey: "personal-actor" } }, "list_elements", {}, "call")).rejects.toMatchObject({ status: 403 });
|
||||
await expect(client.query("p", owner("p"), "list_elements", { context_handle: "MODEL", project_id: "other" }, "call")).rejects.toMatchObject({ status: 400 });
|
||||
expect(count).toBe(0);
|
||||
expect(client.catalog()[0].inputSchema.required).toEqual(["element_type"]);
|
||||
expect(client.catalog()[0].inputSchema.properties).not.toHaveProperty("context_handle");
|
||||
});
|
||||
|
||||
it("exchanges source credentials only with issuer and injects a host context into MCP", async () => {
|
||||
const id = "mcp-owner"; credential(id); const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||
const client = fixture((async (url, init) => {
|
||||
requests.push({ url: String(url), init });
|
||||
if (String(url).includes("/token")) return token();
|
||||
if (init?.method === "POST") return Response.json({ context_handle: "x".repeat(43) });
|
||||
return new Response(null, { status: 204 });
|
||||
}) as McpHttpFetch);
|
||||
const result = await client.query(id, owner(id), "list_elements", { element_type: "pipe" }, "native-call");
|
||||
const exchange = new URLSearchParams(String(requests[0].init?.body));
|
||||
expect(exchange.get("subject_token")).toBe(jwt(id));
|
||||
expect(exchange.get("audience")).toBe("tjwater-mcp");
|
||||
expect(exchange.get("resource")).toBe(settings.resource);
|
||||
const bind = requests.find((request) => request.init?.method === "POST" && !request.url.includes("/token"))!;
|
||||
expect(new Headers(bind.init?.headers).get("Authorization")).toBe(`Bearer ${resourceToken}`);
|
||||
expect(JSON.parse(String(bind.init?.body))).toMatchObject({ purpose: "ordinary_chat", native_session_id: id });
|
||||
expect(result.structuredContent).toMatchObject({ arguments: { context_handle: "x".repeat(43) }, _meta: { "io.tjwater/callId": "native-call" } });
|
||||
expect(requests.at(-1)?.init?.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
it("coalesces concurrent exchange, isolates owners and discards cached tokens on release", async () => {
|
||||
const a = "mcp-a", b = "mcp-b"; credential(a); credential(b); let exchanges = 0;
|
||||
const client = fixture((async (url, init) => {
|
||||
if (String(url).includes("/token")) { exchanges++; await Bun.sleep(5); return token(); }
|
||||
if (init?.method === "POST") return Response.json({ context_handle: "x".repeat(43) });
|
||||
return new Response(null, { status: 204 });
|
||||
}) as McpHttpFetch);
|
||||
await Promise.all([client.query(a, owner(a), "list_elements", {}, "one"), client.query(a, owner(a), "list_elements", {}, "two"), client.query(b, owner(b), "list_elements", {}, "one")]);
|
||||
expect(exchanges).toBe(2);
|
||||
client.dropSession(a);
|
||||
await client.query(a, owner(a), "list_elements", {}, "three");
|
||||
expect(exchanges).toBe(3);
|
||||
});
|
||||
|
||||
it("renews an expired source token through existing credential events", async () => {
|
||||
const id = "mcp-renew"; credential(id); let exchanges = 0, renewals = 0;
|
||||
const unsubscribe = credentialRefreshCoordinator.subscribe(id, (event) => {
|
||||
if (event.type === "credential_refresh_required") {
|
||||
renewals++; refreshSessionCredential(id, { authorization: `Bearer ${jwt("renewed")}`, projectId: "project" }, event.requestId);
|
||||
}
|
||||
});
|
||||
const client = fixture((async (url, init) => {
|
||||
if (String(url).includes("/token")) {
|
||||
exchanges++;
|
||||
const subject = new URLSearchParams(String(init?.body)).get("subject_token");
|
||||
return subject === jwt("renewed") ? token() : Response.json({ error: "invalid_grant" }, { status: 400 });
|
||||
}
|
||||
if (init?.method === "POST") return Response.json({ context_handle: "x".repeat(43) });
|
||||
return new Response(null, { status: 204 });
|
||||
}) as McpHttpFetch);
|
||||
try { await client.query(id, owner(id), "list_elements", {}, "call"); expect(exchanges).toBe(2); expect(renewals).toBe(1); }
|
||||
finally { unsubscribe(); }
|
||||
});
|
||||
|
||||
it("does not refresh for scope rejection and renews resource authentication only once", async () => {
|
||||
const id = "mcp-rejected"; credential(id); let exchanges = 0, binds = 0;
|
||||
const client = fixture((async (url, init) => {
|
||||
if (String(url).includes("/token")) { exchanges++; return token(); }
|
||||
if (init?.method === "POST") { binds++; return Response.json({}, { status: 401 }); }
|
||||
return new Response(null, { status: 204 });
|
||||
}) as McpHttpFetch);
|
||||
await expect(client.query(id, owner(id), "list_elements", {}, "call")).rejects.toMatchObject({ status: 401 });
|
||||
expect(exchanges).toBe(2); expect(binds).toBe(2);
|
||||
});
|
||||
|
||||
it("cancels a caller waiting for shared credential renewal", async () => {
|
||||
const id = "mcp-cancel"; credential(id);
|
||||
let resolve!: (response: Response) => void;
|
||||
let notifyStarted!: () => void;
|
||||
const started = new Promise<void>((done) => { notifyStarted = done; });
|
||||
const client = fixture((async () => new Promise<Response>((done) => { resolve = done; notifyStarted(); })) as McpHttpFetch);
|
||||
const controller = new AbortController();
|
||||
const pending = client.query(id, owner(id), "list_elements", {}, "call", controller.signal);
|
||||
await started;
|
||||
controller.abort(new Error("cancelled"));
|
||||
await expect(pending).rejects.toThrow("cancelled");
|
||||
client.dropSession(id); resolve(token());
|
||||
});
|
||||
|
||||
it("sends resource on both grants and supports advertised Basic client authentication", async () => {
|
||||
const id = "mcp-basic"; credential(id);
|
||||
const grants: Array<{ headers: Headers; body: URLSearchParams }> = [];
|
||||
const client = fixture((async (url, init) => {
|
||||
if (String(url).endsWith("/token")) {
|
||||
grants.push({ headers: new Headers(init?.headers), body: new URLSearchParams(String(init?.body)) });
|
||||
return token();
|
||||
}
|
||||
if (init?.method === "POST") return Response.json({ context_handle: "x".repeat(43) });
|
||||
return new Response(null, { status: 204 });
|
||||
}) as McpHttpFetch, "client_secret_basic");
|
||||
await client.prepare();
|
||||
await client.query(id, owner(id), "list_elements", {}, "call");
|
||||
expect(grants.map((g) => g.body.get("grant_type"))).toEqual(["client_credentials", "urn:ietf:params:oauth:grant-type:token-exchange"]);
|
||||
for (const grant of grants) {
|
||||
expect(grant.body.get("resource")).toBe(settings.resource);
|
||||
expect(grant.body.has("client_secret")).toBeFalse();
|
||||
expect(grant.headers.get("Authorization")).toBe(`Basic ${Buffer.from(`${settings.clientId}:${settings.clientSecret}`).toString("base64")}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports an unsupported resource indicator as configuration failure without a weaker retry", async () => {
|
||||
const id = "mcp-invalid-resource"; credential(id);
|
||||
let attempts = 0;
|
||||
const client = fixture((async (_url, init) => {
|
||||
attempts++;
|
||||
expect(new URLSearchParams(String(init?.body)).get("resource")).toBe(settings.resource);
|
||||
return Response.json({ error: "invalid_target" }, { status: 400 });
|
||||
}) as McpHttpFetch);
|
||||
await expect(client.query(id, owner(id), "list_elements", {}, "call")).rejects.toThrow("RFC 8707");
|
||||
expect(attempts).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects an exchanged identity, issuer, client or audience that differs from the verified source", () => {
|
||||
assertExchangedIdentity(jwt("source"), resourceToken, settings);
|
||||
for (const overrides of [{ sub: "other" }, { aud: "api" }, { iss: "https://other/realm" }, { azp: "browser" }]) {
|
||||
expect(() => assertExchangedIdentity(jwt("source"), jwt("resource", overrides), settings)).toThrow();
|
||||
}
|
||||
});
|
||||
@@ -166,6 +166,36 @@ describe("OperationalTaskClient", () => {
|
||||
expect(JSON.stringify(body)).not.toContain("transient-secret");
|
||||
});
|
||||
|
||||
it("publishes complete progress and presentation with the claimed run without browser receipts", async () => {
|
||||
const requests: Request[] = [];
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
const request = new Request(input, init);
|
||||
requests.push(request);
|
||||
if (request.url.endsWith("/claim")) return Response.json(run);
|
||||
if (request.url.endsWith("/presentation-events")) return Response.json({
|
||||
recorded: true, event_seq: 12, run_id: runId, execution_generation: 1,
|
||||
});
|
||||
return Response.json({ task, plan_revision: null, report_revision: null, progress_revision: 3 });
|
||||
}) as typeof fetch;
|
||||
bindOperationalTaskCredential("runtime-1", { authorization: "Bearer transient-secret", projectId: "project-1" });
|
||||
const client = new OperationalTaskClient("http://server.test");
|
||||
await client.claimTask("runtime-1", taskId, runId, {
|
||||
requestId: executionRequestId, expectedRevision: 1, sourceSessionId: "session-1",
|
||||
});
|
||||
const progress = { summary: "正在查询投诉时段", diagram: null };
|
||||
expect(await client.publishArtifacts("runtime-1", taskId, {
|
||||
requestId: executionRequestId, sourceSessionId: "session-1", progress,
|
||||
})).toMatchObject({ progress_revision: 3 });
|
||||
expect(await requests[1]!.json()).toMatchObject({ progress, run_id: runId, source_session_id: "session-1" });
|
||||
const command = { action: "set_overlay" as const, overlay_id: "complaints", title: "投诉位置", mode: "geometry" as const, evidence_id: "query-1" };
|
||||
expect(await client.publishPresentation("runtime-1", taskId, {
|
||||
requestId: executionRequestId, sourceSessionId: "session-1", command,
|
||||
})).toEqual({ recorded: true, event_seq: 12, run_id: runId, execution_generation: 1 });
|
||||
expect(await requests[2]!.json()).toEqual({ request_id: executionRequestId, run_id: runId, source_session_id: "session-1", command });
|
||||
expect(requests).toHaveLength(3);
|
||||
expect(requests[2]!.headers.get("authorization")).toBe("Bearer transient-secret");
|
||||
});
|
||||
|
||||
it("fails closed without a current product credential", async () => {
|
||||
const client = new OperationalTaskClient("http://server.test");
|
||||
expect(client.getAgentContext("runtime-1", "50000000-0000-4000-8000-000000000001"))
|
||||
@@ -217,49 +247,6 @@ describe("OperationalTaskClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the persisted bounded data snapshot with its evidence id", async () => {
|
||||
const urls: string[] = [];
|
||||
globalThis.fetch = (async (input) => {
|
||||
const url = String(input);
|
||||
urls.push(url);
|
||||
return urls.length === 1
|
||||
? Response.json({
|
||||
evidence_id: "scada_readings:sha256:abc",
|
||||
kind: "system_data",
|
||||
summary: "scada_readings 数据快照",
|
||||
source_label: "scada_readings",
|
||||
source_ref: "scada_readings:sha256:abc",
|
||||
observed_at: "2026-09-23T01:00:00Z",
|
||||
})
|
||||
: Response.json({
|
||||
source: "scada_readings",
|
||||
source_ref: "scada_readings:sha256:abc",
|
||||
queried_at: "2026-09-23T01:00:00Z",
|
||||
origin: "system_data",
|
||||
data: { items: [{ value: 0.42 }] },
|
||||
truncated: false,
|
||||
});
|
||||
}) as typeof fetch;
|
||||
bindOperationalTaskCredential("runtime-1", {
|
||||
authorization: "Bearer transient-secret",
|
||||
projectId: "project-1",
|
||||
});
|
||||
const client = new OperationalTaskClient("http://server.test");
|
||||
|
||||
const result = await client.queryData("runtime-1", "50000000-0000-4000-8000-000000000001", {
|
||||
source: "scada_readings",
|
||||
device_ids: ["device-1"],
|
||||
start: "2026-09-23T00:00:00Z",
|
||||
end: "2026-09-23T01:00:00Z",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
evidence: { evidence_id: "scada_readings:sha256:abc" },
|
||||
snapshot: { origin: "system_data", truncated: false, data: { items: [{ value: 0.42 }] } },
|
||||
});
|
||||
expect(urls[1]).toContain("/evidence/scada_readings%3Asha256%3Aabc/snapshot");
|
||||
});
|
||||
|
||||
it("keeps the current task revision for heartbeats after an action-only response", async () => {
|
||||
const requestBodies: Record<string, unknown>[] = [];
|
||||
let call = 0;
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { ProjectDataClient, projectDataQuerySchema } from "../../src/product/project-data-client.js";
|
||||
import { bindSessionCredential, releaseSessionCredential } from "../../src/product/credential-refresh.js";
|
||||
import { historyChart } from "../../src/product/history-chart.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
afterEach(() => { globalThis.fetch = originalFetch; releaseSessionCredential("query-session"); });
|
||||
describe("controlled project queries", () => {
|
||||
it("dispatches SCADA history to the existing fields endpoint using monitored_value", async () => {
|
||||
bindSessionCredential("query-session", { authorization: "Bearer TEST", projectId: "trusted-project" });
|
||||
globalThis.fetch = (async (url) => {
|
||||
const parsed = new URL(String(url));
|
||||
expect(parsed.pathname).toBe("/api/v1/timeseries/scada-readings/fields");
|
||||
expect(parsed.searchParams.get("field")).toBe("monitored_value");
|
||||
expect(parsed.searchParams.get("device_ids")).toBe("D1,D2");
|
||||
return Response.json([{ device_id: "D1", value: 20 }]);
|
||||
}) as typeof fetch;
|
||||
await new ProjectDataClient("http://server.test").query("query-session", { source: "scada_history", device_ids: ["D1", "D2"], field: "monitored_value", start: "2026-09-01T00:00:00Z", end: "2026-09-01T01:00:00Z" });
|
||||
});
|
||||
it("uses fixed routes and the bound project credential, including encoded asset IDs", async () => {
|
||||
bindSessionCredential("query-session", { authorization: "Bearer TEST", projectId: "trusted-project" });
|
||||
let requested: URL | undefined;
|
||||
globalThis.fetch = (async (url, init) => {
|
||||
requested = new URL(String(url));
|
||||
expect(new Headers(init?.headers).get("X-Project-Id")).toBe("trusted-project");
|
||||
return Response.json({ pressure: 20 });
|
||||
}) as typeof fetch;
|
||||
expect(await new ProjectDataClient("http://server.test").query("query-session", { source: "element_properties", element_type: "junction", element_id: "J&network=other" })).toEqual({ pressure: 20 });
|
||||
expect(requested?.pathname).toBe("/api/v1/junctions/properties");
|
||||
expect(requested?.searchParams.get("junction")).toBe("J&network=other");
|
||||
expect(requested?.searchParams.has("network")).toBeFalse();
|
||||
});
|
||||
it("rejects arbitrary routes, project overrides and excessive time windows before dispatch", () => {
|
||||
expect(projectDataQuerySchema.safeParse({ source: "scada_devices", project_id: "other" }).success).toBeFalse();
|
||||
expect(projectDataQuerySchema.safeParse({ source: "http", url: "http://other" }).success).toBeFalse();
|
||||
expect(projectDataQuerySchema.safeParse({ source: "scada_history", device_ids: ["D1"], field: "monitored_value", start: "2026-09-01T00:00:00Z", end: "2026-09-03T00:00:00Z" }).success).toBeFalse();
|
||||
});
|
||||
it("keeps returned units, drops null samples and never manufactures missing history", () => {
|
||||
expect(() => historyChart({ series: [] }, "J1")).toThrow("NO_HISTORY_DATA");
|
||||
const chart = historyChart({ series: [{ element_id: "J1", metric: "pressure", source: "scada_raw", source_unit: "m", points: [{ time: "t1", value: null }, { time: "t2", value: 20 }] }] }, "J1");
|
||||
expect(chart.title).toContain("(m)");
|
||||
expect(chart.x_data).toEqual(["t2"]);
|
||||
expect(chart.series[0].data).toEqual([20]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import publishPresentation from "../../.opencode/tools/publish_task_presentation.js";
|
||||
import publishProgress from "../../.opencode/tools/publish_task_progress.js";
|
||||
import { taskPublicationCommandSchema, taskPublicationProgressSchema } from "../../.opencode/tools/task-publication-schema.js";
|
||||
import { taskProgressSchema } from "../../src/product/investigation-report.js";
|
||||
import { taskPresentationCommandSchema } from "../../src/product/task-presentation.js";
|
||||
|
||||
describe("task presentation input boundaries", () => {
|
||||
it("aligns native and product progress schemas for legacy and current diagrams", () => {
|
||||
const progress = { summary: "实际读取监测资料", diagram: { title: "当前路线", mermaid: "flowchart TD\nQUERY --> REVIEW", node_refs: [
|
||||
{ node_id: "QUERY", label: "查询", status: "running", map_targets: [{ source_id: "pipes", feature_id: "P-1" }] },
|
||||
{ node_id: "REVIEW", label: "复核", status: null },
|
||||
] } };
|
||||
expect(taskProgressSchema.parse(taskPublicationProgressSchema.parse(progress))).toEqual(taskProgressSchema.parse(progress));
|
||||
expect(taskProgressSchema.parse(taskPublicationProgressSchema.parse({ summary: "当前无图" }))).toEqual({ summary: "当前无图", diagram: null });
|
||||
});
|
||||
|
||||
it("accepts finite display commands and rejects unsupported data, layers and chart evidence", () => {
|
||||
const chart = { title: "压力", chart_type: "line", x_data: ["10:00"], series: [{ name: "MP01", data: [0.4] }] };
|
||||
const commands = [
|
||||
{ action: "highlight_features", layer: "scada", feature_ids: ["MP01"] },
|
||||
{ action: "clear_highlights" },
|
||||
{ action: "open_chart", chart_id: "pressure", chart, evidence_refs: ["reading-1"] },
|
||||
{ action: "close_chart", chart_id: "pressure" },
|
||||
{ action: "set_overlay", overlay_id: "complaints", title: "实际投诉", mode: "heatmap", evidence_id: "query-1" },
|
||||
{ action: "clear_overlay", overlay_id: "complaints" },
|
||||
];
|
||||
for (const command of commands) {
|
||||
expect(taskPresentationCommandSchema.parse(taskPublicationCommandSchema.parse(command))).toEqual(taskPresentationCommandSchema.parse(command));
|
||||
}
|
||||
for (const command of [
|
||||
{ ...commands[0], layer: "drainage" },
|
||||
{ ...commands[2], evidence_refs: [] },
|
||||
{ ...commands[2], chart: { ...chart, series: [{ name: "MP01", data: [0.4, 0.5] }] } },
|
||||
{ ...commands[4], data: { coordinates: [1, 2] } },
|
||||
{ ...commands[4], script: "alert(1)" },
|
||||
]) expect(() => taskPresentationCommandSchema.parse(command)).toThrow();
|
||||
});
|
||||
|
||||
it("revalidates semantic chart and Mermaid inputs before sending a tool request", async () => {
|
||||
const request_id = "60000000-0000-4000-8000-000000000001";
|
||||
await expect(publishPresentation.execute({ request_id, command: {
|
||||
action: "open_chart", chart_id: "pressure", evidence_refs: ["reading-1"],
|
||||
chart: { title: "压力", chart_type: "line", x_data: ["10:00"], series: [{ name: "MP01", data: [0.4, 0.5] }] },
|
||||
} }, { sessionID: "test" })).rejects.toThrow("series must align");
|
||||
await expect(publishProgress.execute({ request_id, progress: {
|
||||
summary: "查询", diagram: { title: "路线", mermaid: "flowchart TD; A-->B; click A callback", node_refs: [] },
|
||||
} }, { sessionID: "test" })).rejects.toThrow("Mermaid");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user