Server-side framework for building MCP applications.
MCP AppsKit Core is the server runtime for defining tools, validating inputs and outputs with Zod, and binding UI resources. It targets both MCP Apps and ChatGPT (OpenAI Apps SDK) from the same definitions.
- Background
- Features
- Compatibility
- Install
- Usage
- Type-Safe Tool Definitions
- API Versioning
- Workflow Engine
- Plugins, Middleware & Events
- Debug Logging
- OAuth 2.1 Authentication
- OpenAI Domain Verification
- Examples
- API
- Contributing
- License
Interactive MCP apps often need to support multiple hosts with slightly different APIs and metadata rules. Core provides a single server-side API for tools, metadata, and UI resources so you can support MCP Apps and ChatGPT Apps without parallel codebases.
- 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) - Workflow Engine: Compose multi-step workflows with parallel execution, branching, and retry policies
- Zod-powered validation with strong TypeScript inference
- Unified metadata for MCP Apps and ChatGPT Apps
- OAuth 2.1 bearer token validation with JWKS discovery
- Plugins, middleware, and events for cross-cutting concerns
- Optional debug logging tool for client-to-server logs
- Node.js:
>= 18 - Zod:
^4.0.0(peer dependency) - MCP SDK: uses
@modelcontextprotocol/sdk
npm install @mcp-apps-kit/core zodimport { createApp, tool } from "@mcp-apps-kit/core";
import { z } from "zod";
const app = createApp({
name: "my-app",
version: "1.0.0",
tools: {
greet: tool("greet")
.describe("Greet a user")
.input({
name: z.string().describe("Name to greet"),
})
.handle(async ({ name }) => {
return { message: `Hello, ${name}!` };
})
.build(),
},
});
await app.start({ port: 3000 });Tools can include a UI definition for displaying results. Use defineUI for type-safe UI definitions, then reference it from your tool. Return UI-only payloads in _meta.
import { createApp, defineTool, defineUI } from "@mcp-apps-kit/core";
import { z } from "zod";
// Define UI widget for displaying restaurant list
const restaurantListUI = defineUI({
name: "Restaurant List",
description: "Displays restaurant search results",
html: "./dist/widget.html",
prefersBorder: true,
autoResize: true, // Enable automatic size notifications (default: true, MCP Apps only)
});
const app = createApp({
name: "restaurant-finder",
version: "1.0.0",
tools: {
search_restaurants: defineTool({
description: "Search for restaurants by location",
input: z.object({ location: z.string() }),
output: z.object({ count: z.number() }),
handler: async ({ location }) => {
const restaurants = await fetchRestaurants(location);
return {
count: restaurants.length,
_meta: { restaurants },
};
},
ui: restaurantListUI,
}),
},
});Core provides two ways to define tools: the Fluent Builder API (recommended) and the Legacy defineTool Helper. Both ensure full type inference for Zod schemas.
The tool() factory provides a progressive, discoverable API for defining tools. It guides you through the definition process with type safety at every step.
import { tool } from "@mcp-apps-kit/core";
tools: {
search: tool("search")
.describe("Search database")
.input({
query: z.string(),
limit: z.number().optional(),
})
.handle(async ({ query, limit }) => {
// Types inferred automatically
return { results: await db.search(query, limit) };
})
.build(),
}- Progressive Discovery: Chain methods to see available options (
describe,input,handle). - Shortcuts: Use
.readOnly(),.destructive(),.expensive()for common annotations. - UI Attachment: Attach UI definitions directly with
.ui(). - Validation: Ensures all required steps (description, input, handler) are completed.
tool("deleteUser")
.describe("Delete a user account")
.input({ userId: z.string() })
.destructive() // Adds warning annotation
.handle(async ({ userId }) => {
/* ... */
})
.build();Use defineTool to get automatic type inference in your handlers with a configuration object:
import { defineTool } from "@mcp-apps-kit/core";
tools: {
search: defineTool({
input: z.object({
query: z.string(),
maxResults: z.number().optional(),
}),
handler: async (input) => {
return { results: await search(input.query, input.maxResults) };
},
}),
}Why helpers? With Zod v4, TypeScript cannot infer concrete schema types across module boundaries when using generic z.ZodType. Both tool() and defineTool capture specific schema types at the call site enabling proper inference.
const searchInput = z.object({
query: z.string(),
maxResults: z.number().optional(),
});
const app = createApp({
tools: {
search: {
input: searchInput,
handler: async (input) => {
const typed = input as z.infer<typeof searchInput>;
return { results: await search(typed.query, typed.maxResults) };
},
},
},
});Export types from your server to use in UI code for fully typed tool results:
// server/index.ts
import { createApp, defineTool, type ClientToolsFromCore } from "@mcp-apps-kit/core";
import { z } from "zod";
const app = createApp({
name: "my-app",
version: "1.0.0",
tools: {
greet: defineTool({
description: "Greet a user",
input: z.object({ name: z.string() }),
output: z.object({ message: z.string(), timestamp: z.string() }),
handler: async (input) => ({
message: `Hello, ${input.name}!`,
timestamp: new Date().toISOString(),
}),
}),
},
});
// Export types for UI code
export type AppTools = typeof app.tools;
export type AppClientTools = ClientToolsFromCore<AppTools>;Then in your UI code, use the exported types with React hooks:
// ui/Widget.tsx
import { useToolResult } from "@mcp-apps-kit/ui-react";
import type { AppClientTools } from "../server";
function Widget() {
// Fully typed: result?.greet?.message is typed as string | undefined
const result = useToolResult<AppClientTools>();
if (result?.greet) {
return (
<p>
{result.greet.message} at {result.greet.timestamp}
</p>
);
}
return <p>Waiting for greeting...</p>;
}Expose multiple API versions from a single application, each with its own tools, UI, and optional configuration overrides.
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/mcpVersion-specific configs are merged with global config, with version-specific taking precedence:
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",
},
},
},
});Plugins are merged: global plugins apply to all versions, version-specific plugins are added per version:
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
},
},
});Each version can have its own middleware chain:
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();
});// 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 keys must match the pattern /^v\d+$/ (e.g., v1, v2, v10):
versions: {
v1: { /* ... */ }, // ✅ Valid
v2: { /* ... */ }, // ✅ Valid
v10: { /* ... */ }, // ✅ Valid
"v1.0": { /* ... */ }, // ❌ Invalid (must be v1, v2, etc.)
"beta": { /* ... */ }, // ❌ Invalid
}All versions share:
- Health check:
GET /health(returns all available versions) - OpenAI domain verification:
GET /.well-known/openai-apps-challenge(if configured)
Single-version apps continue to work as before:
// 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"); // undefinedThe workflow engine enables composing multi-step workflows as MCP tools. Workflows support tool calls, custom logic, parallel execution, conditional branching, and configurable error handling.
import { workflow, toolStep, customStep } from "@mcp-apps-kit/core";
import { z } from "zod";
const orderWorkflow = workflow("process_order")
.describe("Process a customer order end-to-end")
.input({
orderId: z.string(),
customerId: z.string(),
})
.output({
success: z.boolean(),
receiptId: z.string().optional(),
})
.step("validate", toolStep("validate_order"))
.step("payment", toolStep("process_payment"))
.step(
"fulfill",
customStep(async (ctx) => {
// Access previous step outputs
const paymentResult = ctx.outputs.payment;
return { success: true, receiptId: paymentResult.receiptId };
})
)
.build();
// Register as a tool
const app = createApp({
name: "order-service",
tools: { process_order: orderWorkflow },
});Call other registered tools in the same app:
.step("validate", toolStep("validate_order"))
// With input mapping
.step("payment", toolStep("process_payment", {
mapInput: (ctx) => ({
amount: ctx.outputs.validate.total,
customerId: ctx.input.customerId,
}),
}))Execute arbitrary async logic:
.step("enrich", customStep(async (ctx) => {
const userData = await fetchUserData(ctx.input.userId);
return { ...ctx.input, userData };
}))Call tools on external MCP servers:
import { externalStep } from "@mcp-apps-kit/core";
.step("weather", externalStep({
server: "mcp://weather-service",
tool: "get_forecast",
mapInput: (ctx) => ({
location: ctx.input.destination,
date: ctx.input.date,
}),
}))Run multiple steps concurrently:
.parallel("notifications", [
toolStep("send_email"),
toolStep("send_sms"),
toolStep("log_event"),
])Execute different paths based on runtime conditions:
.branch("shipping_method", {
when: (ctx) => ctx.outputs.validate.isDigital,
then: [customStep(async () => ({ delivered: true }))],
else: [toolStep("create_shipment"), toolStep("notify_warehouse")],
})Configure retry, timeout, and error handling per step:
.step("payment", toolStep("process_payment"), {
// Retry configuration
retry: {
maxAttempts: 3,
delay: 1000, // Initial delay in ms
backoff: "exponential", // "linear" | "exponential"
maxDelay: 10000, // Cap for exponential backoff
},
// Timeout
timeout: 30000, // 30 seconds
// Error handling
onError: "skip", // "fail" | "skip" | custom handler
})
// Custom error handler
.step("optional", toolStep("optional_step"), {
onError: async (error, ctx) => {
console.log(`Step failed: ${error.message}`);
return { fallbackResult: true }; // Return fallback value
},
})Workflows can have UI widgets just like regular tools:
const workflowUI = defineUI({
name: "Order Status",
html: "./dist/order-widget.html",
});
const orderWorkflow = workflow("process_order")
.describe("Process order")
.input({ orderId: z.string() })
.ui(workflowUI)
.step("validate", toolStep("validate_order"))
.build();Workflows automatically detect your environment and use the appropriate executor manager:
Traditional Servers (Node.js, Express):
- Global singleton with persistent pooling
- Background cleanup of idle executors (10 min TTL)
- LRU eviction when pool reaches 100 executors
- Graceful shutdown via
server.stop()
Edge/Serverless (Supabase, Vercel, Cloudflare, AWS Lambda):
- Fresh manager per invocation (no singleton)
- Smaller pool size (10 executors, memory-constrained)
- No background timers
- Auto-cleanup on function exit
// Traditional server - automatic cleanup on stop
const server = createApp({ tools: { myWorkflow } });
await server.start();
await server.stop(); // Cleans up all workflow resources
// Advanced configuration
import { ExecutorManager } from "@mcp-apps-kit/core";
const manager = ExecutorManager.getInstance({
maxExecutors: 200, // Pool size for high-traffic apps
executorTTL: 5 * 60 * 1000, // 5 minute idle timeout
autoCleanup: true, // Enable automatic cleanup
cleanupInterval: 60 * 1000, // Cleanup every minute
});
// Get statistics
const stats = manager.getStats();
console.log(`Active: ${stats.activeExecutors}, Total: ${stats.totalExecutors}`);The workflow engine provides specific error types for debugging:
import {
WorkflowError, // Base class for all workflow errors
WorkflowExecutionError, // Step execution failures
StepTimeoutError, // Step timeout exceeded
ExternalToolError, // External MCP call failures
WorkflowValidationError, // Input/output validation failures
WorkflowDefinitionError, // Invalid workflow configuration
} from "@mcp-apps-kit/core";
try {
await orderWorkflow.handler(input, context);
} catch (error) {
if (error instanceof StepTimeoutError) {
console.log(`Step timed out: ${error.stepName}`);
} else if (error instanceof ExternalToolError) {
console.log(`External call failed: ${error.serverUri}`);
}
}import { createPlugin } from "@mcp-apps-kit/core";
const loggingPlugin = createPlugin({
name: "logger",
version: "1.0.0",
onInit: async () => console.log("App initializing..."),
onStart: async () => console.log("App started"),
beforeToolCall: async (context) => {
console.log(`Tool called: ${context.toolName}`);
},
afterToolCall: async (context) => {
console.log(`Tool completed: ${context.toolName}`);
},
});import type { Middleware } from "@mcp-apps-kit/core";
const logger: Middleware = async (context, next) => {
const start = Date.now();
context.state.set("startTime", start);
await next();
console.log(`${context.toolName} completed in ${Date.now() - start}ms`);
};
app.use(logger);app.on("tool:called", ({ toolName }) => {
analytics.track("tool_called", { tool: toolName });
});The framework provides defineMiddleware helpers to prevent common mistakes like forgetting await next(). Two patterns are available: basic (void-based) for simple use cases, and result-passing for advanced scenarios where middleware needs to inspect or transform tool results.
Basic Pattern - Simplified middleware without result access:
- Use for logging, timing, validation, auth checks
- Automatically calls
next()with before/after hooks - Type-enforced
Promise<void>return in wrap pattern
Result-Passing Pattern - Advanced middleware with result access:
- Use for caching, result transformation, result validation, sanitization
- Can inspect and modify tool results
- Type-enforced
Promise<TResult>return in wrap pattern
| Pattern | Use Cases | Result Access | Auto next()? |
|---|---|---|---|
defineMiddleware({ before, after }) |
Logging, metrics, timing, setup/teardown | ❌ No | ✅ Yes |
.before(hook) |
Validation, state initialization | ❌ No | ✅ Yes |
.after(hook) |
Cleanup, final logging | ❌ No | ✅ Yes |
.wrap(wrapper) |
Auth, conditional execution, error handling | ❌ No | ❌ Manual |
.withResult({ before, after }) |
Result inspection, enrichment | ✅ After only | ✅ Yes |
.withResult.after(hook) |
Result transformation, enrichment | ✅ Yes | ✅ Yes |
.withResult.wrap(wrapper) |
Caching, validation, retry, sanitization | ✅ Yes | ❌ Manual |
import { defineMiddleware } from "@mcp-apps-kit/core";
const logging = defineMiddleware({
before: async (context) => {
console.log(`[${new Date().toISOString()}] Tool: ${context.toolName}`);
},
after: async (context) => {
console.log(`Completed: ${context.toolName}`);
},
});
app.use(logging);const timing = defineMiddleware({
before: async (context) => {
context.state.set("startTime", performance.now());
},
after: async (context) => {
const duration = performance.now() - (context.state.get("startTime") as number);
console.log(`${context.toolName} took ${duration.toFixed(2)}ms`);
},
});
app.use(timing);const auth = defineMiddleware.wrap(async (context, next) => {
const token = context.metadata.raw?.["authorization"];
if (!token) {
throw new Error("Unauthorized");
}
const user = await validateToken(token);
context.state.set("user", user);
return next();
});
app.use(auth);import { defineMiddleware } from "@mcp-apps-kit/core";
interface ToolResult {
data: unknown;
_meta?: Record<string, unknown>;
}
const cache = new Map<string, ToolResult>();
const caching = defineMiddleware.withResult.wrap<ToolResult>(async (context, next) => {
const cacheKey = `${context.toolName}:${JSON.stringify(context.input)}`;
const cached = cache.get(cacheKey);
if (cached) {
console.log("Cache hit:", cacheKey);
return cached; // Short-circuit, don't call next()
}
console.log("Cache miss:", cacheKey);
const result = await next();
cache.set(cacheKey, result);
return result;
});
app.use(caching);interface ToolResult {
data: unknown;
_meta?: Record<string, unknown>;
}
const addTimestamp = defineMiddleware.withResult.after<ToolResult>(async (context, result) => {
return {
...result,
_meta: {
...result._meta,
processedAt: new Date().toISOString(),
toolName: context.toolName,
},
};
});
app.use(addTimestamp);const enrichWithUser = defineMiddleware.withResult<ToolResult>({
before: async (context) => {
const userId = context.metadata.subject;
if (userId) {
const userProfile = await fetchUserProfile(userId);
context.state.set("userProfile", userProfile);
}
},
after: async (context, result) => {
const userProfile = context.state.get("userProfile");
if (userProfile) {
return {
...result,
_meta: {
...result._meta,
user: userProfile,
},
};
}
return result; // Keep original if no user
},
});
app.use(enrichWithUser);const validateAndRetry = defineMiddleware.withResult.wrap<ToolResult>(async (context, next) => {
let attempts = 0;
const maxAttempts = 3;
while (attempts < maxAttempts) {
try {
const result = await next();
// Validate result structure
if (!result.data) {
throw new Error("Invalid result: missing data field");
}
return result;
} catch (error) {
attempts++;
if (attempts >= maxAttempts) throw error;
console.log(`Retry ${attempts}/${maxAttempts} for ${context.toolName}`);
await new Promise((resolve) => setTimeout(resolve, 100 * attempts));
}
}
throw new Error("Max retries exceeded");
});
app.use(validateAndRetry);interface SensitiveResult extends ToolResult {
apiKey?: string;
internalId?: string;
}
const sanitizeForPublic = defineMiddleware.withResult.wrap<SensitiveResult>(
async (context, next) => {
const result = await next();
// Remove sensitive fields for non-admin users
const isAdmin = context.state.get("isAdmin");
if (!isAdmin) {
const { apiKey, internalId, ...sanitized } = result;
return sanitized as SensitiveResult;
}
return result;
}
);
app.use(sanitizeForPublic);Old style (still works, but not recommended):
app.use(async (context, next) => {
console.log("before");
await next(); // Easy to forget await!
console.log("after");
});New style (recommended):
app.use(
defineMiddleware({
before: async (context) => console.log("before"),
after: async (context) => console.log("after"),
})
);- Basic pattern: Minimal overhead (<1% compared to raw middleware)
- Result-passing pattern: Small overhead (<5% for result passing through chain)
- Caching middleware: Can dramatically improve performance by avoiding expensive operations
- State sharing: Use
context.stateinstead of closures for better memory efficiency
TypeScript automatically infers types when using defineMiddleware:
// Type of result is inferred from generic parameter
const typed = defineMiddleware.withResult<{ count: number }>({
after: async (context, result) => {
// result.count is typed as number
return { count: result.count + 1 };
},
});
// Or explicit return type annotation
const explicit = defineMiddleware.withResult.after<ToolResult>(
async (context, result): Promise<ToolResult> => {
return { ...result, modified: true };
}
);Enable debug logging to receive structured logs from client UIs through the MCP protocol.
const app = createApp({
name: "my-app",
version: "1.0.0",
tools: {
/* ... */
},
config: {
debug: {
logTool: true,
level: "debug",
},
},
});You can also use the server-side logger directly:
import { debugLogger } from "@mcp-apps-kit/core";
debugLogger.info("User logged in", { userId: "456" });Core validates bearer tokens and injects auth metadata for tool handlers.
const app = createApp({
name: "my-app",
version: "1.0.0",
tools: {
/* ... */
},
config: {
oauth: {
protectedResource: "http://localhost:3000",
authorizationServer: "https://auth.example.com",
scopes: ["mcp:read", "mcp:write"],
},
},
});OAuth metadata is injected into _meta and surfaced via context.subject and context.raw:
tools: {
get_user_data: defineTool({
description: "Get authenticated user data",
input: z.object({}),
handler: async (_input, context) => {
const subject = context.subject;
const auth = context.raw?.["mcp-apps-kit/auth"] as
| {
subject: string;
scopes: string[];
expiresAt: number;
clientId: string;
issuer: string;
audience: string | string[];
token?: string;
extra?: Record<string, unknown>;
}
| undefined;
return { userId: subject, scopes: auth?.scopes ?? [] };
},
}),
}When OAuth is enabled, the server exposes:
/.well-known/oauth-authorization-server/.well-known/oauth-protected-resource
These endpoints describe the external authorization server and this protected resource. They do not issue tokens.
For non-JWT tokens or token introspection:
import type { TokenVerifier } from "@mcp-apps-kit/core";
const customVerifier: TokenVerifier = {
async verifyAccessToken(token: string) {
const response = await fetch("https://auth.example.com/introspect", {
method: "POST",
body: new URLSearchParams({ token }),
});
const data = await response.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 },
};
},
};
const app = createApp({
name: "my-app",
version: "1.0.0",
tools: {
/* ... */
},
config: {
oauth: {
protectedResource: "http://localhost:3000",
authorizationServer: "https://auth.example.com",
tokenVerifier: customVerifier,
},
},
});- JWT signature verification via JWKS
- Claim validation for
iss,aud,exp,sub,client_id - Optional scope enforcement
- Issuer normalization and clock skew tolerance
- HTTPS enforcement for JWKS in production
- Subject override for client-provided identity metadata
- JWKS keys are cached with automatic refresh (10-minute TTL)
- JWKS requests are rate limited (10 requests/minute)
- Validation errors return RFC 6750-compliant WWW-Authenticate headers
const app = createApp({
name: "my-app",
version: "1.0.0",
tools: {
/* ... */
},
});When submitting your app to the ChatGPT App Store, OpenAI requires domain verification to confirm ownership of the MCP server host.
const app = createApp({
name: "my-app",
version: "1.0.0",
tools: {
/* ... */
},
config: {
openai: {
domain_challenge: "your-verification-token-from-openai",
},
},
});- Register your app on the OpenAI Platform to receive a verification token
- Set the token in
config.openai.domain_challenge - The framework exposes
GET /.well-known/openai-apps-challengereturning the token as plain text - OpenAI pings the endpoint during submission to verify domain ownership
- Deploy before submitting so the endpoint is live
- The endpoint returns
text/plainas required - Works in Express and serverless deployments (
handleRequest)
../../examples/minimal- demonstrates API versioning with v1 and v2 endpoints../../examples/restaurant-finder- end-to-end app with search functionality- kanban-mcp-example - full-featured kanban board example
Key exports include:
createApp,tool,defineTool,defineUIworkflow,toolStep,customStep,externalStepExecutorManager,ExternalToolClientcreatePlugin,loggingPlugindebugLogger,ClientToolsFromCoreMiddleware,TypedEventEmitter
For full types, see packages/core/src/types or the project overview in ../../README.md.
See ../../CONTRIBUTING.md for development setup and guidelines. Issues and pull requests are welcome.
MIT