A minimal, pure-ESM MCP server for Node.js. Drop .mjs tools into an entrypoint-adjacent tools/ directory, start one entrypoint, and the library discovers and registers them automatically.
- Stateless Streamable HTTP by default.
- Optional stdio transport for local MCP clients.
- Optional stateful transport mode.
- Automatic
.mjstool discovery. - Zod input schemas.
- None, static bearer, bearer passthrough, and OAuth2 authentication modes.
- Injectable application context for databases and services.
- BigInt-safe
buildResponse()helper. - Express HTTP integration.
- HTTP, HTTPS, or both, with optional HTTP-to-HTTPS redirects.
- TypeScript declarations.
- Node.js 26 or newer
- An MCP client for live HTTP, HTTPS, or stdio operation
npm install @eliware/mcp-server// server.mjs
import { mcpServer } from '@eliware/mcp-server';
await mcpServer({
auth: { mode: 'static', token: process.env.MCP_TOKEN },
});Put tools in ./tools/. The MCP endpoint is /mcp only. There is no root or legacy compatibility endpoint.
See examples/basic.mjs for a complete entrypoint example. Put application tools in a tools/ folder beside that entrypoint.
For local clients that launch the server as a child process:
await mcpServer({ stdio: true });stdio uses stdin/stdout for MCP JSON-RPC. Do not write logs to stdout; use stderr instead. HTTP remains the default for remote deployments.
Create tools/hello.mjs beside your entrypoint:
import { z, buildResponse } from '@eliware/mcp-server';
export default async function registerHello({ mcpServer, toolName, log, db }) {
mcpServer.tool(
toolName,
'Say hello.',
{ name: z.string().min(1) },
async ({ name }) => {
log.debug(`${toolName} request`, { name });
// `db` and other values come from mcpServer({ context: { db } }).
void db;
return buildResponse({ message: `Hello, ${name}!` });
},
);
}Tool requirements:
- File ends in
.mjs. - File exports one default async registration function.
- The function receives
{ mcpServer, toolName, log, ...context }. - Call
mcpServer.tool(name, description, inputSchema, handler). - Define handler inputs with Zod (
z.string(),z.number(),z.object(), etc.). - Return an MCP result, normally using
buildResponse(value).
await mcpServer({
context: { db, config, services },
});Every tool receives those values as properties of its registration argument.
mcpServer(options) supports:
auth:{ mode: 'none' | 'static' | 'bearer-passthrough' | 'oauth2', ... }.toolsDir: custom tool directory. Defaults to<entrypoint-directory>/tools/; when no entrypoint is available, uses the bundledexamples/tools/directory.httpPort: HTTP listener port; setnull/falseto disable HTTP.httpsPort: HTTPS listener port. Requires TLS key/certificate material or file paths.tls: Node HTTPS TLS options (key,cert, optionalca) or file paths (keyFile,certFile,caFile). If omitted,TLS_KEY_FILE,TLS_CERT_FILE, andTLS_CA_FILEare used.httpRedirect: when true, HTTP redirects to HTTPS instead of serving MCP.context: values injected into every tool.stateless: defaults totrue; creates a fresh server/transport per request.endpointPath: defaults to/mcp; use one explicit endpoint path per deployment.enableJsonResponse: defaults totrue.allowedOrigins: optional CORS allowlist.entrypoint: entrypoint path used to resolve the default siblingtools/directory.app: optional caller-supplied Express application. When omitted, the server creates one as before.configureApp: optional callback invoked with the application after built-in HTTP middleware is installed and before the MCP route.
Embed MCP in an existing Express app by supplying app. Use configureApp for application-specific middleware or routes:
await mcpServer({
app,
configureApp: configuredApp => {
configuredApp.use(requestIdMiddleware);
},
});Both options are optional; without them, mcpServer creates and configures its own app.
A complete REST-plus-MCP example is available in examples/express/server.mjs:
import express from 'express';
import { mcpServer } from '@eliware/mcp-server';
const app = express();
app.get('/health', (req, res) => res.json({ status: 'ok' }));
await mcpServer({
app,
httpPort: 1234,
endpointPath: '/mcp',
auth: { mode: 'static', token: process.env.MCP_TOKEN },
});The resulting application serves both /health and /mcp from the same HTTP listener.
Use configureApp when routes or middleware should be installed as part of server setup:
await mcpServer({
app,
configureApp: configuredApp => {
configuredApp.use('/api', apiRoutes);
},
});buildResponse(value): returns{ content: [{ type: 'text', text }] }.convertBigIntToString(value): recursively converts BigInts to strings.z: re-exported Zod namespace.
Type declarations are included in index.d.ts.
The server exposes only /mcp for MCP traffic. Use stdio: true for local process clients and do not write logs to stdout in stdio mode. Authentication, TLS, tool discovery, and OAuth failures should be handled through the configured logger without exposing tokens, credentials, private keys, or sensitive tool data. Always close the returned server resources during shutdown.
npm install
npm run lint
npm test
npm run test:gaps
npm run lint
npm run typecheck
npm run packKeep MCP_TOKEN, OAuth credentials, TLS keys, certificates, and client secrets in environment variables or mounted secret files. Do not commit .env files or certificates. Use HTTPS and explicit authentication for remote deployments.
MIT © Eli Sterling, eliware.org
HTTPS only:
await mcpServer({
httpPort: null,
httpsPort: 443,
tls: { key: process.env.TLS_KEY, cert: process.env.TLS_CERT },
});Both listeners, redirecting HTTP to HTTPS:
await mcpServer({
httpPort: 80,
httpsPort: 443,
httpRedirect: true,
tls: { key: process.env.TLS_KEY, cert: process.env.TLS_CERT },
});Set httpRedirect: false to serve MCP over both listeners. The returned result exposes httpInstance and httpsInstance.
Local HTTP test:
cp .env.example .env
# set MCP_TOKEN in .env
docker compose up --buildHTTPS automatically loads TLS files from tls.keyFile/certFile/caFile, or TLS_KEY_FILE/TLS_CERT_FILE/TLS_CA_FILE; mount certificates read-only and never commit them. docker-compose.tls.yml exposes ports 80 and 443 for that deployment pattern.
The container uses container.mjs so the local package source resolves correctly. Local stdio remains a process mode, not a Docker network service:
docker run --rm -i -e MCP_TOKEN=test ghcr.io/eliware/mcp-server node examples/basic.mjs --stdioUnauthenticated:
auth: { mode: 'none' }Static token:
auth: { mode: 'static', token: process.env.MCP_TOKEN }Bearer passthrough validates that a bearer exists and exposes it to tools through request metadata for backend API calls:
auth: { mode: 'bearer-passthrough' }OAuth2 uses the same request-scoped auth context as the other modes. Tools should use requireAuth(extra) or requireBearer(extra) rather than reading raw request headers.
OAuth2 resource-server mode validates introspection results and injects sanitized request identity plus granted scopes into tool metadata. requiredScopes defines the minimum scopes a token must grant; the complete granted scope list remains available to tools:
auth: {
mode: 'oauth2',
issuer: 'https://auth.example',
resource: 'https://app.example/mcp',
requiredScopes: ['app:read'],
introspect: token => introspectToken(token),
}The introspection function is application-provided so the library does not hard-code an identity provider or persistence system. Static and dynamic OAuth client registration are available through the client-side helpers below.
The package exports provider discovery and dynamic registration helpers. Applications choose static credentials or persist dynamically registered credentials through an injected store; the library does not require a database.
const provider = await discoverOAuthProvider({ issuer });
const client = await registerOAuthClient({
registrationEndpoint: provider.registration_endpoint,
metadata: { client_name: 'my-app', redirect_uris: ['https://app.example/callback'] },
});Use createClientStore({ load, save, remove }) with MySQL, Kubernetes secrets, or another durable store.
OAuth2 mode also publishes protected-resource metadata at:
/.well-known/oauth-protected-resource/.well-known/oauth-protected-resource/mcp
Unauthorized OAuth2 responses include WWW-Authenticate resource metadata and required scopes.
For backward compatibility, scopes is accepted as an alias for requiredScopes, but new integrations should use requiredScopes.
Dynamic client registration is resolved once and cached through the injected client store. Static registration bypasses registration and returns configured credentials. The store must be durable when running multiple replicas.
For app-side OAuth login, createOAuthClient() combines provider discovery and static/dynamic client resolution, then provides PKCE authorization URL creation and authorization-code exchange. Applications remain responsible for state/verifier persistence and user sessions.
Auth helper exports:
requireScope(extra, scope)— throws a 403-style error when absent.hasScope(extra, scope)— boolean scope check.getUser(extra)— sanitized request identity.getAccessToken(extra)— explicit backend passthrough accessor.
OAuth2 may use the generic introspection adapter with auth.introspection.endpoint and optional client credentials, or an injected auth.introspect function.
PKCE helpers provide S256 verifier/challenge generation and one-time state consumption. Applications should persist PKCE records through a durable store and delete them after callback validation.
For help, questions, or community chat:
