fix(jeb): preserve selected endpoint in failures (#7835)
* fix(jeb): preserve selected endpoint in failures * fix(jeb): retain endpoint on typed failures
This commit is contained in:
1 parent
128c2cf632
commit
e79ad6ca8e
3 files changed
+188
-9
No files matched your search
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createJebAnalysisProvider } from "../composition/jeb.js";
|
||||
import { JebProvider } from "./JebProvider.js";
|
||||
import { decodeJebToolResult } from "./JebMcpConnection.js";
|
||||
import type {
|
||||
JebMcpConnection,
|
||||
JebMcpConnectionTimeouts,
|
||||
@@ -52,6 +53,40 @@ const providerWith = (connection: JebMcpConnection) =>
|
||||
});
|
||||
|
||||
describe("JebProvider live availability", () => {
|
||||
it("retains the caller-selected endpoint spelling in results and failures", async () => {
|
||||
const endpoint = "HTTP://127.0.0.1:08425/mcp";
|
||||
const provider = new JebProvider({
|
||||
environment: { REA_JEB_MCP_URL: endpoint },
|
||||
connectionFactory: () =>
|
||||
scriptedConnection({
|
||||
get_client_information: { success: true, version: "5.48.0" },
|
||||
list_units: new Error("connection closed"),
|
||||
}),
|
||||
});
|
||||
try {
|
||||
expect((await provider.inspectAvailability()).diagnostics).toMatchObject({
|
||||
endpoint,
|
||||
});
|
||||
const observation = await provider.execute({
|
||||
operation: "inspect_jeb_client",
|
||||
input: {},
|
||||
});
|
||||
if (!observation.ok) throw observation.error;
|
||||
expect(
|
||||
jebResultSchemas.inspect_jeb_client.parse(observation.value.result)
|
||||
.engine.endpoint,
|
||||
).toBe(endpoint);
|
||||
const failure = await provider.execute({
|
||||
operation: "list_jeb_units",
|
||||
input: { count: 10, index: 0 },
|
||||
});
|
||||
if (failure.ok) throw new Error("Expected transport failure");
|
||||
expect(failure.error.diagnostics).toMatchObject({ endpoint });
|
||||
} finally {
|
||||
await provider.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("passes its validated environment timeout to the production connection", async () => {
|
||||
let configured: JebMcpConnectionTimeouts | undefined;
|
||||
const provider = createJebAnalysisProvider(
|
||||
@@ -125,6 +160,59 @@ describe("JebProvider live availability", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("JebProvider endpoint diagnostics", () => {
|
||||
it("retains the selected endpoint on malformed MCP results without replacing the protocol failure", async () => {
|
||||
let failure: unknown;
|
||||
try {
|
||||
decodeJebToolResult({ content: [] });
|
||||
} catch (cause) {
|
||||
failure = cause;
|
||||
}
|
||||
if (!(failure instanceof AnalysisProtocolError))
|
||||
throw new Error("Expected a protocol failure");
|
||||
failure.retainDiagnostics({ phase: "decode" });
|
||||
const connection = scriptedConnection({ get_client_information: failure });
|
||||
const provider = providerWith(connection);
|
||||
try {
|
||||
const result = await provider.execute({
|
||||
operation: "inspect_jeb_client",
|
||||
input: {},
|
||||
});
|
||||
if (result.ok) throw new Error("Expected a malformed response failure");
|
||||
expect(result.error).toBe(failure);
|
||||
expect(result.error.diagnostics).toEqual({
|
||||
phase: "decode",
|
||||
endpoint: "http://127.0.0.1:8425/mcp",
|
||||
});
|
||||
} finally {
|
||||
await provider.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("retains the selected endpoint when cancelled before execution", async () => {
|
||||
const connection = scriptedConnection({});
|
||||
const provider = providerWith(connection);
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("caller stopped");
|
||||
controller.abort(reason);
|
||||
try {
|
||||
const result = await provider.execute(
|
||||
{ operation: "inspect_jeb_client", input: {} },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.diagnostics).toEqual({
|
||||
endpoint: "http://127.0.0.1:8425/mcp",
|
||||
});
|
||||
expect(connection.calls).toEqual([]);
|
||||
} finally {
|
||||
await provider.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("JebProvider observations and failures", () => {
|
||||
it("normalizes client information and caches engine identity", async () => {
|
||||
const connection = scriptedConnection({
|
||||
@@ -170,6 +258,9 @@ describe("JebProvider observations and failures", () => {
|
||||
if (result.ok) return;
|
||||
expect(result.error).toBeInstanceOf(AnalysisCapabilityUnavailableError);
|
||||
expect(result.error.message).toContain("ECONNREFUSED");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
details: { diagnostics: { endpoint: "http://127.0.0.1:8425/mcp" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps transport failures during an operation to provider unavailability", async () => {
|
||||
@@ -508,6 +599,7 @@ it("preserves provider-authored timeout guidance when projecting the requested o
|
||||
const reason = new Error("selected deadline elapsed");
|
||||
const timeout = new AnalysisTimeoutError("list_units", 123, {
|
||||
cause: reason,
|
||||
diagnostics: { phase: "request" },
|
||||
userMessage: "The selected MCP request budget elapsed.",
|
||||
remediationAction: "Increase the selected MCP request budget.",
|
||||
});
|
||||
@@ -523,6 +615,7 @@ it("preserves provider-authored timeout guidance when projecting the requested o
|
||||
timeoutMs: 123,
|
||||
userMessage: timeout.userMessage,
|
||||
remediationAction: timeout.remediationAction,
|
||||
diagnostics: { phase: "request", endpoint: "http://127.0.0.1:8425/mcp" },
|
||||
});
|
||||
expect(result.error.cause).toBe(reason);
|
||||
} finally {
|
||||
|
||||
+19
-9
@@ -159,6 +159,8 @@ const parseJebEnvelope = (
|
||||
export class JebProvider implements JebAnalysisPort {
|
||||
readonly #connection: JebMcpConnection;
|
||||
readonly #endpoint: URL;
|
||||
/** Caller-selected spelling, retained only after endpoint admission succeeds. */
|
||||
readonly #selectedEndpoint: string;
|
||||
#clientInformation:
|
||||
| Pick<z.infer<typeof clientInformation>, "version" | "gui_client">
|
||||
| undefined;
|
||||
@@ -175,6 +177,8 @@ export class JebProvider implements JebAnalysisPort {
|
||||
const endpoint = parseJebMcpEndpoint(environment.REA_JEB_MCP_URL);
|
||||
if (!endpoint.ok) throw endpoint.error;
|
||||
this.#endpoint = endpoint.value;
|
||||
this.#selectedEndpoint =
|
||||
environment.REA_JEB_MCP_URL || DEFAULT_JEB_MCP_ENDPOINT;
|
||||
const createConnection =
|
||||
options.connectionFactory ?? createStreamableHttpJebMcpConnection;
|
||||
const timeouts = {
|
||||
@@ -208,7 +212,7 @@ export class JebProvider implements JebAnalysisPort {
|
||||
Object.keys(JEB_ENGINE_TOOLS) as JebOperation[]
|
||||
).filter((operation) => tools.has(JEB_ENGINE_TOOLS[operation])),
|
||||
diagnostics: {
|
||||
endpoint: this.#endpoint.toString(),
|
||||
endpoint: this.#selectedEndpoint,
|
||||
engine_version: information.version ?? null,
|
||||
},
|
||||
};
|
||||
@@ -221,8 +225,8 @@ export class JebProvider implements JebAnalysisPort {
|
||||
code: "runtime_unavailable",
|
||||
reason: projected.message,
|
||||
diagnostics: {
|
||||
endpoint: this.#endpoint.toString(),
|
||||
...cause.diagnostics,
|
||||
endpoint: this.#selectedEndpoint,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -230,7 +234,7 @@ export class JebProvider implements JebAnalysisPort {
|
||||
status: "unavailable",
|
||||
code: "not_configured",
|
||||
reason: `${cause instanceof Error ? cause.message : "The JEB MCP endpoint did not respond"}. ${JEB_CONNECTION_REMEDIATION}`,
|
||||
diagnostics: { endpoint: this.#endpoint.toString() },
|
||||
diagnostics: { endpoint: this.#selectedEndpoint },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -247,6 +251,7 @@ export class JebProvider implements JebAnalysisPort {
|
||||
return err(
|
||||
new AnalysisCancelledError(request.operation, {
|
||||
cause: options.signal.reason,
|
||||
diagnostics: { endpoint: this.#selectedEndpoint },
|
||||
}),
|
||||
);
|
||||
try {
|
||||
@@ -300,7 +305,7 @@ export class JebProvider implements JebAnalysisPort {
|
||||
return {
|
||||
name: "jeb",
|
||||
version: information.version ?? null,
|
||||
endpoint: this.#endpoint.toString(),
|
||||
endpoint: this.#selectedEndpoint,
|
||||
gui_client: information.gui_client ?? null,
|
||||
};
|
||||
}
|
||||
@@ -375,7 +380,7 @@ export class JebProvider implements JebAnalysisPort {
|
||||
engine: {
|
||||
name: "jeb",
|
||||
version: information.version ?? null,
|
||||
endpoint: this.#endpoint.toString(),
|
||||
endpoint: this.#selectedEndpoint,
|
||||
gui_client: information.gui_client ?? null,
|
||||
},
|
||||
startup_ts: information.startup_ts ?? null,
|
||||
@@ -524,7 +529,7 @@ export class JebProvider implements JebAnalysisPort {
|
||||
const parsed = parseJebEnvelope(
|
||||
payload,
|
||||
operation,
|
||||
this.#endpoint.toString(),
|
||||
this.#selectedEndpoint,
|
||||
input,
|
||||
);
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
@@ -546,7 +551,10 @@ export class JebProvider implements JebAnalysisPort {
|
||||
) {
|
||||
const options = {
|
||||
cause: cause.cause,
|
||||
diagnostics: cause.diagnostics,
|
||||
diagnostics: {
|
||||
...cause.diagnostics,
|
||||
endpoint: this.#selectedEndpoint,
|
||||
},
|
||||
capturedOutput: cause.capturedOutput,
|
||||
cleanup: cause.cleanup,
|
||||
partialObservation: cause.partialObservation,
|
||||
@@ -567,11 +575,12 @@ export class JebProvider implements JebAnalysisPort {
|
||||
.bindCleanupDependencies([cause])
|
||||
.bindCleanupResourceOwner(cause);
|
||||
}
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
if (cause instanceof AnalysisError)
|
||||
return cause.retainDiagnostics({ endpoint: this.#selectedEndpoint });
|
||||
if (cause instanceof z.ZodError)
|
||||
return new AnalysisProtocolError(
|
||||
"JEB omitted or malformed required observation fields",
|
||||
{ cause },
|
||||
{ cause, diagnostics: { endpoint: this.#selectedEndpoint } },
|
||||
);
|
||||
const physical =
|
||||
cause instanceof JebMcpPhysicalFailure ? cause.cause : cause;
|
||||
@@ -581,6 +590,7 @@ export class JebProvider implements JebAnalysisPort {
|
||||
physical instanceof Error ? physical.message : String(physical),
|
||||
{
|
||||
cause,
|
||||
diagnostics: { endpoint: this.#selectedEndpoint },
|
||||
userMessage: JEB_CONNECTION_REMEDIATION,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { resolve } from "node:path";
|
||||
import { createServer } from "node:http";
|
||||
import { once } from "node:events";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
@@ -26,6 +28,80 @@ const isolatedEnvironment = (workspace: TestWorkspace) => ({
|
||||
REA_JEB_MCP_TIMEOUT_MS: "1.5",
|
||||
});
|
||||
|
||||
test("retains the selected endpoint when a direct JEB connection fails", async ({
|
||||
cli,
|
||||
workspace,
|
||||
}) => {
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(404, { "content-type": "text/plain" });
|
||||
response.end("not found");
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
try {
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string")
|
||||
throw new Error("Missing loopback listener address");
|
||||
const endpoint = `HTTP://127.0.0.1:0${address.port}/mcp`;
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-jeb-client", "--json"],
|
||||
environment: {
|
||||
...isolatedEnvironment(workspace),
|
||||
REA_JEB_MCP_TIMEOUT_MS: "3000",
|
||||
REA_JEB_MCP_URL: endpoint,
|
||||
},
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "capability_unavailable",
|
||||
details: {
|
||||
diagnostics: { endpoint },
|
||||
reason: expect.stringContaining("not found"),
|
||||
},
|
||||
});
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [resolve("scripts/rea.mjs"), "mcp"],
|
||||
cwd: workspace.root,
|
||||
env: {
|
||||
...isolatedEnvironment(workspace),
|
||||
REA_JEB_MCP_TIMEOUT_MS: "3000",
|
||||
REA_JEB_MCP_URL: endpoint,
|
||||
},
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({
|
||||
name: "jeb-endpoint-acceptance",
|
||||
version: "1",
|
||||
});
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const definition = (await client.listTools()).tools.find(
|
||||
({ name }) => name === "inspect_jeb_client",
|
||||
);
|
||||
if (definition === undefined) throw new Error("Missing JEB tool");
|
||||
const refusal = parseMcpToolError(
|
||||
await client.callTool(
|
||||
{ name: "inspect_jeb_client", arguments: {} },
|
||||
{ toolDefinition: definition },
|
||||
),
|
||||
);
|
||||
expect(refusal.error).toMatchObject({
|
||||
code: "capability_unavailable",
|
||||
details: { diagnostics: { endpoint } },
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
}
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolveClose, rejectClose) =>
|
||||
server.close((error) => (error ? rejectClose(error) : resolveClose())),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps compiled CLI discovery usable while JEB rejects its malformed timeout", async ({
|
||||
cli,
|
||||
workspace,
|
||||
|
||||
Reference in new issue
Block a user