fix: resolve M-01..M-02, M-05..M-08, L-01..L-03, L-05 from pre-release review

M-01: Compose OperationDefinitionSchema from OperationSpecSchema via Type.Intersect
M-02: Extract from_openapi to subpath export, remove from main entry
M-05: Fix mapError fragile includes() matching — use startsWith(code+':') or exact
M-06: Replace any casts with MCPClientLike/MCPToolResult interfaces in from_mcp
M-07: Add injectable fetch to HTTPServiceConfig for from_openapi
M-08: Add OpenAPIServiceRegistry with lifecycle methods (add, remove, registerAll)
L-01: Validate subscription handler type at registration and runtime
L-02: Strengthen isResponseEnvelope with source-specific field validation
L-03: Add logger.warn on FromSchema fallback to Type.Unknown
L-04: Noted as intentional (SSE GET body handling)
L-05: Add registerAll to MCPClientLoader and OpenAPIServiceRegistry
This commit is contained in:
2026-05-16 14:56:13 +00:00
parent 2b72289635
commit ca2021bd3d
17 changed files with 424 additions and 72 deletions

View File

@@ -92,8 +92,9 @@ describe("buildEnv", () => {
it("filters out SUBSCRIPTION operations", () => {
const registry = new OperationRegistry();
registry.register(makeOperation("query"));
async function* subHandler(input: any) { yield { result: input.value }; }
registry.register({
...makeOperation("onEvent"),
...makeOperation("onEvent", subHandler),
type: OperationType.SUBSCRIPTION,
});

View File

@@ -50,6 +50,20 @@ describe("mapError", () => {
expect(result.code).toBe("NOT_FOUND");
});
it("does not false-positive on substring match (ITEM_NOT_FOUND vs NOT_FOUND)", () => {
const result = mapError(new Error("ITEM_NOT_FOUND: nope"), [
{ code: "NOT_FOUND", schema: {} },
]);
expect(result.code).toBe(InfrastructureErrorCode.EXECUTION_ERROR);
});
it("matches exact code equality", () => {
const result = mapError(new Error("NOT_FOUND"), [
{ code: "NOT_FOUND", schema: {} },
]);
expect(result.code).toBe("NOT_FOUND");
});
it("maps non-Error to UNKNOWN_ERROR", () => {
const result = mapError("string error");
expect(result.code).toBe(InfrastructureErrorCode.UNKNOWN_ERROR);

View File

@@ -401,4 +401,112 @@ describe("OperationRegistry access control", () => {
expect((error as CallError).message).toContain("identity required");
}
});
});
describe("OperationRegistry subscription handler validation", () => {
it("rejects non-async-generator handler for SUBSCRIPTION via registerHandler", () => {
const registry = new OperationRegistry();
registry.registerSpec({
name: "badSub",
namespace: "test",
version: "1.0.0",
type: OperationType.SUBSCRIPTION,
description: "bad sub",
inputSchema: Type.Object({}),
outputSchema: Type.Unknown(),
accessControl: { requiredScopes: [] },
});
const regularAsyncFn = async () => "not a generator";
expect(() => registry.registerHandler("test.badSub", regularAsyncFn as any)).toThrow(
/must be an async generator function/i,
);
});
it("rejects synchronous function handler for SUBSCRIPTION via registerHandler", () => {
const registry = new OperationRegistry();
registry.registerSpec({
name: "syncSub",
namespace: "test",
version: "1.0.0",
type: OperationType.SUBSCRIPTION,
description: "sync sub",
inputSchema: Type.Object({}),
outputSchema: Type.Unknown(),
accessControl: { requiredScopes: [] },
});
expect(() => registry.registerHandler("test.syncSub", (() => {}) as any)).toThrow(
/must be an async generator function/i,
);
});
it("allows async generator function handler for SUBSCRIPTION via registerHandler", () => {
const registry = new OperationRegistry();
registry.registerSpec({
name: "goodSub",
namespace: "test",
version: "1.0.0",
type: OperationType.SUBSCRIPTION,
description: "good sub",
inputSchema: Type.Object({}),
outputSchema: Type.Unknown(),
accessControl: { requiredScopes: [] },
});
async function* goodHandler(_input: unknown, _context: any) {
yield "event";
}
expect(() => registry.registerHandler("test.goodSub", goodHandler as any)).not.toThrow();
});
it("rejects non-async-generator handler for SUBSCRIPTION via register", () => {
const registry = new OperationRegistry();
expect(() =>
registry.register({
name: "badRegSub",
namespace: "test",
version: "1.0.0",
type: OperationType.SUBSCRIPTION,
description: "bad sub via register",
inputSchema: Type.Object({}),
outputSchema: Type.Unknown(),
accessControl: { requiredScopes: [] },
handler: async () => "not a generator" as any,
}),
).toThrow(/must be an async generator function/i);
});
it("allows async generator handler for SUBSCRIPTION via register", () => {
const registry = new OperationRegistry();
async function* handler(_input: unknown, _context: any) {
yield "event";
}
expect(() =>
registry.register({
name: "goodRegSub",
namespace: "test",
version: "1.0.0",
type: OperationType.SUBSCRIPTION,
description: "good sub via register",
inputSchema: Type.Object({}),
outputSchema: Type.Unknown(),
accessControl: { requiredScopes: [] },
handler,
}),
).not.toThrow();
});
it("allows regular async handler for QUERY via registerHandler", () => {
const registry = new OperationRegistry();
registry.registerSpec({
name: "queryOp",
namespace: "test",
version: "1.0.0",
type: OperationType.QUERY,
description: "query op",
inputSchema: Type.Object({}),
outputSchema: Type.Unknown(),
accessControl: { requiredScopes: [] },
});
const handler = async (_input: unknown, _context: any) => ({ result: "ok" });
expect(() => registry.registerHandler("test.queryOp", handler as any)).not.toThrow();
});
});

View File

@@ -240,6 +240,46 @@ describe("isResponseEnvelope", () => {
it("returns false for object with numeric meta", () => {
expect(isResponseEnvelope({ data: "hello", meta: 42 })).toBe(false);
});
it("returns false for local envelope missing operationId", () => {
expect(isResponseEnvelope({ data: "hello", meta: { source: "local", timestamp: Date.now() } })).toBe(false);
});
it("returns false for local envelope with non-string operationId", () => {
expect(isResponseEnvelope({ data: "hello", meta: { source: "local", operationId: 123, timestamp: Date.now() } })).toBe(false);
});
it("returns false for local envelope missing timestamp", () => {
expect(isResponseEnvelope({ data: "hello", meta: { source: "local", operationId: "op.test" } })).toBe(false);
});
it("returns false for local envelope with non-number timestamp", () => {
expect(isResponseEnvelope({ data: "hello", meta: { source: "local", operationId: "op.test", timestamp: "now" } })).toBe(false);
});
it("returns false for http envelope missing statusCode", () => {
expect(isResponseEnvelope({ data: "hello", meta: { source: "http", headers: {}, contentType: "text/plain" } })).toBe(false);
});
it("returns false for http envelope with non-number statusCode", () => {
expect(isResponseEnvelope({ data: "hello", meta: { source: "http", statusCode: "200", headers: {}, contentType: "text/plain" } })).toBe(false);
});
it("returns false for mcp envelope missing isError", () => {
expect(isResponseEnvelope({ data: "hello", meta: { source: "mcp", content: [] } })).toBe(false);
});
it("returns false for mcp envelope with non-boolean isError", () => {
expect(isResponseEnvelope({ data: "hello", meta: { source: "mcp", isError: "true", content: [] } })).toBe(false);
});
it("returns false for mcp envelope missing content", () => {
expect(isResponseEnvelope({ data: "hello", meta: { source: "mcp", isError: false } })).toBe(false);
});
it("returns false for mcp envelope with non-array content", () => {
expect(isResponseEnvelope({ data: "hello", meta: { source: "mcp", isError: true, content: "error" } })).toBe(false);
});
});
describe("unwrap", () => {

View File

@@ -380,4 +380,85 @@ describe("subscribe", () => {
expect(results).toHaveLength(1);
expect(results[0].data).toBe("secret-event");
});
it("throws CallError when handler returns a non-async-iterable (plain async function)", async () => {
const registry = new OperationRegistry();
registry.registerSpec({
name: "badSub",
namespace: "test",
version: "1.0.0",
type: OperationType.SUBSCRIPTION,
description: "bad sub",
inputSchema: Type.Object({}),
outputSchema: Type.Unknown(),
accessControl: { requiredScopes: [] },
});
const plainAsyncFn = async (_input: unknown, _context: OperationContext) => "not a generator";
(registry as any).handlers.set("test.badSub", plainAsyncFn);
try {
for await (const _ of subscribe(registry, "test.badSub", {}, makeContext())) {
expect.fail("Should have thrown");
}
expect.fail("Should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(CallError);
expect((error as CallError).code).toBe(InfrastructureErrorCode.EXECUTION_ERROR);
expect((error as CallError).message).toContain("must return an async iterable");
}
});
it("throws CallError when handler returns null", async () => {
const registry = new OperationRegistry();
registry.registerSpec({
name: "nullSub",
namespace: "test",
version: "1.0.0",
type: OperationType.SUBSCRIPTION,
description: "null sub",
inputSchema: Type.Object({}),
outputSchema: Type.Unknown(),
accessControl: { requiredScopes: [] },
});
const nullFn = (_input: unknown, _context: OperationContext): any => null;
(registry as any).handlers.set("test.nullSub", nullFn);
try {
for await (const _ of subscribe(registry, "test.nullSub", {}, makeContext())) {
expect.fail("Should have thrown");
}
expect.fail("Should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(CallError);
expect((error as CallError).code).toBe(InfrastructureErrorCode.EXECUTION_ERROR);
expect((error as CallError).message).toContain("must return an async iterable");
}
});
it("throws CallError when handler returns a plain object (non-iterable)", async () => {
const registry = new OperationRegistry();
registry.registerSpec({
name: "objSub",
namespace: "test",
version: "1.0.0",
type: OperationType.SUBSCRIPTION,
description: "obj sub",
inputSchema: Type.Object({}),
outputSchema: Type.Unknown(),
accessControl: { requiredScopes: [] },
});
const objFn = (_input: unknown, _context: OperationContext): any => ({ not: "iterable" });
(registry as any).handlers.set("test.objSub", objFn);
try {
for await (const _ of subscribe(registry, "test.objSub", {}, makeContext())) {
expect.fail("Should have thrown");
}
expect.fail("Should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(CallError);
expect((error as CallError).code).toBe(InfrastructureErrorCode.EXECUTION_ERROR);
expect((error as CallError).message).toContain("must return an async iterable");
}
});
});