From dd3dacd84727621e0f92d86a85834d8ebc80a297 Mon Sep 17 00:00:00 2001 From: sjungwon03 Date: Thu, 27 Aug 2026 15:50:02 +0900 Subject: [PATCH] feat(core): add testing and media helpers --- README.md | 5 +- docs/api/decorator-api.md | 16 +++ docs/architecture/adr/0005-testing-media.md | 19 +++ docs/guides/testing-media.md | 54 +++++++++ .../2026-08-27_issue-182-testing-media.md | 45 ++++++++ docs/product/mvp-scope.md | 2 + package-lock.json | 13 +-- package.json | 12 +- scripts/verify-compatibility-consumer.mjs | 10 +- scripts/verify-package-exports.mjs | 4 + src/compiler/normalize-tool-result.ts | 26 +++++ src/index.ts | 2 + src/media.ts | 108 ++++++++++++++++++ src/testing.ts | 59 ++++++++++ test/media-helpers.test.ts | 45 ++++++++ test/production-dependency-security.test.ts | 5 +- test/single-package-contract.test.ts | 3 +- test/testing-helper.test.ts | 76 ++++++++++++ tsup.config.ts | 1 + 19 files changed, 487 insertions(+), 18 deletions(-) create mode 100644 docs/architecture/adr/0005-testing-media.md create mode 100644 docs/guides/testing-media.md create mode 100644 docs/planning/2026-08-27_issue-182-testing-media.md create mode 100644 src/media.ts create mode 100644 src/testing.ts create mode 100644 test/media-helpers.test.ts create mode 100644 test/testing-helper.test.ts diff --git a/README.md b/README.md index 55808d7..82a9e9c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ > **Published package — `@theorvane/type-mcp@0.3.2`:** provides standard decorators, a separate `@theorvane/type-mcp/legacy` entrypoint for CommonJS legacy decorators, definition validation, explicit instance resolution, MCP SDK compilation, stdio, `@theorvane/type-mcp/http` Streamable HTTP, and the tools-only `@theorvane/type-mcp/langchain` adapter. > -> **Current `dev` source:** additionally includes SDK v2 protocol negotiation, modern component metadata, tool output schemas, explicit prompt arguments, resource URI templates, completion, and a narrow invocation context for cancellation and progress. The examples and capability map below target current source unless they explicitly say “published package.” +> **Current `dev` source:** additionally includes SDK v2 protocol negotiation, modern component metadata, tool output schemas, explicit prompt arguments, resource URI templates, completion, invocation context, protocol-backed testing, and image/audio helpers. The examples and capability map below target current source unless they explicitly say “published package.” > > **Integration boundary:** LangGraph `ToolNode` composition, graph topology, model choice, authorization, state, persistence, and deployment remain consumer responsibilities. @@ -133,6 +133,8 @@ The methods above are ordinary application methods. In current source, use `crea | `getMcpServerDefinition()` | Available | Reads a fresh frozen metadata copy; returns `undefined` for undecorated classes. | | `createMcpServer()` | Available | Validates declarations and compiles the decorated server surface with an explicit resolver seam. | | `McpInvocationContext` | Available | Optional final handler argument exposing request/session identity, cancellation, and progress reporting. | +| `McpImage` / `McpAudio` | Available | Browser-neutral byte helpers normalized to standard MCP media content. | +| `@theorvane/type-mcp/testing` | Available | Connects the official SDK client and a compiled server through the in-memory protocol transport. | | `serveStdioServer()` / `startStdioServer()` | Available | SDK v2 factory-based 2025/2026 negotiation plus an instance-based 2025 compatibility helper. | | `@theorvane/type-mcp/http` / `createMcpHandler()` | Available | Fetch/Streamable HTTP adapter with stateful 2025 sessions and the SDK v2 2026 per-request lifecycle; applications own route hosting, durable session policy, and authorization. | | Definition validation and `TypeMcpDefinitionError` | Available | Validates declarations and reports safe definition errors. | @@ -146,6 +148,7 @@ The methods above are ordinary application methods. In current source, use `crea - [Choose a runtime boundary](docs/guides/runtime-selection.md) — select the released root, stdio, HTTP, or tools-only LangChain surface. - [Dynamic prompts and resources](docs/guides/dynamic-declarations.md) — explicit prompt arguments, URI templates, and completion in current source. - [Invocation context](docs/guides/invocation-context.md) — request identity, cancellation, progress, and streaming constraints. +- [Testing and media helpers](docs/guides/testing-media.md) — in-memory protocol sessions and image/audio byte results. - [Configuration and compatibility](docs/guides/configuration.md) — Node, ESM/CommonJS, TypeScript decorators, schemas, and release boundaries. - [Agent integration guide](docs/guides/agent-integration.md) — evidence-first coding-agent workflow and explicit runtime boundaries. - [HTTP framework integration](docs/guides/http-and-nextjs.md) — published Streamable HTTP example and Fetch/Next.js route shape. diff --git a/docs/api/decorator-api.md b/docs/api/decorator-api.md index 35c2cf1..72fc06c 100644 --- a/docs/api/decorator-api.md +++ b/docs/api/decorator-api.md @@ -104,6 +104,22 @@ Decorated handlers may declare a final `McpInvocationContext` argument. Tools an See the [invocation context guide](../guides/invocation-context.md) for usage and HTTP streaming constraints. +## Testing subpath + +`createMcpTestSession(server, options?)` from `@theorvane/type-mcp/testing` connects an official SDK v2 `Client` and compiled `McpServer` with `InMemoryTransport`. + +| Case | Behavior | +| --- | --- | +| Accept | A compiled server or promise plus optional client implementation identity. | +| Runtime | Returns a frozen session containing `client`, `server`, and idempotent `close()`. Setup failure closes both endpoints before rethrowing the original error. | +| Excluded | Mocks, snapshots, network transport assertions, subprocesses, and application fixtures. | + +## Media helpers + +`McpImage(bytes, { mimeType, annotations? })` and `McpAudio(bytes, { mimeType, annotations? })` defensively copy bytes and metadata. Direct helpers and helper/string lists normalize to standard MCP content blocks. MIME types must belong to the corresponding `image/*` or `audio/*` family. Filesystem and network loading remain consumer-owned. + +See the [testing and media guide](../guides/testing-media.md). + ## Server construction ```ts diff --git a/docs/architecture/adr/0005-testing-media.md b/docs/architecture/adr/0005-testing-media.md new file mode 100644 index 0000000..0271639 --- /dev/null +++ b/docs/architecture/adr/0005-testing-media.md @@ -0,0 +1,19 @@ +# ADR 0005: Add protocol-backed testing and byte media helpers + +- **Status:** Accepted +- **Date:** 2026-08-27 +- **Issue:** [#182](https://github.com/Theorvane/type-mcp/issues/182) + +## Context + +FastMCP makes in-memory protocol testing and image/audio returns convenient. TypeMCP consumers currently repeat official SDK client wiring and manually construct base64 content blocks. Pulling filesystem access into the root package would weaken its framework-neutral, Fetch-compatible boundary. + +## Decision + +TypeMCP adds an isolated testing subpath that connects the official SDK v2 Client and McpServer through InMemoryTransport. It returns a session with explicit close ownership. + +The root exports immutable McpImage and McpAudio byte helpers. They require an explicit image/* or audio/* MIME type, defensively copy bytes and annotations, and produce standard SDK content blocks. Tool normalization converts direct helpers and mixed helper/string lists. + +## Consequences + +The testing entrypoint adds the split SDK client as a production dependency because packed consumers resolve it at runtime. Media helpers remain browser-neutral and do not read files. Filesystem loading, mocks, snapshots, network transports, and file-resource helpers remain consumer responsibilities. diff --git a/docs/guides/testing-media.md b/docs/guides/testing-media.md new file mode 100644 index 0000000..53c5f35 --- /dev/null +++ b/docs/guides/testing-media.md @@ -0,0 +1,54 @@ +# Testing and media helpers + +**Availability:** current `dev` source; not included in published `0.3.2`. + +## In-memory protocol tests + +Use `@theorvane/type-mcp/testing` to run a compiled server through the official SDK v2 protocol without opening a socket or subprocess. + +```ts +import { createMcpServer } from "@theorvane/type-mcp"; +import { createMcpTestSession } from "@theorvane/type-mcp/testing"; +import { CatalogServer } from "./catalog-server.js"; + +const session = await createMcpTestSession( + createMcpServer(CatalogServer), +); + +try { + const tools = await session.client.listTools(); + const result = await session.client.callTool({ + name: "findProduct", + arguments: { sku: "SKU-1" }, + }); +} finally { + await session.close(); +} +``` + +The helper connects the official `Client` and compiled SDK `McpServer` through `InMemoryTransport`. `close()` is idempotent and closes both sides. If connection setup fails, both sides are cleaned up before the original error is rethrown. Pass `{ client: { name, version } }` to override the test client identity. + +This is a protocol-backed integration helper, not a mock. Transport-specific HTTP/stdio behavior still needs transport tests. + +## Image and audio results + +`McpImage` and `McpAudio` accept bytes and an explicit MIME type. They defensively copy input bytes and optional annotations. + +```ts +import { McpAudio, McpImage } from "@theorvane/type-mcp"; + +return new McpImage(pngBytes, { + mimeType: "image/png", + annotations: { audience: ["user"] }, +}); + +return [ + new McpImage(firstPng, { mimeType: "image/png" }), + "Audio preview", + new McpAudio(wavBytes, { mimeType: "audio/wav" }), +]; +``` + +Direct helpers and lists containing only helpers and strings become standard MCP image, audio, and text content blocks. `McpImage` rejects non-`image/*` MIME types; `McpAudio` rejects non-`audio/*` types. + +The root package intentionally does not read file paths. Consumers that load media from disk, object storage, or the network own that I/O and pass the resulting `Uint8Array`. diff --git a/docs/planning/2026-08-27_issue-182-testing-media.md b/docs/planning/2026-08-27_issue-182-testing-media.md new file mode 100644 index 0000000..b8cb9e5 --- /dev/null +++ b/docs/planning/2026-08-27_issue-182-testing-media.md @@ -0,0 +1,45 @@ +# Task brief — testing and media helpers + +**Owner:** Codex + +**Date:** 2026-08-27 + +**Status:** complete + +**Related plan:** GitHub issue `#182` + +**Stacked base:** PR `#181` / `feat/180-invocation-context` until merged to `dev` + +## Objective + +Consumers can test compiled servers through the real in-memory MCP protocol and return image/audio bytes without hand-building base64 content. + +## Scope + +**In:** testing subpath, connected client/server session, deterministic cleanup, byte-only image/audio helpers, MIME validation, direct/list normalization, exports, packed-consumer and docs verification. + +**Out:** filesystem loading, file resources, mocks, snapshots, network transports, OAuth, providers, and MCP Apps. + +## Acceptance criteria + +- [x] A testing subpath connects a compiled server and official SDK client in memory. +- [x] Cleanup closes both client and server and failed connections are cleaned up. +- [x] Image/audio helpers validate MIME families and defensively copy inputs. +- [x] Direct and mixed-list helper returns become standard content blocks. +- [x] Existing result normalization remains compatible. +- [x] Full package verification passes. + +## Red → green evidence + +| Stage | Command | Result / expected reason | +| --- | --- | --- | +| Red: media | `npx vitest run test/media-helpers.test.ts` | Failed as expected because `McpImage` and `McpAudio` were not constructors. | +| Red: testing | `npx vitest run test/testing-helper.test.ts` | Failed as expected because `src/testing.js` did not exist. | +| Green | focused media/testing/result/package suites | Passed: 7 tests cover content conversion, validation, connection, idempotent and failed cleanup, compatibility, and the export map. | +| Regression | `npm test` | Passed from a clean `npm ci`: 33 files and 91 tests; lint, typecheck, build, package/publish, packed ESM/CJS consumers, production audit, and diff checks passed. | + +## Review handoff + +- Spec review: pending +- Quality review: pending +- Final checks: all required local checks passed diff --git a/docs/product/mvp-scope.md b/docs/product/mvp-scope.md index b6bebfb..91e920b 100644 --- a/docs/product/mvp-scope.md +++ b/docs/product/mvp-scope.md @@ -14,6 +14,8 @@ | Prompts | Published baseline: named zero-argument handlers; current `dev`: explicit validated arguments and completion | | Compilation | Decorator metadata compiled to the stable split `@modelcontextprotocol/server` v2 `McpServer` | | Invocation context | Current `dev`: request/session identity, SDK cancellation signal, and progress reporting | +| Testing | Current `dev`: official SDK v2 in-memory client/server session with explicit cleanup | +| Media | Current `dev`: byte-only image/audio helpers with explicit MIME validation | | Instance construction | Direct constructor default plus async-capable `InstanceResolver` interface | | Local transport | stdio helper | | Web transport | Fetch-standard Streamable HTTP handler | diff --git a/package-lock.json b/package-lock.json index 03deb73..7442f21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@hono/node-server": "2.1.1", + "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/server": "2.0.0", "zod": "^4.4.3" }, @@ -17,7 +18,6 @@ "@biomejs/biome": "^2.5.4", "@langchain/core": "1.2.9", "@langchain/langgraph": "1.4.9", - "@modelcontextprotocol/client": "2.0.0", "@types/node": "^26.2.0", "rimraf": "^6.0.1", "tsup": "^8.5.1", @@ -871,7 +871,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", - "dev": true, "license": "MIT", "dependencies": { "@modelcontextprotocol/core": "2.0.0", @@ -1834,7 +1833,6 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1933,7 +1931,6 @@ }, "node_modules/eventsource": { "version": "3.0.7", - "dev": true, "license": "MIT", "dependencies": { "eventsource-parser": "^3.0.1" @@ -1944,7 +1941,6 @@ }, "node_modules/eventsource-parser": { "version": "3.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">=18.0.0" @@ -2038,12 +2034,10 @@ }, "node_modules/isexe": { "version": "2.0.0", - "dev": true, "license": "ISC" }, "node_modules/jose": { "version": "6.2.3", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -2556,7 +2550,6 @@ }, "node_modules/path-key": { "version": "3.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2608,7 +2601,6 @@ }, "node_modules/pkce-challenge": { "version": "5.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">=16.20.0" @@ -2809,7 +2801,6 @@ }, "node_modules/shebang-command": { "version": "2.0.0", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -2820,7 +2811,6 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3200,7 +3190,6 @@ }, "node_modules/which": { "version": "2.0.2", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/package.json b/package.json index 7dcba4a..a128f17 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,16 @@ "default": "./dist/langchain.cjs" } }, + "./testing": { + "import": { + "types": "./dist/testing.d.ts", + "default": "./dist/testing.js" + }, + "require": { + "types": "./dist/testing.d.cts", + "default": "./dist/testing.cjs" + } + }, "./legacy": { "import": { "types": "./dist/legacy.d.ts", @@ -86,6 +96,7 @@ }, "dependencies": { "@hono/node-server": "2.1.1", + "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/server": "2.0.0", "zod": "^4.4.3" }, @@ -99,7 +110,6 @@ "@biomejs/biome": "^2.5.4", "@langchain/core": "1.2.9", "@langchain/langgraph": "1.4.9", - "@modelcontextprotocol/client": "2.0.0", "@types/node": "^26.2.0", "rimraf": "^6.0.1", "tsup": "^8.5.1", diff --git a/scripts/verify-compatibility-consumer.mjs b/scripts/verify-compatibility-consumer.mjs index 0d914ff..d5576c3 100644 --- a/scripts/verify-compatibility-consumer.mjs +++ b/scripts/verify-compatibility-consumer.mjs @@ -77,9 +77,10 @@ try { include: ["server.ts"], }, `import { z } from "zod"; -import { createMcpServer, getMcpServerDefinition, McpServer, McpTool } from "@theorvane/type-mcp"; +import { createMcpServer, getMcpServerDefinition, McpImage, McpServer, McpTool } from "@theorvane/type-mcp"; import { createMcpHandler } from "@theorvane/type-mcp/http"; import { createLangChainTools } from "@theorvane/type-mcp/langchain"; +import { createMcpTestSession } from "@theorvane/type-mcp/testing"; import { McpServer as LegacyMcpServer, McpTool as LegacyMcpTool } from "@theorvane/type-mcp/legacy"; @McpServer({ name: "standard-catalog", version: "1.0.0" }) @@ -92,6 +93,8 @@ if (getMcpServerDefinition(StandardCatalog)?.tools[0]?.name !== "find_product") if (typeof createMcpServer !== "function") throw new Error("ESM root export missing."); if (typeof createMcpHandler !== "function") throw new Error("ESM http export missing."); if (typeof createLangChainTools !== "function") throw new Error("ESM langchain export missing."); +if (typeof createMcpTestSession !== "function") throw new Error("ESM testing export missing."); +if (new McpImage(new Uint8Array([1]), { mimeType: "image/png" }).toContent().data !== "AQ==") throw new Error("ESM media export failed."); if (typeof LegacyMcpServer !== "function" || typeof LegacyMcpTool !== "function") throw new Error("ESM legacy export missing."); `, ); @@ -113,9 +116,10 @@ if (typeof LegacyMcpServer !== "function" || typeof LegacyMcpTool !== "function" include: ["server.ts"], }, `import { z } from "zod"; -import { createMcpServer } from "@theorvane/type-mcp"; +import { createMcpServer, McpImage } from "@theorvane/type-mcp"; import { createMcpHandler } from "@theorvane/type-mcp/http"; import { createLangChainTools } from "@theorvane/type-mcp/langchain"; +import { createMcpTestSession } from "@theorvane/type-mcp/testing"; import { getMcpServerDefinition, McpServer, McpTool } from "@theorvane/type-mcp/legacy"; @McpServer({ name: "legacy-catalog", version: "1.0.0" }) @@ -128,6 +132,8 @@ if (getMcpServerDefinition(LegacyCatalog)?.tools[0]?.name !== "find_product") th if (typeof createMcpServer !== "function") throw new Error("CJS root export missing."); if (typeof createMcpHandler !== "function") throw new Error("CJS http export missing."); if (typeof createLangChainTools !== "function") throw new Error("CJS langchain export missing."); +if (typeof createMcpTestSession !== "function") throw new Error("CJS testing export missing."); +if (new McpImage(new Uint8Array([1]), { mimeType: "image/png" }).toContent().data !== "AQ==") throw new Error("CJS media export failed."); `, ); diff --git a/scripts/verify-package-exports.mjs b/scripts/verify-package-exports.mjs index a8c6faf..2b8c8eb 100644 --- a/scripts/verify-package-exports.mjs +++ b/scripts/verify-package-exports.mjs @@ -25,6 +25,10 @@ const exportsToVerify = [ key: "./langchain", symbols: [{ name: "createLangChainTools", runtimeType: "function" }], }, + { + key: "./testing", + symbols: [{ name: "createMcpTestSession", runtimeType: "function" }], + }, { key: "./legacy", symbols: [ diff --git a/src/compiler/normalize-tool-result.ts b/src/compiler/normalize-tool-result.ts index 1b49baf..6284b46 100644 --- a/src/compiler/normalize-tool-result.ts +++ b/src/compiler/normalize-tool-result.ts @@ -2,6 +2,27 @@ import { type CallToolResult, specTypeSchemas, } from "@modelcontextprotocol/server"; +import { isMcpMedia } from "../media.js"; + +function normalizeMediaResult(result: unknown): CallToolResult | undefined { + if (isMcpMedia(result)) { + return { content: [result.toContent()] }; + } + if ( + Array.isArray(result) && + result.some(isMcpMedia) && + result.every((item) => typeof item === "string" || isMcpMedia(item)) + ) { + return { + content: result.map((item) => + typeof item === "string" + ? { type: "text" as const, text: item } + : item.toContent(), + ), + }; + } + return undefined; +} function isRecord(result: unknown): result is Record { return typeof result === "object" && result !== null; @@ -17,6 +38,11 @@ function isMcpToolResultCandidate(result: unknown): boolean { } export function normalizeToolResult(result: unknown): CallToolResult { + const mediaResult = normalizeMediaResult(result); + if (mediaResult !== undefined) { + return mediaResult; + } + if (typeof result === "string") { return { content: [{ type: "text", text: result }] }; } diff --git a/src/index.ts b/src/index.ts index 07db471..d6e10bf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,8 @@ export { McpServer } from "./decorators/mcp-server.js"; export { McpTool } from "./decorators/mcp-tool.js"; export { TypeMcpDefinitionError } from "./errors.js"; export type { McpInvocationContext } from "./invocation-context.js"; +export type { McpMediaOptions } from "./media.js"; +export { McpAudio, McpImage } from "./media.js"; export { getMcpServerDefinition } from "./metadata/definitions.js"; export { readMcpServerDefinition } from "./metadata/read-server-definition.js"; export { defaultInstanceResolver } from "./resolver/default-instance-resolver.js"; diff --git a/src/media.ts b/src/media.ts new file mode 100644 index 0000000..ae494fe --- /dev/null +++ b/src/media.ts @@ -0,0 +1,108 @@ +import type { + Annotations, + AudioContent, + ImageContent, +} from "@modelcontextprotocol/server"; + +export interface McpMediaOptions { + readonly mimeType: string; + readonly annotations?: Readonly | undefined; +} + +export class McpImage { + readonly #data: Uint8Array; + readonly #mimeType: string; + readonly #annotations: Readonly | undefined; + + public constructor(data: Uint8Array, options: McpMediaOptions) { + this.#data = copyBytes(data); + this.#mimeType = requireMimeType(options.mimeType, "image"); + this.#annotations = copyAnnotations(options.annotations); + Object.freeze(this); + } + + public toContent(): ImageContent { + return { + type: "image", + data: encodeBase64(this.#data), + mimeType: this.#mimeType, + ...(this.#annotations === undefined + ? {} + : { annotations: copyAnnotations(this.#annotations) }), + }; + } +} + +export class McpAudio { + readonly #data: Uint8Array; + readonly #mimeType: string; + readonly #annotations: Readonly | undefined; + + public constructor(data: Uint8Array, options: McpMediaOptions) { + this.#data = copyBytes(data); + this.#mimeType = requireMimeType(options.mimeType, "audio"); + this.#annotations = copyAnnotations(options.annotations); + Object.freeze(this); + } + + public toContent(): AudioContent { + return { + type: "audio", + data: encodeBase64(this.#data), + mimeType: this.#mimeType, + ...(this.#annotations === undefined + ? {} + : { annotations: copyAnnotations(this.#annotations) }), + }; + } +} + +export function isMcpMedia(value: unknown): value is McpImage | McpAudio { + return value instanceof McpImage || value instanceof McpAudio; +} + +function copyBytes(data: Uint8Array): Uint8Array { + return new Uint8Array(data); +} + +function copyAnnotations( + annotations: Readonly | undefined, +): Annotations | undefined { + if (annotations === undefined) { + return undefined; + } + return { + ...annotations, + ...(annotations.audience === undefined + ? {} + : { audience: [...annotations.audience] }), + }; +} + +function requireMimeType(mimeType: string, family: "image" | "audio"): string { + const pattern = + family === "image" + ? /^image\/[a-z0-9][a-z0-9.+-]*$/i + : /^audio\/[a-z0-9][a-z0-9.+-]*$/i; + if (!pattern.test(mimeType)) { + throw new TypeError(`MCP ${family} MIME type must start with ${family}/`); + } + return mimeType; +} + +function encodeBase64(data: Uint8Array): string { + const alphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let encoded = ""; + for (let index = 0; index < data.length; index += 3) { + const first = data[index] ?? 0; + const second = data[index + 1]; + const third = data[index + 2]; + const value = (first << 16) | ((second ?? 0) << 8) | (third ?? 0); + encoded += alphabet.charAt((value >> 18) & 63); + encoded += alphabet.charAt((value >> 12) & 63); + encoded += second === undefined ? "=" : alphabet.charAt((value >> 6) & 63); + encoded += third === undefined ? "=" : alphabet.charAt(value & 63); + } + return encoded; +} diff --git a/src/testing.ts b/src/testing.ts new file mode 100644 index 0000000..cdc0366 --- /dev/null +++ b/src/testing.ts @@ -0,0 +1,59 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { Implementation, McpServer } from "@modelcontextprotocol/server"; + +export interface McpTestSessionOptions { + readonly client?: Readonly | undefined; +} + +export interface McpTestSession { + readonly client: Client; + readonly server: McpServer; + close(): Promise; +} + +export async function createMcpTestSession( + serverValue: McpServer | Promise, + options: McpTestSessionOptions = {}, +): Promise { + const server = await serverValue; + const client = new Client( + options.client ?? { name: "type-mcp-test-client", version: "1.0.0" }, + ); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + + try { + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + } catch (error) { + await Promise.allSettled([client.close(), server.close()]); + throw error; + } + + let closed = false; + return Object.freeze({ + client, + server, + async close(): Promise { + if (closed) { + return; + } + closed = true; + const results = await Promise.allSettled([ + client.close(), + server.close(), + ]); + const failures = results + .filter( + (result): result is PromiseRejectedResult => + result.status === "rejected", + ) + .map((result) => result.reason); + if (failures.length > 0) { + throw new AggregateError(failures, "Failed to close MCP test session"); + } + }, + }); +} diff --git a/test/media-helpers.test.ts b/test/media-helpers.test.ts new file mode 100644 index 0000000..b737e57 --- /dev/null +++ b/test/media-helpers.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { normalizeToolResult } from "../src/compiler/normalize-tool-result.js"; +import { McpAudio, McpImage } from "../src/index.js"; + +describe("MCP media helpers", () => { + it("normalizes copied image and audio bytes into standard content", () => { + const imageBytes = new Uint8Array([137, 80, 78, 71]); + const image = new McpImage(imageBytes, { + mimeType: "image/png", + annotations: { audience: ["user"] }, + }); + imageBytes[0] = 0; + + const audio = new McpAudio(new Uint8Array([82, 73, 70, 70]), { + mimeType: "audio/wav", + }); + + expect(normalizeToolResult([image, "preview", audio])).toEqual({ + content: [ + { + type: "image", + data: "iVBORw==", + mimeType: "image/png", + annotations: { audience: ["user"] }, + }, + { type: "text", text: "preview" }, + { + type: "audio", + data: "UklGRg==", + mimeType: "audio/wav", + }, + ], + }); + expect(Object.isFrozen(image)).toBe(true); + }); + + it("rejects MIME types from the wrong media family", () => { + expect( + () => new McpImage(new Uint8Array([1]), { mimeType: "audio/wav" }), + ).toThrow(/image/); + expect( + () => new McpAudio(new Uint8Array([1]), { mimeType: "image/png" }), + ).toThrow(/audio/); + }); +}); diff --git a/test/production-dependency-security.test.ts b/test/production-dependency-security.test.ts index 9c0b932..4ac6e63 100644 --- a/test/production-dependency-security.test.ts +++ b/test/production-dependency-security.test.ts @@ -28,9 +28,12 @@ describe("production dependency security", () => { expect(manifest.dependencies?.["@modelcontextprotocol/server"]).toBe( "2.0.0", ); - expect(manifest.devDependencies?.["@modelcontextprotocol/client"]).toBe( + expect(manifest.dependencies?.["@modelcontextprotocol/client"]).toBe( "2.0.0", ); + expect( + manifest.devDependencies?.["@modelcontextprotocol/client"], + ).toBeUndefined(); expect( manifest.dependencies?.["@modelcontextprotocol/sdk"], ).toBeUndefined(); diff --git a/test/single-package-contract.test.ts b/test/single-package-contract.test.ts index 0928e0d..3ab0554 100644 --- a/test/single-package-contract.test.ts +++ b/test/single-package-contract.test.ts @@ -9,7 +9,7 @@ interface PackageManifest { } describe("type-mcp single-package contract", () => { - it("declares root and HTTP subpath exports from one package", async () => { + it("declares root and public subpath exports from one package", async () => { const manifest = JSON.parse( await readFile(new URL("../package.json", import.meta.url), "utf8"), ) as PackageManifest; @@ -17,6 +17,7 @@ describe("type-mcp single-package contract", () => { expect(manifest.name).toBe("@theorvane/type-mcp"); expect(manifest.exports).toHaveProperty("."); expect(manifest.exports).toHaveProperty("./http"); + expect(manifest.exports).toHaveProperty("./testing"); expect(createMcpServer).toBeTypeOf("function"); expect(createMcpHandler).toBeTypeOf("function"); }); diff --git a/test/testing-helper.test.ts b/test/testing-helper.test.ts new file mode 100644 index 0000000..9307c6c --- /dev/null +++ b/test/testing-helper.test.ts @@ -0,0 +1,76 @@ +import { McpServer as SdkMcpServer } from "@modelcontextprotocol/server"; +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { createMcpServer, McpServer, McpTool } from "../src/index.js"; +import { createMcpTestSession } from "../src/testing.js"; + +function methodContext( + name: string, + metadata: DecoratorMetadata, +): ClassMethodDecoratorContext { + return { + kind: "method", + name, + static: false, + private: false, + access: { has: () => true, get: () => () => undefined }, + metadata, + addInitializer: () => undefined, + }; +} + +function classContext(metadata: DecoratorMetadata): ClassDecoratorContext { + return { + kind: "class", + name: "TestServer", + metadata, + addInitializer: () => undefined, + }; +} + +describe("MCP testing helper", () => { + it("connects a real SDK client to a compiled server in memory", async () => { + class TestServer { + echo(input: { readonly value: string }): string { + return input.value; + } + } + const metadata: DecoratorMetadata = {}; + McpTool({ input: z.object({ value: z.string() }) })( + TestServer.prototype.echo, + methodContext("echo", metadata), + ); + McpServer({ name: "testing-helper", version: "1.0.0" })( + TestServer, + classContext(metadata), + ); + + const session = await createMcpTestSession(createMcpServer(TestServer)); + const listed = await session.client.listTools(); + const result = await session.client.callTool({ + name: "echo", + arguments: { value: "connected" }, + }); + + expect(listed.tools.map((tool) => tool.name)).toEqual(["echo"]); + expect(result).toMatchObject({ + content: [{ type: "text", text: "connected" }], + }); + + await session.close(); + await session.close(); + }); + + it("closes the server when an in-memory connection fails", async () => { + const server = new SdkMcpServer({ + name: "failed-test-server", + version: "1.0.0", + }); + const failure = new Error("connect failed"); + vi.spyOn(server, "connect").mockRejectedValue(failure); + const close = vi.spyOn(server, "close"); + + await expect(createMcpTestSession(server)).rejects.toBe(failure); + expect(close).toHaveBeenCalledOnce(); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 70b2751..7d41329 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ http: "src/http.ts", langchain: "src/langchain.ts", legacy: "src/legacy.ts", + testing: "src/testing.ts", }, format: ["esm", "cjs"], dts: true,