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-express

Quick 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-configuration

With 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

  • /mcp requires a valid Bearer token, and requests must send Content-Type: application/json.
  • The metadata endpoints are always on. The /mcp handler is wired only when you pass a tool-registration function to descopeMcpAuthRouter.
  • 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)
  • /mcp endpoint 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?

  1. Put your MCP behind Express: add app.use(express.json()) and wire descopeMcpAuthRouter((server) => { /* register tools */ }, provider). The router exposes the metadata endpoints and wires POST /mcp with bearer auth.
  2. Wrap each existing tool: convert server.registerTool(...) calls to defineTool or registerAuthenticatedTool, adding the scopes each tool requires.
  3. Remove custom wiring: you no longer manage StreamableHTTPServerTransport or your own /.well-known/* endpoints; the router handles them.
  4. Update handler signatures: with input: (args, extra) => CallToolResult; without input: (extra) => CallToolResult.
  5. 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.

Was this helpful?

On this page