Drop‑in Express middleware and helpers to add secure auth to your Model Context Protocol (MCP) server with Descope. Ship an authenticated /mcp endpoint and register tools in minutes.
npm install @descope/mcp-express
Before you get started, you must create an MCP Server in the Descope Console and get the Discovery URL of your new server.
.envSERVER_URL=http://localhost:3000
# Recommended: MCP Server Discovery URL
DESCOPE_MCP_SERVER_WELL_KNOWN_URL=https://api.descope.com/v1/apps/agentic/<project>/<mcp-server-id>/.well-known/openid-configuration
# Optional (advanced): issuer URL — path must be `/v1/apps/agentic/<project>/<mcp-server-id>` or `/v1/apps/<project>`
# DESCOPE_MCP_SERVER_ISSUER=https://api.descope.com/v1/apps/agentic/<project>/<mcp-server-id>
# Optional: override derived values (existing setups)
# DESCOPE_PROJECT_ID=your_project_id
# DESCOPE_BASE_URL=https://api.descope.com
import "dotenv/config";
import express from "express";
import { descopeMcpAuthRouter, defineTool, DescopeMcpProvider } from "@descope/mcp-express";
import { z } from "zod";
const app = express();
// Required: so /mcp can read JSON bodies
app.use(express.json());
// Optional: explicit provider config (env work out of the box)
const provider = new DescopeMcpProvider({
serverUrl: process.env.SERVER_URL,
descopeMcpServerWellKnownUrl: process.env.DESCOPE_MCP_SERVER_WELL_KNOWN_URL,
// Backward-compatible fallback:
projectId: process.env.DESCOPE_PROJECT_ID,
baseUrl: process.env.DESCOPE_BASE_URL,
});
// Define an authenticated tool (requires 'openid')
const hello = defineTool({
name: "hello",
description: "Say hello to the authenticated user",
input: {
name: z.string().describe("Name to greet").optional(),
},
scopes: ["openid"],
handler: async (args, extra) => {
const result = {
message: `Hello ${args.name || "there"}!`,
authenticatedUser: extra.authInfo.clientId,
};
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
},
});
// Wire the MCP router and register your tools
app.use(
descopeMcpAuthRouter((server) => {
hello(server);
}, provider),
);
app.listen(3000, () => {
console.log("MCP endpoint: POST http://localhost:3000/mcp");
});
Pro tips
Content-Type: application/json to /mcp./mcp requires a valid Bearer token./mcp handler is wired only when you pass a toolRegistration function.DESCOPE_MCP_SERVER_WELL_KNOWN_URL; the SDK derives issuer, project ID, and API baseUrl (from the URL origin) automatically./v1/apps/agentic/<projectId>/<mcpServerId>/... (MCP Server) or /v1/apps/<projectId> (Inbound App).DESCOPE_PROJECT_ID is optional when those URLs allow deriving the project ID. Otherwise set DESCOPE_PROJECT_ID explicitly.DESCOPE_PROJECT_ID (and optional DESCOPE_BASE_URL) as before./.well-known/oauth-authorization-server and authorization_servers): for project-only config this stays the historical shape https://api.descope.com/<projectId> (via new URL(projectId, baseUrl)). When MCP discovery or DESCOPE_MCP_SERVER_ISSUER is set, the advertised issuer is the resolved MCP / Descope issuer URL instead.Pick your flavor: the ergonomic defineTool or the flexible registerAuthenticatedTool.
defineTool (with input)import { defineTool } from "@descope/mcp-express";
import { z } from "zod";
const getUser = defineTool({
name: "get_user",
description: "Get user information",
input: { userId: z.string().describe("The user ID to fetch") },
scopes: ["profile", "email"],
handler: async (args, extra) => {
return {
content: [{ type: "text", text: JSON.stringify({ userId: args.userId, scopes: extra.authInfo.scopes }, null, 2) }],
};
},
});
registerAuthenticatedToolimport { registerAuthenticatedTool } from "@descope/mcp-express";
import { z } from "zod";
const getUser = registerAuthenticatedTool(
"get_user",
{
description: "Get user information",
inputSchema: { userId: z.string().describe("The user ID to fetch") },
},
async (args, extra) => {
return { content: [{ type: "text", text: JSON.stringify({ userId: args.userId }, null, 2) }] };
},
["profile", "email"],
);
const whoami = registerAuthenticatedTool(
"whoami",
{ description: "Return authenticated identity info" },
async (extra) => {
const result = {
clientId: extra.authInfo.clientId,
scopes: extra.authInfo.scopes || [],
};
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
},
["openid"],
);
Short answer: choose defineTool for brevity and great type inference; choose registerAuthenticatedTool if you prefer explicit overloads and a closer-to-the-metal API.
defineTool is a thin wrapper over registerAuthenticatedTool.defineTool(args, extra) when you provide input.registerAuthenticatedToolregisterTool shape.There isn’t anything you can do with one that you can’t do with the other. Pick the style you prefer.
MCP 2025‑06‑18 compliant Resource Server.
/mcp endpoint with bearer token authenticationOptional (Authorization Server)
/authorize endpoint (disabled by default)Resource Server (always enabled)
Authorization Server (optional)
All OAuth schemas use Zod for runtime validation.
By default, this SDK runs as a Resource Server only. That’s the recommended path and aligns with the MCP 2025‑06‑18 spec. The features below are for legacy compatibility and testing. Enabling them exposes additional endpoints (/authorize, /register). Consider the added surface area before turning them on.
Requirements
Example .env
DESCOPE_MCP_SERVER_WELL_KNOWN_URL=https://api.descope.com/v1/apps/agentic/<project>/<server>/.well-known/openid-configuration
SERVER_URL=http://localhost:3000
Configuration example
import { DescopeMcpProvider } from "@descope/mcp-express";
const provider = new DescopeMcpProvider({
projectId: process.env.DESCOPE_PROJECT_ID,
serverUrl: process.env.SERVER_URL,
authorizationServerOptions: {
isDisabled: false, // enable Authorization Server mode
enableAuthorizeEndpoint: true, // expose /authorize
enableDynamicClientRegistration: true, // optionally expose /register
},
// Only needed if you enable dynamic client registration
dynamicClientRegistrationOptions: {
authPageUrl: `https://api.descope.com/login/${process.env.DESCOPE_PROJECT_ID}?flow=consent`,
permissionScopes: [
{ name: "get-schema", description: "Allow getting the SQL schema" },
{ name: "run-query", description: "Allow executing a SQL query", required: false },
],
nonConfidentialClient: true,
},
});
Notes
enableDynamicClientRegistration: true and provide dynamicClientRegistrationOptions if you want to expose /register.import { DescopeMcpProvider } from "@descope/mcp-express";
const provider = new DescopeMcpProvider({
verifyTokenOptions: {
requiredScopes: ["get-schema", "run-query"],
// resourceIndicator: "your-resource", // optional
// audience: "your-audience", // optional (single value supported currently)
},
});
Already have a plain MCP server using server.registerTool? Here’s the simplest path:
Put your MCP behind Express
Add JSON parsing: app.use(express.json()).
descopeMcpAuthRouter((server) => { /* register tools */ }, provider).The router exposes the required metadata endpoints and wires POST /mcp with bearer auth when you provide a registration function.
Wrap each existing tool
Before (plain MCP):
server.registerTool("whoami", { description: "Return identity" }, async (_args, _extra) => {
const data = { ok: true };
return { content: [{ type: "text", text: JSON.stringify(data) }] };
});
const whoami = defineTool({
name: "whoami",
description: "Return identity",
scopes: ["openid"],
handler: async (extra) => {
const data = { ok: true };
return { content: [{ type: "text", text: JSON.stringify(data) }] };
},
});
app.use(
descopeMcpAuthRouter((server) => {
whoami(server);
}, provider),
);
const whoami = registerAuthenticatedTool(
"whoami",
{ description: "Return identity" },
async (extra) => {
const data = { ok: true };
return { content: [{ type: "text", text: JSON.stringify(data) }] };
},
["openid"],
);
Remove custom wiring
You no longer need to manage StreamableHTTPServerTransport or your own /.well-known/* endpoints. The router handles them.
Update handler signatures and return type as needed
With input: (args, extra) => CallToolResult.
(extra) => CallToolResult.Return CallToolResult as { content: [{ type: "text", text: "..." }] }.
Optional: call external APIs on behalf of the user
Use extra.getOutboundToken(appId, scopes?) to fetch outbound tokens.
If you can’t use the router, the lower-level pieces exist (descopeMcpBearerAuth and createMcpServerHandler on POST /mcp), but the router is the simplest and safest path.
/mcp now uses StreamableHTTPServerTransport from the official MCP SDK.descopeMcpAuthRouter.This SDK adapts code from the Model Context Protocol TypeScript SDK (MIT).
MIT
$ claude mcp add mcp-express \
-- python -m otcore.mcp_server <graph>