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

@brandon-svec/express_mcp

v0.13.3

Published

Add MCP tools, OAuth, and a knowledge base to any Express app so AI clients can call your APIs

Readme

Express MCP

Mount the Model Context Protocol on any Express app so AI clients (Cursor, Claude, Gemini Spark Connected Apps, and other MCP-compatible tools) can agentically interact with your pre-existing API server without standing up a separate MCP server.

Register tools against your existing business logic, optionally add OAuth SSO (GitHub/Google), a FlexSearch knowledge base, and an in-process LLM agent. This package ships routers and middleware only; your host Express app remains in control of TLS, CORS, body parsers, and rate limits.

Features

  • Drop-in MCP on Express — Mount JSON-RPC MCP endpoints on the app you already run
  • Your tools, your data — Register custom tools that wrap existing APIs and services
  • Optional OAuth SSO — GitHub/Google login, Bearer JWTs, PKCE, and Dynamic Client Registration for MCP clients
  • Gemini Spark Connected Apps — First-class Google Account Linking for Spark: default DCR allowlist for oauth-redirect*.googleusercontent.com, wire-encoded state echo, and refresh_token issuance
  • Built-in knowledge base — Full-text document search via FlexSearch (kb_search, kb_list, kb_get) plus MCP resources at kb://{name|documents}/{id}
  • MCP resources — Expose playbooks/SOPs via resources/list and resources/read (BaseResource / registerResource)
  • Tool name prefixing — Avoid collisions when multiple MCP services share a client
  • Optional agent — Gemini-backed agent_ask that can call your registered tools
  • Optional telemetry — OpenInference / GenAI OTLP spans for tool calls and agent turns (http/json, http/protobuf, or grpc; see docs/TELEMETRY.md)
  • Pluggable hooks — In-process BaseHook middleware for SessionStart, PreToolUse, PermissionRequest, PostToolUse, UserPromptSubmit, and Stop

Requirements

  • Node.js: Version 20 or higher
  • Express: Version ^4.18.0 || ^5.0.0 (peer dependency)

Installation

npm install @brandon-svec/express_mcp express

Express is a peer dependency. Ensure you have Express ^4.18.0 or ^5.0.0 installed in your project.

Quick Start

import express from 'express';
import { ExpressMcp, BaseTool, BaseResource } from '@brandon-svec/express_mcp';

const app = express();
app.use(express.json());

const expressMcp = new ExpressMcp();

// Extend BaseTool: name + description for tools/list, inputSchema for args, execute for the handler
class GreetingTool extends BaseTool {
  constructor() {
    super('greeting', 'A simple greeting tool'); // Tool name and description for your agent to know when to use the tool
    this.inputSchema = {
      type: 'object',
      properties: {
        name: { type: 'string', description: 'Name to greet' } // Good descriptions improve agent operations
      },
      required: ['name']
    };
  }

  async execute(params) {
    return `Hello, ${params.name}!`;
  }
}

expressMcp.registerTool(new GreetingTool());
app.use('/mcp', expressMcp.router());

app.listen(3000, () => {
  console.log('MCP endpoint: http://localhost:3000/mcp');
});

When auth is enabled, execute can take a second context argument and read context.user (sub, login, email, provider).

Knowledge Base

Express MCP includes a FlexSearch-backed knowledge base with three MCP tools: kb_search, kb_list, and kb_get. When you pass a name to ExpressMcp, those tools are prefixed (e.g. my-service_kb_search) so multiple MCP services can coexist in one client.

Knowledge base documents are also exposed as native MCP resources at kb://{name}/{id} when name is set, or kb://documents/{id} when unnamed. Calling agents can browse them with resources/list / resources/read while still using {name}_kb_search for full-text discovery.

import { ExpressMcp } from '@brandon-svec/express_mcp';

const expressMcp = new ExpressMcp({
  name: 'my-api-server',
  description: 'Internal API documentation and guides'
});

await expressMcp.addDocument('getting-started', {
  title: 'Getting Started Guide',
  content: 'This guide will help you get started with our API...',
  tags: ['guide', 'tutorial', 'beginner'],
  metadata: { category: 'documentation' }
});
// Also readable at kb://my-api-server/getting-started via resources/read

Document management

await expressMcp.addDocument('doc-id', {
  title: 'Document Title',
  content: 'Document content goes here...',
  tags: ['tag1', 'tag2'],
  metadata: { key: 'value' }
});

await expressMcp.updateDocument('doc-id', {
  title: 'Updated Title',
  content: 'Updated content...'
});

await expressMcp.removeDocument('doc-id');

const stats = await expressMcp.getKnowledgeBaseStats();
// { totalDocuments, totalWords, uniqueTags, tags }

Built-in MCP tools

| Tool | Purpose | |------|---------| | kb_search / {name}_kb_search | Full-text search (query, optional limit, includeContent) | | kb_list / {name}_kb_list | List documents (optional tag, limit) | | kb_get / {name}_kb_get | Fetch one document by plain id or by kb://{namespace}/{id} (the URI printed in the Turn-0 playbook directory) |

Tool descriptions update as you add documents: available tags and your custom description are included so AI clients know what is searchable.

Disable the knowledge base with enableKnowledgeBase: false (KB tools and kb:// resources are omitted; custom registerResource playbooks still work).

Resources

Register static or read-on-demand playbooks and schemas as MCP resources. Subclass BaseResource or pass static text / blob:

import { ExpressMcp, BaseResource } from '@brandon-svec/express_mcp';

const expressMcp = new ExpressMcp({ enableKnowledgeBase: false });

expressMcp.registerResource(new BaseResource({
  uri: 'playbook://incident-response',
  name: 'incident-response',
  title: 'Incident Response',
  description: 'Pager playbook for production incidents',
  text: '# Incident Response\n1. Page on-call\n2. Open a bridge',
}));

Clients discover resources with resources/list and load contents with resources/read (params.uri). Explicit registrations win over a Knowledge Base document that would map to the same URI.

Lifecycle Hooks

Register in-process hooks (no subprocesses) to intercept MCP tool calls and the optional agent. Subclass BaseHook, then call registerHook():

import { ExpressMcp, BaseHook, BaseTool } from '@brandon-svec/express_mcp';

class BlockDestructiveHook extends BaseHook {
  constructor() {
    super('block-destructive', ['PreToolUse', 'PermissionRequest'], {
      matcher: /delete|drop/i
    });
  }

  async execute(event) {
    if (event.tool_annotations?.destructiveHint) {
      return this.deny(`Blocked destructive tool: ${event.tool_name}`);
    }
    return this.allow();
  }
}

class AnnotatedTool extends BaseTool {
  constructor() {
    super(
      'delete_record',
      'Delete a record by id',
      { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
      { destructiveHint: true, title: 'Delete record' }
    );
  }

  async execute({ id }) {
    return { deleted: id };
  }
}

const expressMcp = new ExpressMcp({ permissionMode: 'default' });
expressMcp.registerTool(new AnnotatedTool());
expressMcp.registerHook(new BlockDestructiveHook());

| Event | Where | Blocking | |-------|-------|----------| | SessionStart | New MCP session / empty agent history | No — injects additionalContext into instructions / turn | | UserPromptSubmit | Agent.processMessage | Yes — can rewrite or deny the prompt | | PreToolUse | MCP tools/call and agent tool loop | Yes — validate/modify args or deny (isError: true) | | PermissionRequest | After PreToolUse when permissionMode is ask_for_approval, destructiveHint, or a matcher hits | Yes | | PostToolUse | After successful tool execution | No — can append additionalContext | | Stop | Agent final reply | Yes — can force self-correction within maxToolRounds |

Denied PreToolUse/PermissionRequest results return an MCP tool result with isError: true (JSON-RPC success) so the LLM can read the policy rejection. Unexpected hook errors fail open unless the hook was constructed with { failClosed: true }.

Authentication (OAuth SSO)

Optional GitHub and/or Google login. Users receive a JWT; MCP clients send Authorization: Bearer <token> on each request.

See docs/AUTH.md for the full config reference, env vars, session stores, and OAuth provider setup.

import { ExpressMcp } from '@brandon-svec/express_mcp';

const expressMcp = new ExpressMcp({
  name: 'my-service',
  auth: {
    enabled: true,
    baseUrl: 'https://my-host.example.com',
    callbackUrl: 'https://my-host.example.com/mcp/auth/callback',
    jwtSecret: process.env.JWT_SECRET,
    sessionSecret: process.env.SESSION_SECRET,
    jwtExpiresIn: '7d',
    allowedUsers: [],
    providers: {
      google: { clientId: '...', clientSecret: '...' },
      github: { clientId: '...', clientSecret: '...' }
    }
  }
});

app.use(expressMcp.httpRouter()); // OAuth + MCP on one router

Or build auth from environment variables:

import { buildAuthOptionsFromEnv } from '@brandon-svec/express_mcp';

const auth = buildAuthOptionsFromEnv({ baseUrl: 'http://localhost:3000' });
if (auth) {
  const expressMcp = new ExpressMcp({ name: 'my-service', auth });
}

Env vars: GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, JWT_SECRET, SESSION_SECRET. Optional: JWT_EXPIRES_IN, AUTH_ALLOWED_USERS, OAUTH_CALLBACK_URL.

Gemini Spark Connected Apps

Spark connects through Google Account Linking, not by pasting your GOOGLE_CLIENT_SECRET into Spark’s UI.

  1. Enable auth with a Google IdP client. Register only {baseUrl}/mcp/auth/callback on that Google Cloud OAuth client (the server’s IdP callback).
  2. Point Spark Connected Apps at your MCP HTTPS URL. Spark DCR registers oauth-redirect / oauth-redirect-sandbox / oauth-redirect-test .googleusercontent.com redirect hosts — those are trusted by default (no trustedRedirectHosts or allowAnyHttpsRedirect required for Spark).
  3. After the user signs in, Google’s servers call your POST /mcp/token. The library returns refresh_token and echoes the wire-encoded state unchanged (required for Account Linking).

Do not add oauth-redirect*.googleusercontent.com URLs to the Google Cloud OAuth client’s authorized redirect list. See docs/AUTH.md for trustedRedirectHosts and allowAnyHttpsRedirect when integrating other agents.

Optional AI Agent

Enable an in-process Gemini agent that can call your registered tools (exposed as agent_ask by default):

const expressMcp = new ExpressMcp({
  name: 'my-service',
  auth: { /* auth config */ },
  agent: {
    enabled: true,
    systemInstruction: 'You help users with this service.',
    gemini: {
      apiKey: process.env.GEMINI_API_KEY,
      model: 'gemini-2.0-flash'
    }
  }
});

Requires auth by default (agent.allowUnauthenticated: true to opt out). @google/genai is an optional peer dependency.

Turn-0 domain expert manifest: When the agent is enabled, ExpressMcp appends a live domain manifest to every processMessage system instruction (identity + operational playbooks directory). It is rebuilt each turn from name, description, registered BaseResource playbooks, and knowledge-base documents — so addDocument() / registerResource() appear on the next turn without rebuilding the Agent. Set agent.includeDomainManifest: false for raw unaugmented prompts. When there is no name, no description, and no playbooks, the manifest is empty and systemInstruction is unchanged.

When exposeTool is true (default), the MCP tool {name}_agent_ask (or agent_ask when name is unset) is registered for inbound agent-to-agent delegation:

| Argument | Required | Description | | --- | --- | --- | | prompt | yes | Natural-language question, goal, or instruction | | conversation_id | no | Opaque id (≤128 chars, no control characters) for multi-turn continuity. When omitted, a UUID is generated and returned | | context | no | Non-empty plain object of structured caller JSON. Injected for this turn only as CALLER_CONTEXT:\n<canonical JSON>\n\n via ephemeralPrefix — never stored in history |

Return value: { reply, conversation_id }. History keys are scoped per authenticated user as mcp:{user.sub|anon}:{conversation_id} so callers cannot collide across users.

The tool description is refreshed when tools are registered/unregistered or knowledge-base documents / resources change. It appends the server description (domain copy), callable tool names (Available capabilities: …), knowledge-base tags when present, and an operational playbook count when playbooks exist. Optional agent.description overrides the base description string.

Use getAgent().processMessage(historyKey, text, options) for inbound turns. Optional options.ephemeralPrefix is prepended to text for the model on this turn only (including tool rounds) and is never stored or replayed — hosts use it for working-memory fences and similar ephemeral context. Optional options.meta.traceparent parents the agent CHAIN span under an upstream TOOL/HTTP span. Optional per-turn options:

  • toolNames: string[] — only these registered tools are declared and callable for this turn (must pass excludeTools / toolAllowlist). An empty list omits the tools key from the Gemini request entirely (Gemini rejects functionDeclarations: [] with INVALID_ARGUMENT).
  • systemInstruction: string — replace the agent constructor system instruction for this turn only.
  • includePriorHistoryInContents: boolean — default true. When false, Gemini contents for this turn are only the current user turn (plus in-turn tool rounds); prior history is still read for SessionStart / logging and still appended at the end. Hosts that embed a compact transcript in ephemeralPrefix use this to avoid duplicating multi-turn Content[] JSON.
  • recordedAt: Date | number — optional timestamp stored with the history turn (also accepted by recordAssistantMessage). Defaults to new Date() at append time. One combined turn still holds user + model contents.
  • escalation: { toolNames, systemInstruction, resolve? } — requires toolNames (allowed catalog; must be a superset of the turn’s toolNames) and systemInstruction (full-set fallback). Adds a synthetic request_tools declaration (with tools.items.enum constrained to unloaded catalog names). When escalation is set, the agent appends an MCP CAPABILITIES catalog (Active vs Available from ToolRegistry descriptions, plus chaining / anti-guessing rules) to systemInstruction. After a successful escalate, the AVAILABLE block is omitted because request_tools is no longer declared. The model may call request_tools({ reason, tools? }): omit tools to grant the full catalog + static systemInstruction; pass tools: string[] to load those names (merged with the active set). Invalid LLM tools / empty reason return { ok: false, error, availableTools } so the model can retry; host resolve contract errors still throw. Optional resolve(requestedTools) returns { toolNames, systemInstruction } for host-controlled selective escalation. On success the functionResponse is { ok: true, tools: [{ name, description, parameters }, ...] }. Escalation grants +1 maxToolRounds budget. request_tools call/response pairs are stripped from stored history (atomic pair scrub) so Gemini function-pairing stays valid on replay.

Optional agent constructor options:

  • historyToolTurns: 'full' | 'omit' | 'digest' — 'full' (default) stores tool-loop functionCall / functionResponse parts; 'omit' stores only the user text and final model reply so large tool payloads are not replayed; 'digest' stores one model content with a HOST_TOOLS: JSON part (from required historyToolDigest(toolName, args, result)) plus the reply. Failed calls store { ok: false, error } with error truncated to 200 characters. Turns with no tool calls store user + reply only. request_tools is never included in the digest.
  • historyToolDigest — required when historyToolTurns is 'digest'; must return a plain object.
  • historyMaxTurns — when using the default in-memory history, keep at most this many turns after TTL pruning (oldest first). Cannot be set together with a custom agent.history.
  • excludeTools: string[] — additional tool names the agent must not call (merged with built-in session / agent_ask exclusions).
  • description: string — custom base description for the agent_ask MCP tool (before domain / capabilities / tag enrichment).
  • includeDomainManifest: boolean — default true. When true, appends the Turn-0 domain expert manifest (service identity + playbooks directory with governing tools) to the system instruction on every turn. Set false to leave systemInstruction unaugmented.

processMessage also accepts optional historyUserText: when set, that string is stored for the user turn instead of text (the model still sees text, optionally with ephemeralPrefix).

httpRouter({ mcpPath, rootAliases, sessionOptions }): set rootAliases: false for a secondary mount (e.g. /mcp/admin) so it does not register site-root OAuth AS aliases that would collide with the primary. Path-based discovery (/.well-known/oauth-authorization-server{mcpPath}) is always registered. Auth middleware is attached only to exact / MCP routes so a longer sibling path is not intercepted.

The default in-memory history store (InMemoryHistoryStore) exposes:

  • get(key) — flattened Gemini Content[] for replay (timestamps stripped).
  • getTurns(key) — pruned Array<{ recordedAt: Date, contents }> as detached copies (safe to .reverse() without mutating store order).
  • append(key, contents, { recordedAt? }) — optional recordedAt as a Date or epoch millis (defaults to new Date()).

Package exports also include formatTurnsTranscript(turns, { now, userLabel, modelLabel }) (newest-first relative transcript; reverses turns and contents; skips non-text and HOST_TOOLS: digest parts), buildCapabilitiesCatalog({ activeTools, availableTools }), and buildDomainExpertManifest({ serverName, serviceDescription, agentDescription, playbooks }) for hosts that need the helpers outside processMessage.

For host-initiated agent speech (proactive reminders, notifications) that must appear in the next turn’s conversation history without calling the LLM, use getAgent().recordAssistantMessage(historyKey, text, { recordedAt? }). That method requires a history store and throws if none is configured. When recorded model turns leave history starting with a model role, processMessage prepends a synthetic user [continued] turn before calling Gemini so contents remain valid. Schema and tool execution errors are returned to the model as { ok: false, error } functionResponses so further maxToolRounds can fix args and retry (same recovery Cursor gets over MCP JSON-RPC).

Set loggerOptions.level to trace to log the exact model request (contents, systemInstruction, toolDeclarations) plus a per-content size breakdown before each generate. Trace payloads include raw user content. At info, each completed turn logs character totals (contentsChars, systemInstructionChars, toolDeclarationChars, historyTurns, toolCount, escalated) without the request body.

API Reference

ExpressMcp

const expressMcp = new ExpressMcp({
  name: 'my-service',           // Instance name (tool prefixing + logger name)
  description: 'My API docs',   // Appended to knowledge base tool descriptions
  enableKnowledgeBase: true,    // Register kb_* tools (default: true)
  loggerOptions: {
    enabled: true,
    level: 'info',
    name: 'express-mcp'
  },
  logger: customLoggerInstance, // Optional; overrides loggerOptions
  auth: { /* see docs/AUTH.md */ },
  agent: { /* optional; see above */ },
  telemetry: { /* see docs/TELEMETRY.md */ }
});

Tool name prefixing

When name is set, knowledge base tools become {name}_kb_search, {name}_kb_list, {name}_kb_get. Use a unique name per service so clients like Cursor can route calls correctly when multiple MCP servers are connected.

Custom tools from registerTool() are not prefixed by default. To prefix a custom tool:

expressMcp.toolRegistry.register(new MyTool(), 'my-service');

Management APIs (hasRegisteredTool, unregisterTool, etc.) accept the original tool name.

Tool management

  • registerTool(tool) — Register a tool (custom tools are not prefixed)
  • unregisterTool(name) — Unregister by original name
  • hasRegisteredTool(name) / getRegisteredTool(name)
  • getRegisteredToolCount() / clearRegisteredTools()
  • registerHook(hook) / unregisterHook(name) — Lifecycle hooks
  • hasRegisteredHook(name) / getRegisteredHooks() / getRegisteredHookCount() / clearRegisteredHooks()
  • close() — Drain telemetry batch queue and destroy keep-alive sockets (call on process shutdown when telemetry is enabled)
  • router() — MCP-only Express router
  • httpRouter() — OAuth + MCP when auth is enabled

Optional constructor permissionMode ('default' | 'ask_for_approval') controls when PermissionRequest hooks run in addition to destructiveHint / matcher matches.

Knowledge base

  • addDocument(id, document) / updateDocument(id, updates) / removeDocument(id)
  • getKnowledgeBaseStats()

Documents accept optional playbook fields: summary (string), tools (string[] of governed tool names), and relatedDocs (string[]). When summary is omitted, the first clean non-heading paragraph is extracted (≤180 chars). When tools is omitted, content is scanned for known registered tool names (identifier-safe, longest-first).

kb_search scores matches across tools (+3), title (+3), summary (+2), tags (+2), and content (+1) so documentation is discoverable by tool name or semantic intent.

Resources

  • registerResource(resource) — Register a BaseResource (URIs are not name-prefixed)
  • unregisterResource(uri) / hasRegisteredResource(uri)
  • getRegisteredResources() / clearRegisteredResources()

BaseResource accepts optional summary and tools for the operational playbooks directory (merged with KB documents via resourceRegistry.getPlaybooksManifest()).

MCP Protocol Support

This module implements:

  • initialize — Initialize MCP session
  • notifications/initialized — Confirm initialization
  • tools/list — List available tools
  • tools/call — Execute a tool with parameters
  • resources/list — List Knowledge Base documents and custom resources
  • resources/read — Read a resource by URI (kb://… or custom)

Responses follow JSON-RPC 2.0 with proper error handling.

Learn more

| Topic | Document | |-------|----------| | OAuth SSO, sessions, DCR, agent auth | docs/AUTH.md | | OTLP telemetry, Phoenix, sanitization | docs/TELEMETRY.md | | Host security (TLS, CORS, rate limits) | SECURITY.md | | Local development, tests, releasing | CONTRIBUTING.md |

License

MIT