diff --git a/README.md b/README.md index 17e1d1d0..82bcf982 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ This project may be a poor fit if you: ## Features - Single `createApp()` entry point to define tools and UI once +- **API Versioning**: Expose multiple API versions from a single app (e.g., `/v1/mcp`, `/v2/mcp`) - Type-safe tool bindings with full TypeScript inference for inputs, outputs, and UI access - Protocol abstraction so UI code works identically on both platforms - OAuth 2.1 security with JWT validation and JWKS discovery (RFC 6750, RFC 8414) @@ -181,6 +182,70 @@ export type AppTools = typeof app.tools; export type AppClientTools = ClientToolsFromCore; ``` +### API Versioning + +Expose multiple API versions from a single application, each with its own tools and UI: + +```typescript +const app = createApp({ + name: "my-app", + + // Shared config across all versions + config: { + cors: { origin: true }, + oauth: { authorizationServer: "https://auth.example.com" }, + }, + + // Version-specific tools and config + versions: { + v1: { + version: "1.0.0", + tools: { + greet: defineTool({ + description: "Greet v1", + input: z.object({ name: z.string() }), + output: z.object({ message: z.string() }), + handler: async ({ name }) => ({ message: `Hello, ${name}!` }), + }), + }, + }, + v2: { + version: "2.0.0", + tools: { + greet: defineTool({ + description: "Greet v2", + input: z.object({ name: z.string(), surname: z.string().optional() }), + output: z.object({ message: z.string() }), + handler: async ({ name, surname }) => ({ + message: `Hello, ${name} ${surname || ""}!`.trim(), + }), + }), + }, + // Version-specific config overrides global config + config: { + protocol: "openai", + }, + }, + }, +}); + +// Access version info programmatically +console.log(app.getVersions()); // ["v1", "v2"] +const v2App = app.getVersion("v2"); + +// Each version is exposed at its dedicated route +// - v1: http://localhost:3000/v1/mcp +// - v2: http://localhost:3000/v2/mcp +``` + +Each version can have: + +- Different tools and tool schemas +- Version-specific UI components +- Config overrides (merged with global config) +- Version-specific plugins (merged with global plugins) +- Shared middleware and OAuth configuration + ### UI Setup (React) ```typescript @@ -455,7 +520,7 @@ npm run dev Local examples: -- [examples/minimal](examples/minimal/) - minimal server and UI widget +- [examples/minimal](examples/minimal/) - minimal server with API versioning (v1 and v2) - [examples/restaurant-finder](examples/restaurant-finder/) - end-to-end app with search functionality ## API diff --git a/examples/minimal/README.md b/examples/minimal/README.md index bd2ae677..1c8aa8c9 100644 --- a/examples/minimal/README.md +++ b/examples/minimal/README.md @@ -1,12 +1,15 @@ -# Minimal Example +# Minimal Example with Versioning -A simple "hello world" example demonstrating basic @mcp-apps-kit/core usage. +A simple example demonstrating @mcp-apps-kit/core versioning support - exposing multiple API versions from a single application. ## Features -- Single tool definition with Zod schema validation -- Simple UI widget showing greeting messages -- Basic server setup +- **API Versioning**: Two API versions exposed at different routes + - `v1`: Simple greet tool (name only) + - `v2`: Enhanced greet tool (name + optional surname) +- **Shared Configuration**: CORS, debug settings shared across versions +- **Type-Safe Tools**: Full TypeScript support for each version's tools +- **React UI Widgets**: Version-specific UI components ## Quick Start @@ -22,6 +25,30 @@ pnpm build pnpm start ``` +## API Endpoints + +Once running, the server exposes: + +| Endpoint | Description | +| -------------- | --------------------------- | +| `GET /health` | Health check | +| `POST /v1/mcp` | MCP v1 API (name only) | +| `POST /v2/mcp` | MCP v2 API (name + surname) | + +## Testing the API + +```bash +# v1: Greet with name only +curl -X POST http://localhost:3000/v1/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"greet","arguments":{"name":"World"}},"id":1}' + +# v2: Greet with name and surname +curl -X POST http://localhost:3000/v2/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"greet","arguments":{"name":"John","surname":"Doe"}},"id":1}' +``` + ## Connecting to Claude Desktop Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`): @@ -29,9 +56,11 @@ Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_ ```json { "mcpServers": { - "minimal-app": { - "command": "npx", - "args": ["tsx", "path/to/examples/minimal/src/index.ts"] + "minimal-app-v1": { + "url": "http://localhost:3000/v1/mcp" + }, + "minimal-app-v2": { + "url": "http://localhost:3000/v2/mcp" } } } @@ -42,18 +71,20 @@ Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_ ``` minimal/ src/ - index.ts # Server with tool definition + index.ts # Server with versioned app setup ui/ - index.html # Widget HTML entry - main.ts # Widget TypeScript + GreetingWidgetV1.tsx # V1 UI widget (name only) + GreetingWidgetV2.tsx # V2 UI widget (name + surname) + styles.css # Shared styles + dist/ # Built UI HTML files package.json tsconfig.json vite.config.ts ``` -## Tool +## Tools -### `greet` +### V1: `greet` Greet someone by name. @@ -65,3 +96,50 @@ Greet someone by name. - `message` (string): Greeting message - `timestamp` (string): ISO timestamp + +### V2: `greet` + +Greet someone by name and optional surname. + +**Input:** + +- `name` (string): First name to greet +- `surname` (string, optional): Surname + +**Output:** + +- `message` (string): Greeting message +- `fullName` (string): The full name used in greeting +- `timestamp` (string): ISO timestamp + +## Versioning Configuration + +The app uses the `createApp` versioning feature: + +```typescript +const app = createApp({ + name: "minimal-app", + + // Shared config across all versions + config: { + cors: { origin: true }, + debug: { logTool: true, level: "info" }, + }, + + // Version-specific tools and config + versions: { + v1: { + version: "1.0.0", + tools: { greet: greetToolV1 }, + }, + v2: { + version: "2.0.0", + tools: { greet: greetToolV2 }, + }, + }, +}); + +// Access version info programmatically +console.log(app.getVersions()); // ["v1", "v2"] +const v2App = app.getVersion("v2"); +``` diff --git a/examples/minimal/src/index.ts b/examples/minimal/src/index.ts index f6dcc0a6..cf0c5539 100644 --- a/examples/minimal/src/index.ts +++ b/examples/minimal/src/index.ts @@ -1,58 +1,50 @@ /** - * Minimal Example App + * Minimal Example App with Versioning Support * - * A simple "hello world" example demonstrating basic @mcp-apps-kit/core usage: - * - Simple tool definition with Zod schema - * - Colocated UI resource binding (UI defined alongside tool) - * - Server startup - * - Type-safe handlers using defineTool helper (no type assertions needed!) + * Demonstrates @mcp-apps-kit/core versioning feature: + * - v1: Simple greet tool with just name + * - v2: Enhanced greet tool with name + optional surname + * - Shared config (CORS, debug) across versions + * - Each version exposed at /v1/mcp and /v2/mcp */ import { createApp, defineTool, type ClientToolsFromCore } from "@mcp-apps-kit/core"; import { defineReactUI } from "@mcp-apps-kit/ui-react-builder"; -import { GreetingWidget } from "./ui/GreetingWidget"; +import { GreetingWidgetV1 } from "./ui/GreetingWidgetV1"; +import { GreetingWidgetV2 } from "./ui/GreetingWidgetV2"; import { z } from "zod"; -// Define schemas separately for clarity -const greetInput = z.object({ +// ============================================================================= +// V1: Simple greet tool (name only) +// ============================================================================= + +const greetInputV1 = z.object({ name: z.string().describe("Name to greet"), }); -const greetOutput = z.object({ +const greetOutputV1 = z.object({ message: z.string(), timestamp: z.string(), }); -// Use defineTool + defineUI for full type safety with colocated UI definition -// No external UI config needed - the UI is defined right where it's used! -const greetTool = defineTool({ +const greetToolV1 = defineTool({ title: "Greet", description: "Greet someone by name", - input: greetInput, - output: greetOutput, + input: greetInputV1, + output: greetOutputV1, visibility: "both", - // React component UI - the Vite plugin auto-discovers and builds this! ui: defineReactUI({ - component: GreetingWidget, - name: "Greeting Widget", - description: "Displays greeting messages", + component: GreetingWidgetV1, + name: "Greeting Widget V1", + description: "Displays greeting messages (v1 - name only)", prefersBorder: true, }), handler: async (input, context) => { - // input is automatically typed as { name: string } - // No type assertion needed! ✅ - - // With OAuth enabled: context.subject contains the authenticated user const userInfo = context.subject ? ` (authenticated as ${context.subject})` : ""; const message = `Hello, ${input.name}${userInfo}!`; - // Access full auth context when OAuth is enabled - // const auth = context.raw?.["mcp-apps-kit/auth"]; - // const scopes = auth?.scopes ?? []; - // const clientId = auth?.clientId; - return { message, timestamp: new Date().toISOString(), @@ -61,16 +53,57 @@ const greetTool = defineTool({ }, }); -const app = createApp({ - name: "minimal-app", - version: "1.0.0", +// ============================================================================= +// V2: Enhanced greet tool (name + optional surname) +// ============================================================================= + +const greetInputV2 = z.object({ + name: z.string().describe("First name to greet"), + surname: z.string().optional().describe("Optional surname"), +}); + +const greetOutputV2 = z.object({ + message: z.string(), + fullName: z.string(), + timestamp: z.string(), +}); + +const greetToolV2 = defineTool({ + title: "Greet", + description: "Greet someone by name and optional surname", + input: greetInputV2, + output: greetOutputV2, + visibility: "both", + + ui: defineReactUI({ + component: GreetingWidgetV2, + name: "Greeting Widget V2", + description: "Displays greeting messages (v2 - with surname support)", + prefersBorder: true, + }), - tools: { - greet: greetTool, + handler: async (input, context) => { + const fullName = input.surname ? `${input.name} ${input.surname}` : input.name; + const userInfo = context.subject ? ` (authenticated as ${context.subject})` : ""; + const message = `Hello, ${fullName}${userInfo}!`; + + return { + message, + fullName, + timestamp: new Date().toISOString(), + _text: message, + }; }, +}); - // No ui config needed - UI is colocated with the tool definition above! +// ============================================================================= +// Create Versioned App +// ============================================================================= +const app = createApp({ + name: "minimal-app", + + // Shared config across all versions config: { cors: { origin: true, @@ -80,66 +113,56 @@ const app = createApp({ logTool: true, level: "info", }, + }, - // OAuth 2.1 Configuration (Uncomment and configure with your values) - // oauth: { - // // Public URL of this MCP server (the Protected Resource) - // protectedResource: "http://localhost:3000", - - // // Issuer URL of your OAuth 2.1 Authorization Server - // // Replace with your actual Authorization Server URL - // // Examples: "https://accounts.google.com", "https://your-auth0-domain.auth0.com" - // authorizationServer: "https://auth.example.com", - - // // Optional: Required OAuth scopes for all requests - // // Tokens must contain ALL listed scopes - // scopes: ["mcp:read"], - - // // Optional: Explicit JWKS URI (auto-discovered if not provided) - // // jwksUri: "https://auth.example.com/.well-known/jwks.json", - - // // Optional: Allowed JWT signing algorithms (defaults to ["RS256"]) - // // algorithms: ["RS256", "RS384", "ES256"], - - // // Optional: Expected audience (defaults to protectedResource) - // // audience: "https://api.example.com", - - // // Optional: Custom token verification (for token introspection, non-JWT tokens) - // // tokenVerifier: { - // // async verifyAccessToken(token: string) { - // // const res = await fetch("https://auth.example.com/introspect", { - // // method: "POST", - // // body: new URLSearchParams({ token }), - // // }); - // // const data = await res.json(); - // // if (!data.active) throw new Error("Token inactive"); - // // return { - // // token, - // // clientId: data.client_id, - // // scopes: data.scope.split(" "), - // // expiresAt: data.exp, - // // extra: { subject: data.sub }, - // // }; - // // }, - // // }, - // }, + // Version definitions + versions: { + v1: { + version: "1.0.0", + tools: { + greet: greetToolV1, + }, + }, + v2: { + version: "2.0.0", + tools: { + greet: greetToolV2, + }, + config: { + protocol: "openai", + }, + }, }, }); const port = parseInt(process.env.PORT || "3000"); app.start({ port }).then(() => { + const versions = app.getVersions(); console.log(` -Minimal Example Server running on http://localhost:${port} -MCP endpoint: http://localhost:${port}/mcp -Health check: http://localhost:${port}/health +Minimal Example Server with Versioning running on http://localhost:${port} + +Available API versions: ${versions.join(", ")} + +Endpoints: + - v1 MCP: http://localhost:${port}/v1/mcp (name only) + - v2 MCP: http://localhost:${port}/v2/mcp (name + surname) + - Health: http://localhost:${port}/health `); }); +// ============================================================================= // Export types for UI -export type AppTools = typeof app.tools; -export type AppClientTools = ClientToolsFromCore; - -// Alternatively, export concrete types for better IDE support -export type GreetInput = z.infer; -export type GreetOutput = z.infer; +// ============================================================================= + +// V1 types +export type AppToolsV1 = { greet: typeof greetToolV1 }; +export type AppClientToolsV1 = ClientToolsFromCore; +export type GreetInputV1 = z.infer; +export type GreetOutputV1 = z.infer; + +// V2 types +export type AppToolsV2 = { greet: typeof greetToolV2 }; +export type AppClientToolsV2 = ClientToolsFromCore; +export type GreetInputV2 = z.infer; +export type GreetOutputV2 = z.infer; diff --git a/examples/minimal/src/ui/GreetingWidget.tsx b/examples/minimal/src/ui/GreetingWidgetV1.tsx similarity index 80% rename from examples/minimal/src/ui/GreetingWidget.tsx rename to examples/minimal/src/ui/GreetingWidgetV1.tsx index 098051cd..e7921cbe 100644 --- a/examples/minimal/src/ui/GreetingWidget.tsx +++ b/examples/minimal/src/ui/GreetingWidgetV1.tsx @@ -1,22 +1,19 @@ /** - * Greeting Widget Component + * Greeting Widget V1 Component * - * A React component that displays greeting messages from the greet tool. + * A React component for the v1 API - displays greeting messages with name only. * Uses @mcp-apps-kit/ui-react hooks for receiving tool output and theme changes. - * - * Demonstrates the typed tools proxy for calling tools with a more ergonomic API. */ import { useEffect, useState } from "react"; import { useToolResult, useHostContext, useAppsClient } from "@mcp-apps-kit/ui-react"; -import type { AppClientTools } from "../index"; +import type { AppClientToolsV1 } from "../index"; -export function GreetingWidget() { - const result = useToolResult(); +export function GreetingWidgetV1() { + const result = useToolResult(); const { theme } = useHostContext(); - const client = useAppsClient(); + const client = useAppsClient(); - // Modal state const [isModalOpen, setIsModalOpen] = useState(false); const [name, setName] = useState(""); const [isLoading, setIsLoading] = useState(false); @@ -25,11 +22,8 @@ export function GreetingWidget() { ); const [errorMessage, setErrorMessage] = useState(null); - // Extract the greet output - prioritize local state from UI-initiated calls, - // then fall back to host-pushed tool results const greetOutput = greetResult ?? result?.greet; - // Apply theme to document useEffect(() => { if (typeof document !== "undefined") { document.documentElement.className = theme; @@ -42,8 +36,6 @@ export function GreetingWidget() { setIsLoading(true); setErrorMessage(null); try { - // Using the typed tools proxy - more ergonomic than callTool! - // Instead of: client.callTool("greet", { name }) const response = await client.tools.callGreet({ name: name.trim() }); setGreetResult(response); setIsModalOpen(false); @@ -59,6 +51,8 @@ export function GreetingWidget() { return (
+
API v1
+ {greetOutput?.message ? (

{greetOutput.message}

@@ -104,4 +98,4 @@ export function GreetingWidget() { ); } -export default GreetingWidget; +export default GreetingWidgetV1; diff --git a/examples/minimal/src/ui/GreetingWidgetV2.tsx b/examples/minimal/src/ui/GreetingWidgetV2.tsx new file mode 100644 index 00000000..3964f0d9 --- /dev/null +++ b/examples/minimal/src/ui/GreetingWidgetV2.tsx @@ -0,0 +1,118 @@ +/** + * Greeting Widget V2 Component + * + * A React component for the v2 API - displays greeting messages with name + optional surname. + * Uses @mcp-apps-kit/ui-react hooks for receiving tool output and theme changes. + */ + +import { useEffect, useState } from "react"; +import { useToolResult, useHostContext, useAppsClient } from "@mcp-apps-kit/ui-react"; +import type { AppClientToolsV2 } from "../index"; + +export function GreetingWidgetV2() { + const result = useToolResult(); + const { theme } = useHostContext(); + const client = useAppsClient(); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [name, setName] = useState(""); + const [surname, setSurname] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [greetResult, setGreetResult] = useState<{ + message: string; + fullName: string; + timestamp: string; + } | null>(null); + const [errorMessage, setErrorMessage] = useState(null); + + const greetOutput = greetResult ?? result?.greet; + + useEffect(() => { + if (typeof document !== "undefined") { + document.documentElement.className = theme; + } + }, [theme]); + + const handleGreet = async () => { + if (!name.trim()) return; + + setIsLoading(true); + setErrorMessage(null); + try { + const response = await client.tools.callGreet({ + name: name.trim(), + surname: surname.trim() || undefined, + }); + setGreetResult(response); + setIsModalOpen(false); + setName(""); + setSurname(""); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error("Failed to greet:", msg); + setErrorMessage(msg); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
API v2
+ + {greetOutput?.message ? ( +
+

{greetOutput.message}

+

Full name: {greetOutput.fullName}

+

at {new Date(greetOutput.timestamp).toLocaleTimeString()}

+ +
+ ) : ( +
+

Waiting for greeting...

+ +
+ )} + + {isModalOpen && ( +
setIsModalOpen(false)}> +
e.stopPropagation()}> +

Enter your name

+
+ setName(e.target.value)} + placeholder="First name *" + autoFocus + onKeyDown={(e) => e.key === "Enter" && handleGreet()} + /> + setSurname(e.target.value)} + placeholder="Surname (optional)" + onKeyDown={(e) => e.key === "Enter" && handleGreet()} + /> +
+ {errorMessage &&

{errorMessage}

} +
+ + +
+
+
+ )} +
+ ); +} + +export default GreetingWidgetV2; diff --git a/examples/minimal/src/ui/styles.css b/examples/minimal/src/ui/styles.css index d68fdce3..436cb6c6 100644 --- a/examples/minimal/src/ui/styles.css +++ b/examples/minimal/src/ui/styles.css @@ -15,6 +15,27 @@ body { .container { max-width: 400px; margin: 0 auto; + position: relative; +} + +/* Version badge */ +.version-badge { + position: absolute; + top: 8px; + right: 8px; + background: rgba(255, 255, 255, 0.2); + color: white; + padding: 4px 10px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + backdrop-filter: blur(4px); + border: 1px solid rgba(255, 255, 255, 0.3); +} + +.version-badge.v2 { + background: rgba(0, 200, 100, 0.3); + border-color: rgba(0, 200, 100, 0.5); } .greeting { @@ -36,6 +57,12 @@ body { opacity: 0.8; } +.greeting .full-name { + font-size: 0.9rem; + opacity: 0.9; + margin-bottom: 4px; +} + .waiting { text-align: center; padding: 24px; @@ -106,6 +133,21 @@ body { box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2); } +.input-group { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 8px; +} + +.input-group input { + margin-bottom: 0; +} + +.input-group input::placeholder { + color: #999; +} + .modal-actions { display: flex; gap: 8px; diff --git a/packages/core/README.md b/packages/core/README.md index d953fed5..26bd97dd 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -30,6 +30,7 @@ Interactive MCP apps often need to support multiple hosts with slightly differen ## Features - Single `createApp()` entry point for tools and UI definitions +- **API Versioning**: Expose multiple API versions from a single app (e.g., `/v1/mcp`, `/v2/mcp`) - Zod-powered validation with strong TypeScript inference - Unified metadata for MCP Apps and ChatGPT Apps - OAuth 2.1 bearer token validation with JWKS discovery @@ -213,6 +214,222 @@ function Widget() { } ``` +## API Versioning + +Expose multiple API versions from a single application, each with its own tools, UI, and optional configuration overrides. + +### Basic Usage + +```ts +const app = createApp({ + name: "my-app", + + // Shared config across all versions + config: { + cors: { origin: true }, + debug: { logTool: true, level: "info" }, + }, + + // Version definitions + versions: { + v1: { + version: "1.0.0", + tools: { + greet: defineTool({ + description: "Greet v1", + input: z.object({ name: z.string() }), + output: z.object({ message: z.string() }), + handler: async ({ name }) => ({ message: `Hello, ${name}!` }), + }), + }, + }, + v2: { + version: "2.0.0", + tools: { + greet: defineTool({ + description: "Greet v2", + input: z.object({ name: z.string(), surname: z.string().optional() }), + output: z.object({ message: z.string() }), + handler: async ({ name, surname }) => ({ + message: `Hello, ${name} ${surname || ""}!`.trim(), + }), + }), + }, + }, + }, +}); + +await app.start({ port: 3000 }); + +// Each version is exposed at its dedicated route: +// - v1: http://localhost:3000/v1/mcp +// - v2: http://localhost:3000/v2/mcp +``` + +### Version-Specific Configuration + +Version-specific configs are merged with global config, with version-specific taking precedence: + +```ts +const app = createApp({ + name: "my-app", + config: { + cors: { origin: true }, + oauth: { authorizationServer: "https://auth.example.com" }, + }, + versions: { + v1: { + version: "1.0.0", + tools: { + /* ... */ + }, + // Uses global OAuth config + }, + v2: { + version: "2.0.0", + tools: { + /* ... */ + }, + config: { + // Override OAuth for v2 + oauth: { authorizationServer: "https://auth-v2.example.com" }, + // Override protocol + protocol: "openai", + }, + }, + }, +}); +``` + +### Version-Specific Plugins + +Plugins are merged: global plugins apply to all versions, version-specific plugins are added per version: + +```ts +const globalPlugin = createPlugin({ + name: "global-logger", + onInit: () => console.log("App initializing"), +}); + +const v2Plugin = createPlugin({ + name: "v2-analytics", + beforeToolCall: (context) => { + if (context.toolName === "greet") { + analytics.track("v2_greet_called"); + } + }, +}); + +const app = createApp({ + name: "my-app", + plugins: [globalPlugin], // Applied to all versions + versions: { + v1: { + version: "1.0.0", + tools: { + /* ... */ + }, + }, + v2: { + version: "2.0.0", + tools: { + /* ... */ + }, + plugins: [v2Plugin], // Only applied to v2 + }, + }, +}); +``` + +### Version-Specific Middleware + +Each version can have its own middleware chain: + +```ts +const app = createApp({ + name: "my-app", + versions: { + v1: { + version: "1.0.0", + tools: { + /* ... */ + }, + }, + v2: { + version: "2.0.0", + tools: { + /* ... */ + }, + }, + }, +}); + +// Add middleware to specific version +const v2App = app.getVersion("v2"); +v2App?.use(async (context, next) => { + console.log("v2 middleware"); + await next(); +}); +``` + +### Accessing Versions Programmatically + +```ts +// Get list of available version keys +const versions = app.getVersions(); // ["v1", "v2"] + +// Get a specific version app instance +const v1App = app.getVersion("v1"); +const v2App = app.getVersion("v2"); + +// Access version-specific tools, middleware, etc. +if (v2App) { + v2App.use(v2SpecificMiddleware); +} +``` + +### Version Key Requirements + +Version keys must match the pattern `/^v\d+$/` (e.g., `v1`, `v2`, `v10`): + +```ts +versions: { + v1: { /* ... */ }, // ✅ Valid + v2: { /* ... */ }, // ✅ Valid + v10: { /* ... */ }, // ✅ Valid + "v1.0": { /* ... */ }, // ❌ Invalid (must be v1, v2, etc.) + "beta": { /* ... */ }, // ❌ Invalid +} +``` + +### Shared Endpoints + +All versions share: + +- Health check: `GET /health` (returns all available versions) +- OpenAI domain verification: `GET /.well-known/openai-apps-challenge` (if configured) + +### Backward Compatibility + +Single-version apps continue to work as before: + +```ts +// Single-version (backward compatible) +const app = createApp({ + name: "my-app", + version: "1.0.0", + tools: { + /* ... */ + }, +}); + +// getVersions() returns empty array for single-version apps +app.getVersions(); // [] + +// getVersion() returns undefined for single-version apps +app.getVersion("v1"); // undefined +``` + ## Plugins, Middleware & Events ### Plugins @@ -461,9 +678,9 @@ const app = createApp({ ## Examples -- `../../examples/minimal` -- `../../examples/restaurant-finder` -- [kanban-mcp-example](https://github.com/AndurilCode/kanban-mcp-example) +- `../../examples/minimal` - demonstrates API versioning with v1 and v2 endpoints +- `../../examples/restaurant-finder` - end-to-end app with search functionality +- [kanban-mcp-example](https://github.com/AndurilCode/kanban-mcp-example) - full-featured kanban board example ## API diff --git a/packages/core/src/createApp.ts b/packages/core/src/createApp.ts index b5fa42e6..99972931 100644 --- a/packages/core/src/createApp.ts +++ b/packages/core/src/createApp.ts @@ -5,11 +5,18 @@ */ import type { ToolDefs, App, StartOptions, McpServer, ExpressMiddleware } from "./types/tools"; -import type { AppConfig, DebugConfig } from "./types/config"; +import type { + AppConfig, + AppConfigInput, + VersionsConfig, + VersionConfig, + GlobalConfig, + VersionSpecificConfig, +} from "./types/config"; import type { UIDef, UIDefs } from "./types/ui"; import type { Middleware } from "./middleware/types"; -import type { EventMap } from "./events/types"; -import { AppError, ErrorCode } from "./utils/errors"; +import type { EventMap, EventHandler } from "./events/types"; +import { AppError, ErrorCode, wrapError } from "./utils/errors"; import { createServerInstance, type ServerInstance } from "./server/index"; import { PluginManager } from "./plugins/PluginManager"; import { MiddlewareChain } from "./middleware/MiddlewareChain"; @@ -19,6 +26,9 @@ import { OAuthConfigSchema } from "./server/oauth/types.js"; import { getJwksUri } from "./server/oauth/discovery.js"; import { createJwksClient } from "./server/oauth/jwks-client.js"; import type { JwksClient } from "jwks-rsa"; +import express, { type Request as ExpressRequest, type Response as ExpressResponse } from "express"; +import http, { type Server } from "http"; +import type { Plugin } from "./plugins/types"; /** * Check if a value is a UIDef object (has required 'html' property) @@ -60,145 +70,402 @@ function extractColocatedUIs(tools: T): { uiDefs: UIDefs; no } /** - * Validate app configuration + * Check if config is a multi-version config */ -function validateConfig(config: unknown): asserts config is AppConfig { - if (typeof config !== "object" || config === null) { - throw new AppError(ErrorCode.INVALID_CONFIG, "Config must be an object"); - } +function isVersionsConfig( + config: AppConfigInput +): config is VersionsConfig { + return "versions" in config && typeof config.versions === "object"; +} - const cfg = config as Record; +/** + * Validate version key format (must match /^v\d+$/) + */ +function validateVersionKey(versionKey: string): void { + if (!/^v\d+$/.test(versionKey)) { + throw new AppError( + ErrorCode.INVALID_CONFIG, + `Version key must match pattern /^v\\d+$/, got: "${versionKey}"` + ); + } +} - if (typeof cfg.name !== "string" || cfg.name.length === 0) { +/** + * Validate a single version config + */ +function validateVersionConfig( + versionKey: string, + versionConfig: VersionConfig +): void { + if (typeof versionConfig.version !== "string" || versionConfig.version.length === 0) { throw new AppError( ErrorCode.INVALID_CONFIG, - "Config.name is required and must be a non-empty string" + `Version "${versionKey}".version is required and must be a non-empty string` ); } - if (typeof cfg.version !== "string" || cfg.version.length === 0) { + if (typeof versionConfig.tools !== "object" || versionConfig.tools === null) { throw new AppError( ErrorCode.INVALID_CONFIG, - "Config.version is required and must be a non-empty string" + `Version "${versionKey}".tools is required and must be an object` ); } - if (typeof cfg.tools !== "object" || cfg.tools === null) { - throw new AppError(ErrorCode.INVALID_CONFIG, "Config.tools is required and must be an object"); + // Validate version-specific config if provided + if (versionConfig.config) { + validateGlobalConfig(versionConfig.config, `Version "${versionKey}".config`); + } + + // Validate version-specific plugins if provided + if (versionConfig.plugins !== undefined) { + if (!Array.isArray(versionConfig.plugins)) { + throw new AppError( + ErrorCode.INVALID_CONFIG, + `Version "${versionKey}".plugins must be an array if provided` + ); + } } +} +/** + * Validate global config + * Accepts GlobalConfig, Partial, or VersionSpecificConfig (which allows null values) + */ +function validateGlobalConfig( + config: GlobalConfig | Partial | VersionSpecificConfig, + prefix = "Config" +): void { // Validate serverRoute if provided - const globalConfig = cfg.config as Record | undefined; - if (globalConfig?.serverRoute !== undefined) { - const serverRoute = globalConfig.serverRoute; + if (config.serverRoute !== undefined) { + const serverRoute = config.serverRoute; if (typeof serverRoute !== "string") { - throw new AppError(ErrorCode.INVALID_CONFIG, "Config.config.serverRoute must be a string"); + throw new AppError(ErrorCode.INVALID_CONFIG, `${prefix}.serverRoute must be a string`); } if (!serverRoute.startsWith("/")) { throw new AppError( ErrorCode.INVALID_CONFIG, - `Config.config.serverRoute must start with "/", got: "${serverRoute}"` + `${prefix}.serverRoute must start with "/", got: "${serverRoute}"` ); } if (serverRoute === "/health") { throw new AppError( ErrorCode.INVALID_CONFIG, - 'Config.config.serverRoute cannot be "/health" as it conflicts with the health check endpoint' + `${prefix}.serverRoute cannot be "/health" as it conflicts with the health check endpoint` ); } } - // Validate debug config if provided - if (globalConfig?.debug !== undefined) { - const debug = globalConfig.debug as DebugConfig; - if (typeof debug !== "object" || debug === null) { - throw new AppError(ErrorCode.INVALID_CONFIG, "Config.config.debug must be an object"); + // Validate debug config if provided (null is valid - means disable) + if (config.debug !== undefined && config.debug !== null) { + const debug = config.debug; + if (typeof debug !== "object") { + throw new AppError(ErrorCode.INVALID_CONFIG, `${prefix}.debug must be an object or null`); } - if (debug.logTool !== undefined && typeof debug.logTool !== "boolean") { + // Note: Nested null values (e.g., logTool: null) are valid for deep merge + // and will be handled by deepMerge to remove the property + if ( + debug.logTool !== undefined && + debug.logTool !== null && + typeof debug.logTool !== "boolean" + ) { throw new AppError( ErrorCode.INVALID_CONFIG, - "Config.config.debug.logTool must be a boolean if provided" + `${prefix}.debug.logTool must be a boolean if provided` ); } - if (debug.level !== undefined) { + if (debug.level !== undefined && debug.level !== null) { const validLevels = ["debug", "info", "warn", "error"]; if (!validLevels.includes(debug.level)) { throw new AppError( ErrorCode.INVALID_CONFIG, - `Config.config.debug.level must be one of: ${validLevels.join(", ")}` + `${prefix}.debug.level must be one of: ${validLevels.join(", ")}` ); } } - if (debug.batchSize !== undefined) { + if (debug.batchSize !== undefined && debug.batchSize !== null) { if (typeof debug.batchSize !== "number" || debug.batchSize < 1) { throw new AppError( ErrorCode.INVALID_CONFIG, - "Config.config.debug.batchSize must be a positive number" + `${prefix}.debug.batchSize must be a positive number` ); } } - if (debug.flushIntervalMs !== undefined) { + if (debug.flushIntervalMs !== undefined && debug.flushIntervalMs !== null) { if (typeof debug.flushIntervalMs !== "number" || debug.flushIntervalMs < 0) { throw new AppError( ErrorCode.INVALID_CONFIG, - "Config.config.debug.flushIntervalMs must be a non-negative number" + `${prefix}.debug.flushIntervalMs must be a non-negative number` ); } } } - // Validate OAuth config if provided - if (globalConfig?.oauth !== undefined) { + // Validate OAuth config if provided (null is valid - means disable) + if (config.oauth !== undefined && config.oauth !== null) { try { - OAuthConfigSchema.parse(globalConfig.oauth); + OAuthConfigSchema.parse(config.oauth); } catch (error) { if (error instanceof Error) { throw new AppError( ErrorCode.INVALID_CONFIG, - `Invalid OAuth configuration: ${error.message}` + `${prefix}.oauth: Invalid OAuth configuration: ${error.message}` ); } - throw new AppError(ErrorCode.INVALID_CONFIG, "Invalid OAuth configuration"); + throw new AppError(ErrorCode.INVALID_CONFIG, `${prefix}.oauth: Invalid OAuth configuration`); } } - // Validate OpenAI config if provided - if (globalConfig?.openai !== undefined) { - const openaiConfig = globalConfig.openai as Record; - if (typeof openaiConfig !== "object" || openaiConfig === null) { - throw new AppError(ErrorCode.INVALID_CONFIG, "Config.config.openai must be an object"); + // Validate OpenAI config if provided (null is valid - means disable) + if (config.openai !== undefined && config.openai !== null) { + const openaiConfig = config.openai as Record; + if (typeof openaiConfig !== "object") { + throw new AppError(ErrorCode.INVALID_CONFIG, `${prefix}.openai must be an object or null`); } if (openaiConfig.domain_challenge !== undefined) { const token = openaiConfig.domain_challenge; if (typeof token !== "string") { throw new AppError( ErrorCode.INVALID_CONFIG, - "Config.config.openai.domain_challenge must be a string" + `${prefix}.openai.domain_challenge must be a string` ); } if (token.length === 0) { throw new AppError( ErrorCode.INVALID_CONFIG, - "Config.config.openai.domain_challenge cannot be an empty string" + `${prefix}.openai.domain_challenge cannot be an empty string` ); } if (token.length > 1000) { throw new AppError( ErrorCode.INVALID_CONFIG, - "Config.config.openai.domain_challenge cannot exceed 1000 characters" + `${prefix}.openai.domain_challenge cannot exceed 1000 characters` ); } } } } +/** + * Validate app configuration (supports both single and multi-version) + */ +function validateConfig(config: unknown): asserts config is AppConfigInput { + if (typeof config !== "object" || config === null) { + throw new AppError(ErrorCode.INVALID_CONFIG, "Config must be an object"); + } + + const cfg = config as Record; + + if (typeof cfg.name !== "string" || cfg.name.length === 0) { + throw new AppError( + ErrorCode.INVALID_CONFIG, + "Config.name is required and must be a non-empty string" + ); + } + + // Check if this is a multi-version config + if (isVersionsConfig(cfg as unknown as AppConfigInput)) { + const versionsConfig = cfg as unknown as VersionsConfig; + + // Validate versions object + if (typeof versionsConfig.versions !== "object" || versionsConfig.versions === null) { + throw new AppError( + ErrorCode.INVALID_CONFIG, + "Config.versions is required and must be an object" + ); + } + + // Validate each version + const versionKeys = Object.keys(versionsConfig.versions); + if (versionKeys.length === 0) { + throw new AppError( + ErrorCode.INVALID_CONFIG, + "Config.versions must have at least one version" + ); + } + + for (const versionKey of versionKeys) { + validateVersionKey(versionKey); + const versionConfig = versionsConfig.versions[versionKey]; + if (!versionConfig) { + throw new AppError(ErrorCode.INVALID_CONFIG, `Version "${versionKey}" config is missing`); + } + validateVersionConfig(versionKey, versionConfig); + } + + // Validate global config if provided + if (versionsConfig.config) { + validateGlobalConfig(versionsConfig.config); + } + + // Validate global plugins if provided + if (versionsConfig.plugins !== undefined) { + if (!Array.isArray(versionsConfig.plugins)) { + throw new AppError(ErrorCode.INVALID_CONFIG, "Config.plugins must be an array if provided"); + } + } + } else { + // Single-version config (backward compatible) + if (typeof cfg.version !== "string" || cfg.version.length === 0) { + throw new AppError( + ErrorCode.INVALID_CONFIG, + "Config.version is required and must be a non-empty string" + ); + } + + if (typeof cfg.tools !== "object" || cfg.tools === null) { + throw new AppError( + ErrorCode.INVALID_CONFIG, + "Config.tools is required and must be an object" + ); + } + + // Validate global config if provided + const globalConfig = cfg.config as GlobalConfig | undefined; + if (globalConfig) { + validateGlobalConfig(globalConfig); + } + } +} + +/** + * Deep merge two objects. Version-specific values override global values. + * - null explicitly removes/disables the property + * - undefined inherits from global + * - Objects are recursively merged + * - Arrays and primitives are replaced + * + * @param global - Global config object + * @param versionSpecific - Version-specific config (overrides global) + * @returns Merged config or undefined if disabled + */ +function deepMerge>( + global: T | undefined, + versionSpecific: Partial | null | undefined +): T | undefined { + // null explicitly disables the config + if (versionSpecific === null) { + return undefined; + } + + // undefined inherits from global + if (versionSpecific === undefined) { + return global; + } + + // No global config, use version-specific + if (global === undefined) { + return versionSpecific as T; + } + + // Deep merge objects - build result without null properties + const result: Record = {}; + + // First, copy all global properties + for (const [key, value] of Object.entries(global)) { + result[key] = value; + } + + // Then, apply version-specific overrides + for (const [key, value] of Object.entries(versionSpecific)) { + if (value === null) { + // null removes the property - use Reflect.deleteProperty to satisfy ESLint + Reflect.deleteProperty(result, key); + } else if (value === undefined) { + // undefined keeps the global value (no change) + } else if ( + typeof value === "object" && + !Array.isArray(value) && + typeof result[key] === "object" && + !Array.isArray(result[key]) && + result[key] !== null + ) { + // Recursively merge nested objects + result[key] = deepMerge( + result[key] as Record, + value as Record + ); + } else { + // Replace arrays and primitives + result[key] = value; + } + } + + return result as T; +} + +/** + * Merge global config with version-specific config + * Version-specific config takes precedence over global config. + * + * Nested objects (oauth, cors, openai, debug, protocol) are deep-merged: + * - Specific properties override global properties + * - undefined inherits from global + * - null explicitly disables/removes the config + * - Arrays and primitives are replaced (not merged) + */ +function mergeVersionConfig( + globalConfig: GlobalConfig | undefined, + versionConfig: VersionConfig, + globalPlugins: Plugin[] | undefined +): AppConfig & { ui?: UIDefs } { + // Handle primitive config properties (null means remove, undefined means inherit) + const serverRoute = + versionConfig.config?.serverRoute === null + ? undefined + : (versionConfig.config?.serverRoute ?? globalConfig?.serverRoute); + + // Handle protocol (string literal, not an object - use simple override) + const protocol = + versionConfig.config?.protocol === null + ? undefined + : (versionConfig.config?.protocol ?? globalConfig?.protocol); + + // Deep merge nested config objects (null disables, undefined inherits) + // Type assertions needed because deepMerge returns Record + const mergedConfig: GlobalConfig = { + serverRoute, + protocol, + // Deep merge nested objects + oauth: deepMerge( + globalConfig?.oauth as Record | undefined, + versionConfig.config?.oauth as Record | null | undefined + ) as GlobalConfig["oauth"], + cors: deepMerge( + globalConfig?.cors as Record | undefined, + versionConfig.config?.cors as Record | null | undefined + ) as GlobalConfig["cors"], + openai: deepMerge( + globalConfig?.openai as Record | undefined, + versionConfig.config?.openai as Record | null | undefined + ) as GlobalConfig["openai"], + debug: deepMerge( + globalConfig?.debug as Record | undefined, + versionConfig.config?.debug as Record | null | undefined + ) as GlobalConfig["debug"], + }; + + // Merge plugins arrays (global + version-specific) + const mergedPlugins = [...(globalPlugins ?? []), ...(versionConfig.plugins ?? [])]; + + return { + name: "", // Will be set from global config + version: versionConfig.version, + tools: versionConfig.tools, + ui: versionConfig.ui, + config: mergedConfig, + plugins: mergedPlugins.length > 0 ? mergedPlugins : undefined, + }; +} + /** * Create an MCP app with unified tool and UI definitions * + * Supports both single-version (backward compatible) and multi-version configurations. + * * @param config - App configuration with tools and UI resources * @returns App instance for starting server or getting middleware * - * @example + * @example Single-version (backward compatible) * ```typescript * const app = createApp({ * name: "my-app", @@ -215,11 +482,44 @@ function validateConfig(config: unknown): asserts config is * * await app.start({ port: 3000 }); * ``` + * + * @example Multi-version + * ```typescript + * const app = createApp({ + * name: "my-app", + * config: { + * oauth: { authorizationServer: "https://auth.example.com" }, + * cors: { origin: true } + * }, + * versions: { + * v1: { + * version: "1.0.0", + * tools: { greet: {...} } + * }, + * v2: { + * version: "2.0.0", + * tools: { greet: {...}, search: {...} } + * } + * } + * }); + * ``` */ -export function createApp(config: AppConfig): App { +export function createApp(config: AppConfigInput): App { // Validate config at runtime validateConfig(config); + // Check if this is a multi-version config + if (isVersionsConfig(config)) { + return createMultiVersionApp(config); + } else { + return createSingleVersionApp(config); + } +} + +/** + * Create a single-version app (backward compatible) + */ +function createSingleVersionApp(config: AppConfig): App { // Extract colocated UIs from tool definitions for internal server processing const { uiDefs, normalizedTools } = extractColocatedUIs(config.tools); @@ -317,7 +617,8 @@ export function createApp(config: AppConfig): App { function getServerInstance(): ServerInstance { if (!serverInstance) { - serverInstance = createServerInstance(normalizedConfig, pluginManager, jwksClient); + // Pass a getter function for JWKS client to support lazy initialization + serverInstance = createServerInstance(normalizedConfig, pluginManager, () => jwksClient); // Attach middleware chain to server instance for tool execution serverInstance.setMiddlewareChain(middlewareChain); // Attach event emitter to server instance for event emission @@ -390,7 +691,7 @@ export function createApp(config: AppConfig): App { /** * Handle a single request (for serverless) */ - handleRequest: async (req: Request, env?: unknown): Promise => { + handleRequest: async (req: globalThis.Request, env?: unknown): Promise => { // Initialize OAuth lazily for serverless (idempotent) await ensureOAuthInitialized(); @@ -429,6 +730,20 @@ export function createApp(config: AppConfig): App { onAny: (handler) => { return eventEmitter.onAny(handler); }, + + /** + * Get a version-specific app instance (not available for single-version apps) + */ + getVersion: (_versionKey: string): App | undefined => { + return undefined; + }, + + /** + * Get list of available version keys (empty for single-version apps) + */ + getVersions: (): string[] => { + return []; + }, }; // Emit app:init event after app is created @@ -438,6 +753,476 @@ export function createApp(config: AppConfig): App { return app; } +/** + * Create a multi-version app + */ +function createMultiVersionApp(config: VersionsConfig): App { + // Shared Express app for all versions + const sharedExpressApp = express(); + sharedExpressApp.use(express.json()); + + // Map of version keys to their app instances + const versionApps = new Map>(); + + // Map of version keys to their server instances + const versionServerInstances = new Map(); + + // Shared HTTP server for multi-version apps (stored here for getServer() access) + let sharedHttpServer: Server | undefined; + + // Shared OAuth JWKS clients (keyed by OAuth config hash for reuse) + const jwksClients = new Map(); + const oauthInitPromises = new Map>(); + + // Configure debug logger if global debug config is provided + if (config.config?.debug) { + configureDebugLogger(config.config.debug); + } + + // Create app instance for each version + for (const [versionKey, versionConfig] of Object.entries(config.versions)) { + // Merge global and version-specific configs + const mergedConfig = mergeVersionConfig(config.config, versionConfig, config.plugins); + mergedConfig.name = config.name; // Set app name from global config + + // Extract colocated UIs from tool definitions + const { uiDefs, normalizedTools } = extractColocatedUIs(versionConfig.tools); + + // Create normalized config with extracted UIs + const normalizedVersionConfig: AppConfig & { ui?: UIDefs } = { + ...mergedConfig, + tools: normalizedTools, + ui: Object.keys(uiDefs).length > 0 ? uiDefs : undefined, + }; + + // Note: Debug logger is configured once with global config (line 681-683). + // We don't reconfigure it per-version because it's a global singleton. + // If version-specific debug configs are needed, they would require per-version + // logger instances, which is a larger architectural change. + + // Initialize version-specific plugin manager + const versionPluginManager = new PluginManager(normalizedVersionConfig.plugins ?? []); + let versionPluginInitialized = false; + + // Initialize version-specific middleware chain + const versionMiddlewareChain = new MiddlewareChain(); + + // Initialize version-specific event emitter + const versionEventEmitter = new TypedEventEmitter>(); + + // Create version-specific OAuth JWKS client key (for reuse if config is identical) + const oauthConfigKey = normalizedVersionConfig.config?.oauth + ? JSON.stringify(normalizedVersionConfig.config.oauth) + : "no-oauth"; + + // Get or create OAuth JWKS client + let versionJwksClient: JwksClient | null = null; + let versionOauthInitPromise: Promise | null = null; + + /** + * Ensure OAuth is initialized for this version (idempotent) + */ + async function ensureVersionOAuthInitialized(): Promise { + if (!normalizedVersionConfig.config?.oauth) { + return; + } + + // Reuse existing JWKS client if config is identical + const existingClient = jwksClients.get(oauthConfigKey); + if (existingClient) { + versionJwksClient = existingClient; + return; + } + + // If initialization is in progress for this config, wait for it + const existingPromise = oauthInitPromises.get(oauthConfigKey); + if (existingPromise) { + await existingPromise; + const clientAfterInit = jwksClients.get(oauthConfigKey); + if (clientAfterInit) { + versionJwksClient = clientAfterInit; + } + return; + } + + // Start initialization + versionOauthInitPromise = (async () => { + try { + const oauthConfig = normalizedVersionConfig.config?.oauth; + if (!oauthConfig) { + throw new AppError(ErrorCode.INVALID_CONFIG, "OAuth configuration is missing"); + } + + try { + const jwksUri = await getJwksUri(oauthConfig.authorizationServer, oauthConfig.jwksUri); + versionJwksClient = createJwksClient({ + jwksUri, + cacheMaxAge: 600000, + jwksRequestsPerMinute: 10, + timeout: 5000, + }); + jwksClients.set(oauthConfigKey, versionJwksClient); + + if (oauthConfig.tokenVerifier) { + debugLogger.info( + `OAuth enabled for ${versionKey} - Using custom token verifier with JWKS URI: ${jwksUri}` + ); + } else { + debugLogger.info(`OAuth enabled for ${versionKey} - JWKS URI: ${jwksUri}`); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error during JWKS discovery"; + throw new AppError( + ErrorCode.INVALID_CONFIG, + `OAuth initialization failed for ${versionKey}: ${errorMessage}. Please verify your authorization server URL and network connectivity.` + ); + } + } catch (error) { + oauthInitPromises.delete(oauthConfigKey); + throw error; + } + })(); + + oauthInitPromises.set(oauthConfigKey, versionOauthInitPromise); + await versionOauthInitPromise; + } + + // Create server instance for this version + // Pass a getter function for JWKS client to support lazy initialization + const versionRoute = `/${versionKey}/mcp`; + const versionServerInstance = createServerInstance( + normalizedVersionConfig, + versionPluginManager, + () => versionJwksClient, + versionRoute + ); + versionServerInstances.set(versionKey, versionServerInstance); + + // Attach middleware chain and event emitter to server instance + versionServerInstance.setMiddlewareChain(versionMiddlewareChain); + versionServerInstance.setEventEmitter(versionEventEmitter); + + // Mount version's Express app on shared Express app + // Each version server has its own Express app with its routes registered at serverRoute + // We mount the entire Express app, so routes registered on it will be accessible + // Note: Express strips the mount path, so routes registered at serverRoute on the version app + // will be accessible at versionRoute + serverRoute on the shared app + // Since serverRoute is the same as versionRoute for multi-version, this works correctly + sharedExpressApp.use(versionServerInstance.expressApp); + + // Create version-specific app instance + const versionApp: App = { + tools: versionConfig.tools, + + get expressApp() { + return sharedExpressApp; + }, + + start: async (options?: StartOptions): Promise => { + // Initialize plugins if not already done + if (!versionPluginInitialized) { + await versionPluginManager.init({ + config: normalizedVersionConfig, + tools: normalizedVersionConfig.tools, + }); + versionPluginInitialized = true; + } + + // Initialize OAuth if configured + await ensureVersionOAuthInitialized(); + + // For multi-version apps, version servers don't start their own HTTP servers + // They're mounted on the shared Express app which is started at the top level + // Just call plugin onStart hooks + await versionPluginManager.start({ + port: options?.port, + transport: options?.transport ?? "http", + }); + + // Emit version-specific app:start event + await versionEventEmitter.emit("app:start", { + port: options?.port, + transport: options?.transport ?? "http", + }); + }, + + getServer: (): McpServer => { + return versionServerInstance.mcpServer as unknown as McpServer; + }, + + handler: (): ExpressMiddleware => { + return versionServerInstance.handler(); + }, + + handleRequest: async ( + req: globalThis.Request, + env?: unknown + ): Promise => { + await ensureVersionOAuthInitialized(); + return versionServerInstance.handleRequest(req, env); + }, + + use: (middleware: Middleware) => { + versionMiddlewareChain.use(middleware); + }, + + on: (event, handler) => { + return versionEventEmitter.on(event, handler); + }, + + once: (event, handler) => { + return versionEventEmitter.once(event, handler); + }, + + onAny: (handler) => { + return versionEventEmitter.onAny(handler); + }, + + getVersion: (key: string): App | undefined => { + return versionApps.get(key); + }, + + getVersions: (): string[] => { + return Array.from(versionApps.keys()); + }, + }; + + versionApps.set(versionKey, versionApp); + + // Emit version-specific app:init event + void versionEventEmitter.emit("app:init", { config: normalizedVersionConfig }); + } + + // Add health check endpoint to shared Express app + sharedExpressApp.get("/health", (_req: ExpressRequest, res: ExpressResponse) => { + res.json({ status: "ok", name: config.name, versions: Array.from(versionApps.keys()) }); + }); + + // Add OpenAI domain verification challenge endpoint if configured + if (config.config?.openai?.domain_challenge) { + const challengeToken = config.config.openai.domain_challenge; + sharedExpressApp.get( + "/.well-known/openai-apps-challenge", + (_req: ExpressRequest, res: ExpressResponse) => { + res.type("text/plain").send(challengeToken); + } + ); + } + + // Add catch-all 404 handler for unmatched routes + sharedExpressApp.use((_req: ExpressRequest, res: ExpressResponse) => { + res.status(404).json({ error: "Not found" }); + }); + + // Create main app instance that delegates to version apps + const mainApp: App = { + // Use tools from first version (for type inference). + // To access a specific version's tools, use getVersion(key).tools + tools: (Object.values(config.versions)[0] as VersionConfig | undefined)?.tools as T, + + get expressApp() { + return sharedExpressApp; + }, + + start: async (options?: StartOptions): Promise => { + // Initialize all version plugins and OAuth + for (const [_versionKey, versionApp] of versionApps) { + await versionApp.start(options); + } + + // Start the shared HTTP server + const port = options?.port ?? 3000; + + return new Promise((resolve, reject) => { + try { + sharedHttpServer = http.createServer(sharedExpressApp); + sharedHttpServer.listen(port, () => { + // Attach HTTP server to all version ServerInstances for getServer().httpServer access + for (const serverInstance of versionServerInstances.values()) { + serverInstance.httpServer = sharedHttpServer; + } + resolve(); + }); + sharedHttpServer.on("error", reject); + } catch (error) { + reject(wrapError(error)); + } + }); + }, + + getServer: (): McpServer => { + // Return first version's ServerInstance (cast to McpServer for type compatibility) + // The ServerInstance has both mcpServer and httpServer properties + // httpServer is attached when start() is called + const firstVersion = Array.from(versionServerInstances.values())[0]; + if (!firstVersion) { + throw new Error("No server instance available"); + } + // Return the ServerInstance itself so httpServer is accessible + // Type assertion maintains backward compatibility with McpServer type + return firstVersion as unknown as McpServer; + }, + + handler: (): ExpressMiddleware => { + return (req: unknown, res: unknown, next: () => void) => { + sharedExpressApp(req as ExpressRequest, res as ExpressResponse, next); + }; + }, + + handleRequest: async (req: globalThis.Request, env?: unknown): Promise => { + // Route to appropriate version based on request path + const url = new URL(req.url); + + // Health check endpoint (shared across all versions) + if (url.pathname === "/health") { + return new globalThis.Response( + JSON.stringify({ + status: "ok", + name: config.name, + versions: Array.from(versionApps.keys()), + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + // OpenAI domain verification challenge endpoint (shared across all versions) + if ( + url.pathname === "/.well-known/openai-apps-challenge" && + config.config?.openai?.domain_challenge + ) { + return new globalThis.Response(config.config.openai.domain_challenge, { + status: 200, + headers: { "Content-Type": "text/plain" }, + }); + } + + // Route to version-specific MCP endpoints + const pathParts = url.pathname.split("/").filter(Boolean); + + if (pathParts.length >= 2 && pathParts[0]?.match(/^v\d+$/) && pathParts[1] === "mcp") { + const versionKey = pathParts[0]; + if (versionKey) { + const versionApp = versionApps.get(versionKey); + if (versionApp) { + return versionApp.handleRequest(req, env); + } + // Version key matches pattern but doesn't exist + return new globalThis.Response( + JSON.stringify({ + error: "Version not found", + message: `Version "${versionKey}" does not exist`, + availableVersions: Array.from(versionApps.keys()), + }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + } + ); + } + } + + // Return 404 for unmatched routes (consistent with Express path) + return new globalThis.Response(JSON.stringify({ error: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + }, + + use: (middleware: Middleware) => { + // Apply shared middleware to all versions + // Each version app has its own middleware chain, so we add the middleware to all of them + for (const versionApp of versionApps.values()) { + versionApp.use(middleware); + } + }, + + on: (event, handler) => { + // Subscribe to events on all versions + const unsubscribers: (() => void)[] = []; + for (const versionApp of versionApps.values()) { + unsubscribers.push(versionApp.on(event, handler)); + } + return () => { + for (const unsubscribe of unsubscribers) { + unsubscribe(); + } + }; + }, + + once: (event: K, handler: EventHandler) => { + // Shared wrapper that tracks if it has fired + let fired = false; + const unsubscribers: (() => void)[] = []; + let isUnsubscribed = false; + + // Create wrapper that fires only once across all versions + const wrapper: EventHandler = async (payload) => { + // Prevent execution if already unsubscribed or already fired + if (isUnsubscribed || fired) { + return; + } + + // Mark as fired BEFORE unsubscribing to prevent race conditions + fired = true; + + // Unsubscribe from all versions BEFORE calling handler to prevent memory leaks + // This ensures cleanup happens even if handler throws or if other versions + // fire events during handler execution + isUnsubscribed = true; + for (const unsubscribe of unsubscribers) { + unsubscribe(); + } + + // Call the original handler with received payload + await handler(payload); + }; + + // Register wrapper on each version app and collect unsubscribers + for (const versionApp of versionApps.values()) { + unsubscribers.push(versionApp.once(event, wrapper)); + } + + // Return single unsubscribe that clears all and prevents further wrapper calls + return () => { + if (isUnsubscribed) { + return; + } + isUnsubscribed = true; + for (const unsubscribe of unsubscribers) { + unsubscribe(); + } + }; + }, + + onAny: (handler) => { + // Subscribe to all events on all versions + const unsubscribers: (() => void)[] = []; + for (const versionApp of versionApps.values()) { + unsubscribers.push(versionApp.onAny(handler)); + } + return () => { + for (const unsubscribe of unsubscribers) { + unsubscribe(); + } + }; + }, + + getVersion: (versionKey: string): App | undefined => { + return versionApps.get(versionKey); + }, + + getVersions: (): string[] => { + return Array.from(versionApps.keys()); + }, + }; + + return mainApp; +} + // Re-export defineTool from types/tools for convenience export { defineTool } from "./types/tools"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6f8b4ebc..7e7ce779 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -58,6 +58,8 @@ export type { AppConfig, DebugConfig, DebugLogLevel, + VersionSpecificConfig, + DeepPartialWithNull, } from "./types/config"; // OAuth types diff --git a/packages/core/src/server/index.ts b/packages/core/src/server/index.ts index fc3a525d..f67b509d 100644 --- a/packages/core/src/server/index.ts +++ b/packages/core/src/server/index.ts @@ -77,7 +77,8 @@ type InternalAppConfig = AppConfig & { export function createServerInstance( config: InternalAppConfig, pluginManager: PluginManager, - jwksClient: JwksClient | null = null + jwksClient: JwksClient | null | (() => JwksClient | null) = null, + versionRoute?: string ): ServerInstance { // Create protocol adapter const adapter = createAdapter(config.config?.protocol ?? "mcp"); @@ -128,8 +129,9 @@ export function createServerInstance( let httpServer: Server | undefined; // Get configurable server route (default: "/mcp") + // If versionRoute is provided, use it; otherwise use config.serverRoute or default // Note: Validation is done in createApp's validateConfig function - const serverRoute = config.config?.serverRoute ?? "/mcp"; + const serverRoute = versionRoute ?? config.config?.serverRoute ?? "/mcp"; // Apply OAuth middleware if configured if (config.config?.oauth) { @@ -142,8 +144,11 @@ export function createServerInstance( audience: config.config.oauth.audience ?? protectedResourceUrl.href, }; - // Pass jwksClient even for custom verifiers (enables hybrid verification scenarios) - const oauthMiddleware = createOAuthMiddleware(oauthConfigWithAudience, jwksClient); + // Resolve JWKS client getter (supports lazy initialization) + const getJwksClient = typeof jwksClient === "function" ? jwksClient : () => jwksClient; + + // Pass jwksClient getter even for custom verifiers (enables hybrid verification scenarios) + const oauthMiddleware = createOAuthMiddleware(oauthConfigWithAudience, getJwksClient); expressApp.post(serverRoute, oauthMiddleware); } @@ -236,34 +241,41 @@ export function createServerInstance( res.status(405).json({ error: "DELETE not supported in stateless mode" }); }); - // Health check endpoint - expressApp.get("/health", (_req: Request, res: Response) => { - res.json({ status: "ok", name: config.name, version: config.version }); - }); + // Only add global endpoints when NOT a versioned server + // Versioned servers are mounted on a shared Express app that has its own global endpoints + const isVersionedServer = !!versionRoute; - // OpenAI domain verification challenge endpoint - if (config.config?.openai?.domain_challenge) { - const challengeToken = config.config.openai.domain_challenge; - expressApp.get("/.well-known/openai-apps-challenge", (_req: Request, res: Response) => { - res.type("text/plain").send(challengeToken); + if (!isVersionedServer) { + // Health check endpoint + expressApp.get("/health", (_req: Request, res: Response) => { + res.json({ status: "ok", name: config.name, version: config.version }); }); - } - // Catch-all 404 handler for unregistered routes - expressApp.use((_req: Request, res: Response) => { - res.status(404).json({ error: "Not found" }); - }); + // OpenAI domain verification challenge endpoint + if (config.config?.openai?.domain_challenge) { + const challengeToken = config.config.openai.domain_challenge; + expressApp.get("/.well-known/openai-apps-challenge", (_req: Request, res: Response) => { + res.type("text/plain").send(challengeToken); + }); + } - // Error handler middleware - expressApp.use((err: Error, _req: Request, res: Response, _next: () => void) => { - const appError = wrapError(err); - res.status(500).json({ - error: { - code: appError.code, - message: appError.message, - }, + // Catch-all 404 handler for unregistered routes + // Note: versioned servers don't add this - the shared Express app handles 404s + expressApp.use((_req: Request, res: Response) => { + res.status(404).json({ error: "Not found" }); }); - }); + + // Error handler middleware + expressApp.use((err: Error, _req: Request, res: Response, _next: () => void) => { + const appError = wrapError(err); + res.status(500).json({ + error: { + code: appError.code, + message: appError.message, + }, + }); + }); + } const instance: ServerInstance = { mcpServer, diff --git a/packages/core/src/server/oauth/middleware.ts b/packages/core/src/server/oauth/middleware.ts index cf27bbc0..957cda3a 100644 --- a/packages/core/src/server/oauth/middleware.ts +++ b/packages/core/src/server/oauth/middleware.ts @@ -154,7 +154,7 @@ function injectAuthContext(req: Request, authContext: AuthContext): void { * and injects authenticated context into the request. * * @param config - OAuth configuration - * @param jwksClient - JWKS client for JWT verification (null if custom verifier) + * @param jwksClient - JWKS client for JWT verification (null if custom verifier) or a getter function * @returns Express middleware function * * @example @@ -165,7 +165,7 @@ function injectAuthContext(req: Request, authContext: AuthContext): void { */ export function createOAuthMiddleware( config: OAuthConfig, - jwksClient: JwksClient | null + jwksClient: JwksClient | null | (() => JwksClient | null) ): (req: Request, res: Response, next: NextFunction) => Promise { return async (req: Request, res: Response, next: NextFunction): Promise => { try { @@ -178,13 +178,15 @@ export function createOAuthMiddleware( if (config.tokenVerifier) { validatedToken = await config.tokenVerifier.verifyAccessToken(token); } else { - if (!jwksClient) { + // Resolve JWKS client (supports lazy initialization via getter function) + const resolvedJwksClient = typeof jwksClient === "function" ? jwksClient() : jwksClient; + if (!resolvedJwksClient) { throw new OAuthError( ErrorCode.INVALID_REQUEST, "OAuth configuration error: JWKS client not initialized" ); } - validatedToken = await verifyJWT(token, config, jwksClient); + validatedToken = await verifyJWT(token, config, resolvedJwksClient); } // Validate scopes if required diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index 21e667bc..17c09c03 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -5,6 +5,7 @@ import type { ToolDefs } from "./tools"; import type { Plugin } from "../plugins/types"; import type { OAuthConfig } from "../server/oauth/types.js"; +import type { UIDefs } from "./ui"; // ============================================================================= // PROTOCOL CONFIGURATION @@ -237,12 +238,146 @@ export interface GlobalConfig { } /** - * Main application configuration + * Version-specific configuration for multi-version apps + * + * Each version has its own tools, UI, and optional config overrides. + * Global config from VersionsConfig is merged with version-specific config. * - * This is the input to `createApp()`. + * @typeParam T - The tool definitions type for type inference + */ +/** + * Deep partial type that allows null at any level to remove/disable properties. + * + * - undefined: inherit from global config + * - null: explicitly disable/remove the property + * - value: override the property + */ +export type DeepPartialWithNull = T extends object + ? { [P in keyof T]?: DeepPartialWithNull | null } + : T; + +/** + * Version-specific config type that allows null to disable inherited configs. + * + * - undefined: inherit from global config + * - null: explicitly disable/remove the config (at any nesting level) + * - object: deep-merge with global config + */ +export type VersionSpecificConfig = DeepPartialWithNull; + +export interface VersionConfig { + /** + * Semantic version for this API version. + * + * @example "1.0.0" + */ + version: string; + + /** + * Tool definitions for this version. + * Each key is the tool name, value is the tool definition. + */ + tools: T; + + /** + * UI resource definitions for this version. + */ + ui?: UIDefs; + + /** + * Optional configuration overrides for this version. + * Deep-merged with global config from VersionsConfig. + * + * - Properties set to undefined: inherit from global + * - Properties set to null: explicitly disable/remove + * - Properties set to objects: deep-merge with global + * + * @example + * ```typescript + * config: { + * debug: { level: "warn" }, // Override level, inherit other debug props + * oauth: null, // Disable OAuth for this version + * } + * ``` + */ + config?: VersionSpecificConfig; + + /** + * Optional version-specific plugins. + * Merged with global plugins from VersionsConfig. + */ + plugins?: Plugin[]; +} + +/** + * Multi-version application configuration + * + * Allows exposing multiple MCP server versions under dedicated routes (e.g., /v1/mcp, /v2/mcp). + * Each version has version-specific tools and UI, while optionally sharing global configuration. * * @example * ```typescript + * const config: VersionsConfig = { + * name: "my-app", + * config: { + * oauth: { authorizationServer: "https://auth.example.com" }, + * cors: { origin: true } + * }, + * plugins: [sharedPlugin], + * versions: { + * v1: { + * version: "1.0.0", + * tools: { greet: {...} } + * }, + * v2: { + * version: "2.0.0", + * tools: { greet: {...}, search: {...} }, + * config: { + * oauth: { authorizationServer: "https://auth-v2.example.com" } + * } + * } + * } + * }; + * ``` + * + * @typeParam T - The tool definitions type for type inference (union of all version tool types) + */ +export interface VersionsConfig { + /** + * App name. + * Used in MCP server registration and protocol metadata. + * + * Should be a valid npm package name format (lowercase, no spaces). + */ + name: string; + + /** + * Version definitions. + * Keys must match pattern `/^v\d+$/` (e.g., "v1", "v2"). + * Each version will be exposed at `/{versionKey}/mcp`. + */ + versions: Record>; + + /** + * Shared global configuration options. + * Merged with each version's config, with version-specific taking precedence. + */ + config?: GlobalConfig; + + /** + * Shared plugins. + * Merged with each version's plugins. + */ + plugins?: Plugin[]; +} + +/** + * Main application configuration + * + * This is the input to `createApp()`. Supports both single-version and multi-version formats. + * + * @example Single-version (backward compatible) + * ```typescript * const config: AppConfig = { * name: "my-app", * version: "1.0.0", @@ -305,3 +440,10 @@ export interface AppConfig { */ plugins?: Plugin[]; } + +/** + * Union type for createApp input - supports both single and multi-version configs + * + * @typeParam T - The tool definitions type for type inference + */ +export type AppConfigInput = AppConfig | VersionsConfig; diff --git a/packages/core/src/types/tools.ts b/packages/core/src/types/tools.ts index 937af321..3a6ae630 100644 --- a/packages/core/src/types/tools.ts +++ b/packages/core/src/types/tools.ts @@ -501,6 +501,46 @@ export interface App { * @returns Unsubscribe function */ onAny(handler: AnyEventHandler): UnsubscribeFn; + + // --------------------------------------------------------------------------- + // VERSIONING + // --------------------------------------------------------------------------- + + /** + * Get a version-specific app instance + * + * For multi-version apps, returns the app instance for the specified version. + * For single-version apps, returns undefined. + * + * @param versionKey - Version key (e.g., "v1", "v2") + * @returns App instance for the version, or undefined if not found + * + * @example + * ```typescript + * const v1App = app.getVersion("v1"); + * if (v1App) { + * v1App.use(v1SpecificMiddleware); + * } + * ``` + */ + getVersion(versionKey: string): App | undefined; + + /** + * Get list of available version keys + * + * For multi-version apps, returns array of version keys (e.g., ["v1", "v2"]). + * For single-version apps, returns empty array. + * + * @returns Array of version keys + * + * @example + * ```typescript + * const versions = app.getVersions(); + * // ["v1", "v2"] for multi-version apps + * // [] for single-version apps + * ``` + */ + getVersions(): string[]; } // ============================================================================= diff --git a/packages/core/tests/unit/versioning.test.ts b/packages/core/tests/unit/versioning.test.ts new file mode 100644 index 00000000..362f3b10 --- /dev/null +++ b/packages/core/tests/unit/versioning.test.ts @@ -0,0 +1,967 @@ +/** + * Unit tests for createApp versioning support + * + * Tests multi-version app creation, config merging, route isolation, + * and backward compatibility with single-version apps. + */ + +import { describe, it, expect, afterEach } from "vitest"; +import { z } from "zod"; +import { createApp, type AppConfigInput, type VersionsConfig } from "../../src/index"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +describe("createApp versioning", () => { + describe("multi-version app creation", () => { + it("should create a multi-version app with versions config", () => { + const app = createApp({ + name: "test-app", + versions: { + v1: { + version: "1.0.0", + tools: { + greet: { + description: "Greet v1", + input: z.object({ name: z.string() }), + output: z.object({ message: z.string() }), + handler: async ({ name }) => ({ message: `Hello, ${name}!` }), + }, + }, + }, + v2: { + version: "2.0.0", + tools: { + greet: { + description: "Greet v2", + input: z.object({ name: z.string(), surname: z.string().optional() }), + output: z.object({ message: z.string() }), + handler: async ({ name, surname }) => ({ + message: `Hello, ${name} ${surname || ""}!`.trim(), + }), + }, + }, + }, + }, + }); + + expect(app).toBeDefined(); + expect(app.getVersions).toBeDefined(); + expect(app.getVersion).toBeDefined(); + }); + + it("should return available version keys", () => { + const app = createApp({ + name: "test-app", + versions: { + v1: { + version: "1.0.0", + tools: {}, + }, + v2: { + version: "2.0.0", + tools: {}, + }, + v3: { + version: "3.0.0", + tools: {}, + }, + }, + }); + + const versions = app.getVersions(); + expect(versions).toEqual(["v1", "v2", "v3"]); + }); + + it("should return undefined for getVersions() in single-version mode", () => { + const app = createApp({ + name: "test-app", + version: "1.0.0", + tools: {}, + }); + + const versions = app.getVersions(); + expect(versions).toEqual([]); + }); + + it("should return undefined for getVersion() in single-version mode", () => { + const app = createApp({ + name: "test-app", + version: "1.0.0", + tools: {}, + }); + + const version = app.getVersion("v1"); + expect(version).toBeUndefined(); + }); + + it("should return version app for valid version key", () => { + const app = createApp({ + name: "test-app", + versions: { + v1: { + version: "1.0.0", + tools: { + tool1: { + description: "Tool 1", + input: z.object({}), + output: z.object({ result: z.string() }), + handler: async () => ({ result: "v1" }), + }, + }, + }, + v2: { + version: "2.0.0", + tools: { + tool2: { + description: "Tool 2", + input: z.object({}), + output: z.object({ result: z.string() }), + handler: async () => ({ result: "v2" }), + }, + }, + }, + }, + }); + + const v1App = app.getVersion("v1"); + const v2App = app.getVersion("v2"); + + expect(v1App).toBeDefined(); + expect(v2App).toBeDefined(); + expect(v1App?.tools.tool1).toBeDefined(); + expect(v2App?.tools.tool2).toBeDefined(); + expect(v1App?.tools.tool2).toBeUndefined(); + expect(v2App?.tools.tool1).toBeUndefined(); + }); + + it("should return undefined for invalid version key", () => { + const app = createApp({ + name: "test-app", + versions: { + v1: { + version: "1.0.0", + tools: {}, + }, + }, + }); + + const version = app.getVersion("v999"); + expect(version).toBeUndefined(); + }); + }); + + describe("version key validation", () => { + it("should reject invalid version keys", () => { + expect(() => + createApp({ + name: "test-app", + versions: { + // Testing runtime validation - "invalid" key fails pattern /^v\d+$/ + invalid: { + version: "1.0.0", + tools: {}, + }, + }, + }) + ).toThrow(/Version key must match pattern/); + }); + + it("should accept valid version keys (v1, v2, v10, etc.)", () => { + const app = createApp({ + name: "test-app", + versions: { + v1: { + version: "1.0.0", + tools: {}, + }, + v2: { + version: "2.0.0", + tools: {}, + }, + v10: { + version: "10.0.0", + tools: {}, + }, + }, + }); + + expect(app.getVersions()).toEqual(["v1", "v2", "v10"]); + }); + + it("should reject version keys that conflict with reserved routes", () => { + expect(() => + createApp({ + name: "test-app", + versions: { + v1: { + version: "1.0.0", + tools: {}, + config: { + // Testing runtime validation - "/health" conflicts with health endpoint + serverRoute: "/health", + }, + }, + }, + }) + ).toThrow(/conflicts with the health check endpoint/); + }); + }); + + describe("config merging", () => { + it("should merge global config with version-specific config", () => { + const app = createApp({ + name: "test-app", + config: { + cors: { + origin: true, + }, + debug: { + logTool: true, + level: "info", + }, + }, + versions: { + v1: { + version: "1.0.0", + tools: {}, + config: { + debug: { + logTool: false, + level: "warn", + }, + }, + }, + }, + }); + + const v1App = app.getVersion("v1"); + expect(v1App).toBeDefined(); + // Version-specific config should override global + // We can't directly access config, but we can verify behavior + }); + + it("should use global config when version-specific config is not provided", () => { + const app = createApp({ + name: "test-app", + config: { + cors: { + origin: "https://example.com", + }, + }, + versions: { + v1: { + version: "1.0.0", + tools: {}, + }, + }, + }); + + expect(app).toBeDefined(); + // Global config should be applied to v1 + }); + + it("should merge global plugins with version-specific plugins", () => { + const globalPlugin = { + name: "global-plugin", + onInit: () => {}, + }; + + const versionPlugin = { + name: "version-plugin", + onInit: () => {}, + }; + + const app = createApp({ + name: "test-app", + plugins: [globalPlugin], + versions: { + v1: { + version: "1.0.0", + tools: {}, + plugins: [versionPlugin], + }, + }, + }); + + expect(app).toBeDefined(); + // Both plugins should be registered for v1 + }); + }); + + describe("deep config merging", () => { + it("should deep merge nested config objects", () => { + const app = createApp({ + name: "test-app", + config: { + debug: { + logTool: true, + level: "info", + batchSize: 100, + }, + }, + versions: { + v1: { + version: "1.0.0", + tools: {}, + config: { + debug: { + level: "warn", // Only override level, inherit logTool and batchSize + }, + }, + }, + }, + }); + + expect(app).toBeDefined(); + // v1 should have debug config with: + // - logTool: true (inherited from global) + // - level: "warn" (overridden) + // - batchSize: 100 (inherited from global) + }); + + it("should allow null to disable nested config entirely", () => { + const app = createApp({ + name: "test-app", + config: { + debug: { + logTool: true, + level: "info", + }, + }, + versions: { + v1: { + version: "1.0.0", + tools: {}, + config: { + debug: null, // Explicitly disable debug for v1 + }, + }, + }, + }); + + expect(app).toBeDefined(); + // v1 should have debug disabled (undefined) + }); + + it("should use version-specific config when global is not provided", () => { + const app = createApp({ + name: "test-app", + versions: { + v1: { + version: "1.0.0", + tools: {}, + config: { + debug: { + logTool: true, + level: "debug", + }, + }, + }, + }, + }); + + expect(app).toBeDefined(); + // v1 should have its own debug config + }); + + it("should inherit global config when version config is undefined", () => { + const app = createApp({ + name: "test-app", + config: { + debug: { + logTool: true, + level: "info", + }, + }, + versions: { + v1: { + version: "1.0.0", + tools: {}, + // No config override - should inherit global debug config + }, + }, + }); + + expect(app).toBeDefined(); + // v1 should have global debug config + }); + + it("should replace arrays instead of merging them", () => { + const app = createApp({ + name: "test-app", + config: { + cors: { + origin: ["https://example.com", "https://app.example.com"], + credentials: true, + }, + }, + versions: { + v1: { + version: "1.0.0", + tools: {}, + config: { + cors: { + origin: ["https://v1.example.com"], // Replace array entirely + }, + }, + }, + }, + }); + + expect(app).toBeDefined(); + // v1 should have cors.origin = ["https://v1.example.com"] (replaced) + // v1 should have cors.credentials = true (inherited) + }); + + it("should allow null to remove specific nested properties", () => { + const app = createApp({ + name: "test-app", + config: { + debug: { + logTool: true, + level: "info", + batchSize: 50, + }, + }, + versions: { + v1: { + version: "1.0.0", + tools: {}, + config: { + debug: { + batchSize: null, // Remove batchSize property + }, + }, + }, + }, + }); + + expect(app).toBeDefined(); + // v1 should have debug with logTool and level but NOT batchSize + }); + }); + + describe("route isolation", () => { + it("should expose each version at its dedicated route", async () => { + const app = createApp({ + name: "test-app", + versions: { + v1: { + version: "1.0.0", + tools: { + tool1: { + description: "Tool 1", + input: z.object({}), + output: z.object({ result: z.string() }), + handler: async () => ({ result: "v1-result" }), + }, + }, + }, + v2: { + version: "2.0.0", + tools: { + tool2: { + description: "Tool 2", + input: z.object({}), + output: z.object({ result: z.string() }), + handler: async () => ({ result: "v2-result" }), + }, + }, + }, + }, + }); + + const port = 3100; + await app.start({ port }); + + // Test v1 endpoint + const transport1 = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/v1/mcp`) + ); + const client1 = new Client( + { + name: "test-client", + version: "1.0.0", + }, + { + capabilities: {}, + } + ); + await client1.connect(transport1); + const tools1 = await client1.listTools(); + await client1.close(); + + expect(tools1.tools.length).toBeGreaterThanOrEqual(1); + expect(tools1.tools.find((t) => t.name === "tool1")).toBeDefined(); + + // Test v2 endpoint + const transport2 = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/v2/mcp`) + ); + const client2 = new Client( + { + name: "test-client", + version: "1.0.0", + }, + { + capabilities: {}, + } + ); + await client2.connect(transport2); + const tools2 = await client2.listTools(); + await client2.close(); + + expect(tools2.tools.length).toBeGreaterThanOrEqual(1); + expect(tools2.tools.find((t) => t.name === "tool2")).toBeDefined(); + expect(tools2.tools.find((t) => t.name === "tool1")).toBeUndefined(); + + const httpServer = app.getServer().httpServer; + if (httpServer) { + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }); + } + }); + + it("should have shared health endpoint", async () => { + const app = createApp({ + name: "test-app", + versions: { + v1: { + version: "1.0.0", + tools: {}, + }, + v2: { + version: "2.0.0", + tools: {}, + }, + }, + }); + + const port = 3101; + await app.start({ port }); + + const response = await fetch(`http://localhost:${port}/health`); + const data = await response.json(); + + expect(data.status).toBe("ok"); + expect(data.name).toBe("test-app"); + expect(data.versions).toEqual(["v1", "v2"]); + + const httpServer = app.getServer().httpServer; + if (httpServer) { + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }); + } + }); + + it("should return 404 for non-existent version routes", async () => { + const app = createApp({ + name: "test-app", + versions: { + v1: { + version: "1.0.0", + tools: {}, + }, + }, + }); + + const port = 3102; + await app.start({ port }); + + const response = await fetch(`http://localhost:${port}/v999/mcp`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "tools/list", + params: {}, + id: 1, + }), + }); + + expect(response.status).toBe(404); + + const httpServer = app.getServer().httpServer; + if (httpServer) { + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }); + } + }); + }); + + describe("tool execution isolation", () => { + it("should execute tools independently per version", async () => { + const app = createApp({ + name: "test-app", + versions: { + v1: { + version: "1.0.0", + tools: { + add: { + description: "Add v1", + input: z.object({ a: z.number(), b: z.number() }), + output: z.object({ result: z.number() }), + handler: async ({ a, b }) => ({ result: a + b }), + }, + }, + }, + v2: { + version: "2.0.0", + tools: { + add: { + description: "Add v2", + input: z.object({ a: z.number(), b: z.number(), c: z.number().optional() }), + output: z.object({ result: z.number() }), + handler: async ({ a, b, c }) => ({ result: a + b + (c || 0) }), + }, + }, + }, + }, + }); + + const port = 3103; + await app.start({ port }); + + // Test v1 tool + const transport1 = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/v1/mcp`) + ); + const client1 = new Client( + { + name: "test-client", + version: "1.0.0", + }, + { + capabilities: {}, + } + ); + await client1.connect(transport1); + const result1 = await client1.callTool({ + name: "add", + arguments: { a: 1, b: 2 }, + }); + await client1.close(); + + expect(result1.content[0].text).toContain("3"); + + // Test v2 tool (with optional c parameter) + const transport2 = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/v2/mcp`) + ); + const client2 = new Client( + { + name: "test-client", + version: "1.0.0", + }, + { + capabilities: {}, + } + ); + await client2.connect(transport2); + const result2 = await client2.callTool({ + name: "add", + arguments: { a: 1, b: 2, c: 3 }, + }); + await client2.close(); + + expect(result2.content[0].text).toContain("6"); + + const httpServer = app.getServer().httpServer; + if (httpServer) { + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }); + } + }); + }); + + describe("backward compatibility", () => { + it("should support single-version config (backward compatible)", () => { + const app = createApp({ + name: "test-app", + version: "1.0.0", + tools: { + greet: { + description: "Greet", + input: z.object({ name: z.string() }), + output: z.object({ message: z.string() }), + handler: async ({ name }) => ({ message: `Hello, ${name}!` }), + }, + }, + }); + + expect(app).toBeDefined(); + expect(app.tools.greet).toBeDefined(); + expect(app.getVersions()).toEqual([]); + expect(app.getVersion("v1")).toBeUndefined(); + }); + + it("should work with single-version app.start()", async () => { + const app = createApp({ + name: "test-app", + version: "1.0.0", + tools: {}, + }); + + const port = 3104; + await app.start({ port }); + const server = app.getServer(); + expect(server).toBeDefined(); + + const httpServer = server.httpServer; + if (httpServer) { + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }); + } + }); + }); + + describe("shared Express app", () => { + it("should use the same Express app instance for all versions", () => { + const app = createApp({ + name: "test-app", + versions: { + v1: { + version: "1.0.0", + tools: {}, + }, + v2: { + version: "2.0.0", + tools: {}, + }, + }, + }); + + const v1App = app.getVersion("v1"); + const v2App = app.getVersion("v2"); + + expect(v1App?.expressApp).toBeDefined(); + expect(v2App?.expressApp).toBeDefined(); + // Both should reference the same Express app + expect(v1App?.expressApp).toBe(v2App?.expressApp); + expect(v1App?.expressApp).toBe(app.expressApp); + }); + }); + + describe("version-specific middleware", () => { + it("should allow version-specific middleware", async () => { + const v1MiddlewareCalled: boolean[] = []; + const v2MiddlewareCalled: boolean[] = []; + + const app = createApp({ + name: "test-app", + versions: { + v1: { + version: "1.0.0", + tools: { + test: { + description: "Test", + input: z.object({}), + output: z.object({ result: z.string() }), + handler: async () => ({ result: "v1" }), + }, + }, + }, + v2: { + version: "2.0.0", + tools: { + test: { + description: "Test", + input: z.object({}), + output: z.object({ result: z.string() }), + handler: async () => ({ result: "v2" }), + }, + }, + }, + }, + }); + + const v1App = app.getVersion("v1"); + const v2App = app.getVersion("v2"); + + v1App?.use(async (ctx, next) => { + v1MiddlewareCalled.push(true); + await next(); + }); + + v2App?.use(async (ctx, next) => { + v2MiddlewareCalled.push(true); + await next(); + }); + + const port = 3105; + await app.start({ port }); + + // Call v1 tool + const transport1 = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/v1/mcp`) + ); + const client1 = new Client( + { + name: "test-client", + version: "1.0.0", + }, + { + capabilities: {}, + } + ); + await client1.connect(transport1); + await client1.callTool({ name: "test", arguments: {} }); + await client1.close(); + + // Call v2 tool + const transport2 = new StreamableHTTPClientTransport( + new URL(`http://localhost:${port}/v2/mcp`) + ); + const client2 = new Client( + { + name: "test-client", + version: "1.0.0", + }, + { + capabilities: {}, + } + ); + await client2.connect(transport2); + await client2.callTool({ name: "test", arguments: {} }); + await client2.close(); + + expect(v1MiddlewareCalled.length).toBeGreaterThan(0); + expect(v2MiddlewareCalled.length).toBeGreaterThan(0); + + const httpServer = app.getServer().httpServer; + if (httpServer) { + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }); + } + }); + }); + + describe("handleRequest for serverless deployments", () => { + it("should handle /health endpoint via handleRequest", async () => { + const app = createApp({ + name: "test-app", + versions: { + v1: { + version: "1.0.0", + tools: {}, + }, + v2: { + version: "2.0.0", + tools: {}, + }, + }, + }); + + const request = new Request("http://localhost/health"); + const response = await app.handleRequest(request); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.status).toBe("ok"); + expect(data.name).toBe("test-app"); + expect(data.versions).toEqual(["v1", "v2"]); + }); + + it("should handle /.well-known/openai-apps-challenge via handleRequest", async () => { + const challengeToken = "test-challenge-token-123"; + const app = createApp({ + name: "test-app", + config: { + openai: { + domain_challenge: challengeToken, + }, + }, + versions: { + v1: { + version: "1.0.0", + tools: {}, + }, + }, + }); + + const request = new Request("http://localhost/.well-known/openai-apps-challenge"); + const response = await app.handleRequest(request); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("text/plain"); + const text = await response.text(); + expect(text).toBe(challengeToken); + }); + + it("should return 404 for /.well-known/openai-apps-challenge when not configured", async () => { + const app = createApp({ + name: "test-app", + versions: { + v1: { + version: "1.0.0", + tools: {}, + }, + }, + }); + + const request = new Request("http://localhost/.well-known/openai-apps-challenge"); + const response = await app.handleRequest(request); + + expect(response.status).toBe(404); + }); + + it("should return 404 for unmatched routes via handleRequest", async () => { + const app = createApp({ + name: "test-app", + versions: { + v1: { + version: "1.0.0", + tools: {}, + }, + }, + }); + + const request = new Request("http://localhost/unknown-route"); + const response = await app.handleRequest(request); + + expect(response.status).toBe(404); + const data = await response.json(); + expect(data.error).toBe("Not found"); + }); + + it("should return 404 with available versions for non-existent version via handleRequest", async () => { + const app = createApp({ + name: "test-app", + versions: { + v1: { + version: "1.0.0", + tools: {}, + }, + v2: { + version: "2.0.0", + tools: {}, + }, + }, + }); + + const request = new Request("http://localhost/v999/mcp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "tools/list", + params: {}, + id: 1, + }), + }); + const response = await app.handleRequest(request); + + expect(response.status).toBe(404); + const data = await response.json(); + expect(data.error).toBe("Version not found"); + expect(data.message).toContain("v999"); + expect(data.availableVersions).toEqual(["v1", "v2"]); + }); + }); +});