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