npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mcp-apps-kit/core

v0.5.0

Published

Server-side framework for building MCP applications

Downloads

55

Readme

@mcp-apps-kit/core

npm node license

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.

Table of Contents

Background

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.

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
  • Plugins, middleware, and events for cross-cutting concerns
  • Optional debug logging tool for client-to-server logs

Compatibility

  • Node.js: >= 20
  • Zod: ^4.0.0 (peer dependency)
  • MCP SDK: uses @modelcontextprotocol/sdk

Install

npm install @mcp-apps-kit/core zod

Usage

Quick start

import { 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 });

Attach UI to tool outputs

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,
    }),
  },
});

Defining Tools

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.

Fluent Tool Builder (Recommended)

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(),
}

Builder Features

  • 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();

Legacy: The defineTool helper

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.

Alternative: object syntax with type assertions

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) };
      },
    },
  },
});

End-to-End Type Safety with ClientToolsFromCore

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>;
}

API Versioning

Expose multiple API versions from a single application, each with its own tools, UI, and optional configuration overrides.

Basic Usage

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:

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:

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:

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

// 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):

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:

// 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

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}`);
  },
});

Middleware

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);

Events

app.on("tool:called", ({ toolName }) => {
  analytics.track("tool_called", { tool: toolName });
});

Middleware Best Practices

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.

Pattern Overview

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 Selection Guide

| 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 |

Example 1: Simple Logging (Basic Pattern)

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);

Example 2: Timing with State (Basic Pattern)

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);

Example 3: Authentication (Wrap Pattern)

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);

Example 4: Result Caching (Result-Passing with Short-Circuit)

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);

Example 5: Result Transformation (Result-Passing After Hook)

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);

Example 6: Result Enrichment from State

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);

Example 7: Result Validation & Retry

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);

Example 8: Result Sanitization

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);

Migration from Raw Middleware

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"),
  })
);

Performance Considerations

  • 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.state instead of closures for better memory efficiency

Type Inference

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 };
  }
);

Debug Logging

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" });

OAuth 2.1 Authentication

Core validates bearer tokens and injects auth metadata for tool handlers.

Quick start

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"],
    },
  },
});

Auth context access

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 ?? [] };
    },
  }),
}

OAuth metadata endpoints

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.

Custom token verification

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,
    },
  },
});

Security features

  • 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

Production considerations

  1. JWKS keys are cached with automatic refresh (10-minute TTL)
  2. JWKS requests are rate limited (10 requests/minute)
  3. Validation errors return RFC 6750-compliant WWW-Authenticate headers

Testing without OAuth

const app = createApp({
  name: "my-app",
  version: "1.0.0",
  tools: {
    /* ... */
  },
});

OpenAI Domain Verification

When submitting your app to the ChatGPT App Store, OpenAI requires domain verification to confirm ownership of the MCP server host.

Configuration

const app = createApp({
  name: "my-app",
  version: "1.0.0",
  tools: {
    /* ... */
  },
  config: {
    openai: {
      domain_challenge: "your-verification-token-from-openai",
    },
  },
});

How it works

  1. Register your app on the OpenAI Platform to receive a verification token
  2. Set the token in config.openai.domain_challenge
  3. The framework exposes GET /.well-known/openai-apps-challenge returning the token as plain text
  4. OpenAI pings the endpoint during submission to verify domain ownership

Notes

  • Deploy before submitting so the endpoint is live
  • The endpoint returns text/plain as required
  • Works in Express and serverless deployments (handleRequest)

References

Examples

  • ../../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

API

Key exports include:

  • createApp, tool, defineTool, defineUI
  • createPlugin, loggingPlugin
  • debugLogger, ClientToolsFromCore
  • Middleware, TypedEventEmitter

For full types, see packages/core/src/types or the project overview in ../../README.md.

Contributing

See ../../CONTRIBUTING.md for development setup and guidelines. Issues and pull requests are welcome.

License

MIT