Express MCP SDK
The Descope MCP Express SDK (@descope/mcp-express) provides drop-in Express middleware and helpers to add secure auth to your MCP server. It ships an authenticated /mcp endpoint, serves the required OAuth metadata endpoints, and lets you register tools with per-tool scopes.
By default it runs as a resource server, in line with the current MCP authorization spec: Descope stays the authorization server, and the SDK validates the tokens Descope issues.
Prerequisites
- A Descope project with an MCP Server created in the Console; you will need its Discovery URL
- An Express app
- Node.js 18+
Installation
npm install @descope/mcp-expressQuick Start
Create your .env
SERVER_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-configurationWith DESCOPE_MCP_SERVER_WELL_KNOWN_URL set, the SDK derives the issuer, project ID, and API base URL automatically. Existing setups can instead provide DESCOPE_PROJECT_ID (and optionally DESCOPE_BASE_URL), or set DESCOPE_MCP_SERVER_ISSUER directly.
Wire up a minimal server
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 vars work out of the box)
const provider = new DescopeMcpProvider({
serverUrl: process.env.SERVER_URL,
descopeMcpServerWellKnownUrl: process.env.DESCOPE_MCP_SERVER_WELL_KNOWN_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");
});Good to know
/mcprequires a valid Bearer token, and requests must sendContent-Type: application/json.- The metadata endpoints are always on. The
/mcphandler is wired only when you pass a tool-registration function todescopeMcpAuthRouter. - Self-provided issuer or discovery URLs must use one of two path shapes:
/v1/apps/agentic/<projectId>/<mcpServerId>/...(MCP Server) or/v1/apps/<projectId>(Inbound App).
Creating Authenticated Tools
There are two APIs with identical capabilities: both support Zod input, an optional output schema, annotations, and scopes. defineTool is a thin wrapper over registerAuthenticatedTool with a single-object config and cleaner TypeScript inference for (args, extra); registerAuthenticatedTool mirrors the underlying MCP registerTool shape with explicit overloads. Pick the style you prefer.
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) }],
};
},
});import { registerAuthenticatedTool } from "@descope/mcp-express";
import { z } from "zod";
// With input
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"],
);
// Without input
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"],
);Features
An MCP 2025-06-18 compliant resource server, always enabled:
- OAuth 2.0 Protected Resource Metadata (RFC 8705)
- OAuth 2.0 Authorization Server Metadata (RFC 8414)
/mcpendpoint with bearer token authentication- Resource Indicator support (RFC 8707)
All OAuth schemas use Zod for runtime validation. Token and revocation endpoints are provided by Descope.
Verify Token Options
Require scopes globally, or pin the expected resource indicator and audience:
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)
},
});Migrating an Existing MCP Server
Already have a plain MCP server using server.registerTool?
- Put your MCP behind Express: add
app.use(express.json())and wiredescopeMcpAuthRouter((server) => { /* register tools */ }, provider). The router exposes the metadata endpoints and wiresPOST /mcpwith bearer auth. - Wrap each existing tool: convert
server.registerTool(...)calls todefineToolorregisterAuthenticatedTool, adding thescopeseach tool requires. - Remove custom wiring: you no longer manage
StreamableHTTPServerTransportor your own/.well-known/*endpoints; the router handles them. - Update handler signatures: with input:
(args, extra) => CallToolResult; without input:(extra) => CallToolResult. - Optional: call external APIs on the user's behalf: use
extra.getOutboundToken(appId, scopes?)to fetch a vaulted third-party token from Connections.
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.
Legacy Authorization Server Mode
Not recommended
By default the SDK runs as a resource server only. That is the recommended path, aligned with the MCP 2025-06-18 spec. Legacy Authorization Server mode exposes additional endpoints (/authorize, /register) for backwards compatibility and testing. Consider the added surface area before enabling it. It requires DESCOPE_PROJECT_ID, SERVER_URL, and a DESCOPE_MANAGEMENT_KEY.
Enable it through authorizationServerOptions, and configure /register through dynamicClientRegistrationOptions:
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 },
],
clientType: "public", // or "confidential"; Descope then issues a client_secret at registration
},
});Note
With clientType: "confidential", the /register response includes a client_secret (per RFC 7591). It is only returned at registration time, so store it securely.
Related
- MCP Express SDK on GitHub: source and full reference
- Python MCP SDK: the same resource-server role for Python and FastMCP
- MCP Servers: configure the server object, scopes, and registration methods
- MCP overview: how the authorization model fits together