@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
Maintainers
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-encodedstateecho, andrefresh_tokenissuance - Built-in knowledge base — Full-text document search via FlexSearch (
kb_search,kb_list,kb_get) plus MCP resources atkb://{name|documents}/{id} - MCP resources — Expose playbooks/SOPs via
resources/listandresources/read(BaseResource/registerResource) - Tool name prefixing — Avoid collisions when multiple MCP services share a client
- Optional agent — Gemini-backed
agent_askthat can call your registered tools - Optional telemetry — OpenInference / GenAI OTLP spans for tool calls and agent turns (
http/json,http/protobuf, orgrpc; see docs/TELEMETRY.md) - Pluggable hooks — In-process
BaseHookmiddleware 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 expressExpress 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/readDocument 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 routerOr 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.
- Enable auth with a Google IdP client. Register only
{baseUrl}/mcp/auth/callbackon that Google Cloud OAuth client (the server’s IdP callback). - Point Spark Connected Apps at your MCP HTTPS URL. Spark DCR registers
oauth-redirect/oauth-redirect-sandbox/oauth-redirect-test.googleusercontent.comredirect hosts — those are trusted by default (notrustedRedirectHostsorallowAnyHttpsRedirectrequired for Spark). - After the user signs in, Google’s servers call your
POST /mcp/token. The library returnsrefresh_tokenand echoes the wire-encodedstateunchanged (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 passexcludeTools/toolAllowlist). An empty list omits thetoolskey from the Gemini request entirely (Gemini rejectsfunctionDeclarations: []withINVALID_ARGUMENT).systemInstruction: string— replace the agent constructor system instruction for this turn only.includePriorHistoryInContents: boolean— defaulttrue. Whenfalse, Geminicontentsfor 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 inephemeralPrefixuse this to avoid duplicating multi-turnContent[]JSON.recordedAt: Date | number— optional timestamp stored with the history turn (also accepted byrecordAssistantMessage). Defaults tonew Date()at append time. One combined turn still holds user + model contents.escalation: { toolNames, systemInstruction, resolve? }— requirestoolNames(allowed catalog; must be a superset of the turn’stoolNames) andsystemInstruction(full-set fallback). Adds a syntheticrequest_toolsdeclaration (withtools.items.enumconstrained to unloaded catalog names). When escalation is set, the agent appends an MCP CAPABILITIES catalog (Active vs Available fromToolRegistrydescriptions, plus chaining / anti-guessing rules) tosystemInstruction. After a successful escalate, theAVAILABLEblock is omitted becauserequest_toolsis no longer declared. The model may callrequest_tools({ reason, tools? }): omittoolsto grant the full catalog + staticsystemInstruction; passtools: string[]to load those names (merged with the active set). Invalid LLMtools/ emptyreasonreturn{ ok: false, error, availableTools }so the model can retry; hostresolvecontract errors still throw. Optionalresolve(requestedTools)returns{ toolNames, systemInstruction }for host-controlled selective escalation. On success the functionResponse is{ ok: true, tools: [{ name, description, parameters }, ...] }. Escalation grants +1maxToolRoundsbudget.request_toolscall/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-loopfunctionCall/functionResponseparts;'omit'stores only the user text and final model reply so large tool payloads are not replayed;'digest'stores one model content with aHOST_TOOLS:JSON part (from requiredhistoryToolDigest(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_toolsis never included in the digest.historyToolDigest— required whenhistoryToolTurnsis'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 customagent.history.excludeTools: string[]— additional tool names the agent must not call (merged with built-in session /agent_askexclusions).description: string— custom base description for theagent_askMCP tool (before domain / capabilities / tag enrichment).includeDomainManifest: boolean— defaulttrue. Whentrue, appends the Turn-0 domain expert manifest (service identity + playbooks directory with governing tools) to the system instruction on every turn. Setfalseto leavesystemInstructionunaugmented.
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 GeminiContent[]for replay (timestamps stripped).getTurns(key)— prunedArray<{ recordedAt: Date, contents }>as detached copies (safe to.reverse()without mutating store order).append(key, contents, { recordedAt? })— optionalrecordedAtas aDateor epoch millis (defaults tonew 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 namehasRegisteredTool(name)/getRegisteredTool(name)getRegisteredToolCount()/clearRegisteredTools()registerHook(hook)/unregisterHook(name)— Lifecycle hookshasRegisteredHook(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 routerhttpRouter()— 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 aBaseResource(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
