Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9deaf20
feat(ai-mcp): scaffold package with errors and types
tusharpandey13 Oct 16, 2026
3691874
test(ai-mcp): add Auth0 and downstream API test support
tusharpandey13 Oct 16, 2026
9ee84dd
chore(ai-mcp): mark package private until the release channel is decided
tusharpandey13 Oct 16, 2026
7759ce2
chore(ai-mcp): declare jose as a runtime dependency
tusharpandey13 Oct 16, 2026
3fd22d6
feat(ai-mcp): build the caller principal from verified claims
tusharpandey13 Oct 16, 2026
d3c85cb
feat(ai-mcp): add request context and scope checks
tusharpandey13 Oct 16, 2026
b956cfc
feat(ai-mcp): add indexed memory store, encrypted store and single fl…
tusharpandey13 Oct 16, 2026
0a6e3a5
feat(ai-mcp): add the MCP client and request-scoped client
tusharpandey13 Oct 16, 2026
2639fe6
feat(ai-mcp): export the client, context, scope and store APIs
tusharpandey13 Oct 16, 2026
b45196e
feat(ai-mcp): share concurrent on-behalf-of exchanges and isolate for…
tusharpandey13 Oct 16, 2026
75dd5e3
feat(ai-mcp): add a framework-agnostic HTTP handler with RFC 6750 cha…
tusharpandey13 Oct 16, 2026
0cca900
feat(ai-mcp): add MCP SDK transport wiring under the mcp-sdk subpath
tusharpandey13 Oct 16, 2026
3010317
fix(ai-mcp): harden the Node adapter error and abort paths
tusharpandey13 Oct 16, 2026
6e454d1
docs(examples): add an MCP server example that delegates to a verifie…
tusharpandey13 Oct 16, 2026
6088379
test(ai-mcp): add integration suites over the real MCP SDK transport
tusharpandey13 Oct 16, 2026
f8afb7f
fix(ai-mcp): keep token operation errors generic in tool results
tusharpandey13 Oct 16, 2026
a8ab10a
refactor(ai-mcp): make principal scope arrays readonly
tusharpandey13 Oct 16, 2026
2566d1e
feat(ai-mcp): re-export the core token store types
tusharpandey13 Oct 16, 2026
f0411e0
test(ai-mcp): snapshot the public API surface
tusharpandey13 Oct 16, 2026
625c6be
fix(ai-mcp): harden handler against malformed input and observer fail…
tusharpandey13 Oct 16, 2026
cb90737
chore(examples): assert the MCP server example resolves a supported M…
tusharpandey13 Oct 16, 2026
0af3679
docs(ai-mcp): add README, examples and guides
tusharpandey13 Oct 16, 2026
70ca6b2
test(ai-mcp): typecheck documentation snippets and pin documented beh…
tusharpandey13 Oct 16, 2026
216bf27
fix(ai-mcp): redact the bearer from AuthInfo and reject non-POST on t…
tusharpandey13 Oct 16, 2026
26dc834
fix(ai-mcp): treat a narrowed client credentials grant as a server fa…
tusharpandey13 Oct 16, 2026
1db6e20
docs(ai-mcp): route the metadata path and document handler status beh…
tusharpandey13 Oct 16, 2026
fc22405
test(ai-mcp): tie the tolerated 405 to the SSE GET in transport tests
tusharpandey13 Oct 16, 2026
17e3d21
docs(ai-mcp): state how an absent or empty token response scope is read
tusharpandey13 Oct 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions examples/ai-mcp-server/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Auth0 tenant domain, without scheme.
AUTH0_DOMAIN=your-tenant.us.auth0.com

# Public URL of this server. It is also the identifier of the API that represents
# it: access tokens sent to the server must be issued for it.
MCP_SERVER_URL=http://localhost:3001/mcp

# Application that performs the on-behalf-of token exchange.
AUTH0_CLIENT_ID=your-client-id
AUTH0_CLIENT_SECRET=your-client-secret

# Scopes every request to the MCP server must carry (space separated). Optional.
MCP_REQUIRED_SCOPES=

# Scope a caller needs to use the "admin_summary" tool.
MCP_ADMIN_SCOPE=read:admin

# Downstream API: identifier (audience), base URL, scope requested for it, listen port.
DOWNSTREAM_AUDIENCE=https://reports.example.com
DOWNSTREAM_URL=http://localhost:3002
DOWNSTREAM_SCOPE=read:reports
DOWNSTREAM_REQUIRED_SCOPE=read:reports
# Longest wait for the downstream API, in milliseconds. Optional, defaults to 5000.
DOWNSTREAM_TIMEOUT_MS=5000

# Listen ports.
MCP_PORT=3001
DOWNSTREAM_PORT=3002
54 changes: 54 additions & 0 deletions examples/ai-mcp-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# MCP server with Auth0 token delegation

A small MCP server built with `@auth0/ai-mcp` and the MCP TypeScript SDK. It checks the access token on every request, then calls a downstream API as the signed-in user through an on-behalf-of token exchange. A second small server plays the downstream API.

## What it shows

- `src/mcp-app.ts` wires `createNodeMcpHandler` to an `McpServer` over Streamable HTTP. Requests without a valid token are answered with a `WWW-Authenticate` challenge before any tool runs. The server also publishes its protected resource metadata (RFC 9728).
- `list_reports` exchanges the caller's token for a token to the downstream API with the request-scoped client, then calls the API with it. It sends only the exchanged token, whose audience is `DOWNSTREAM_AUDIENCE`, and never the caller's own token. Failures come back as a fixed error message, and the call stops after `DOWNSTREAM_TIMEOUT_MS`.
- `admin_summary` is wrapped in `withRequiredScopes`. A caller without the admin scope gets an error result for that tool only (HTTP 200); the rest of the server stays available.
- `src/downstream-api.ts` verifies every token it receives (signature from the tenant JWKS, issuer, audience, expiry and scope) before it returns data.

## Auth0 setup

Create these in your tenant:

1. An API for the MCP server. Its identifier is the public URL of the server (`MCP_SERVER_URL`). Add the scope you use for `MCP_ADMIN_SCOPE`.
2. An API for the downstream service, with the identifier you set as `DOWNSTREAM_AUDIENCE` and a scope matching `DOWNSTREAM_SCOPE`.
3. An application allowed to perform on-behalf-of token exchange for the downstream API. Its client id and secret go in `AUTH0_CLIENT_ID` and `AUTH0_CLIENT_SECRET`. Authorize it for the downstream API and its scope. Enabling on-behalf-of exchange on a tenant may require Auth0 support; see the Auth0 documentation for token exchange.
4. A client application that signs users in and requests an access token for the MCP server API.

## Run

```sh
cp .env.example .env # fill in the values
npm run start:api # downstream API
npm run start # MCP server, in a second terminal
```

Point an MCP client at `MCP_SERVER_URL` and send the user's access token as a bearer token.

The `.env` file is read with Node's `--env-file` support, so no extra package is needed.

## Check

```sh
npm run type-check
npm run lint
npm run build
npm test
```

The example needs `@modelcontextprotocol/sdk` 1.31 or newer, and `npm test` fails if the resolved copy is older. If a hoisted older copy wins, install a newer one next to the example without saving. Create a scratch directory outside the repository and run the install there, then copy the result in:

```sh
SCRATCH="$(mktemp -d)"
cd "$SCRATCH" && echo '{"name":"scratch","private":true}' > package.json
npm install --no-save --no-package-lock --ignore-scripts @modelcontextprotocol/sdk@1.32.1
cd - && mkdir -p examples/ai-mcp-server/node_modules
cp -R "$SCRATCH/node_modules/." examples/ai-mcp-server/node_modules/
```

Any version at or above 1.31 works. The copy also brings the SDK's transitive dependencies. A clean checkout fails this test until the root dependency resolves 1.31 or later.

`npm test` runs both servers on loopback with a mocked Auth0 tenant. It makes no network calls.
11 changes: 11 additions & 0 deletions examples/ai-mcp-server/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";

/** @type {import('eslint').Linter.Config[]} */
export default [
{ ignores: ["dist/**", "eslint.config.mjs"] },
{ languageOptions: { globals: globals.node } },
pluginJs.configs.recommended,
...tseslint.configs.recommended,
];
36 changes: 36 additions & 0 deletions examples/ai-mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "@auth0/auth0-ai-js-examples-ai-mcp-server",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "MCP server that authenticates callers with Auth0 and calls a downstream API on their behalf",
"scripts": {
"build": "rm -rf dist && tsc -p tsconfig.build.json",
"start": "tsx --env-file=.env src/server.ts",
"start:api": "tsx --env-file=.env src/downstream-server.ts",
"lint": "eslint .",
"type-check": "tsc --noEmit",
"test": "vitest run"
},
"author": {
"name": "Auth0 Inc.",
"url": "https://auth0.com"
},
"license": "Apache-2.0",
"dependencies": {
"@auth0/ai-mcp": "*",
"@modelcontextprotocol/sdk": "^1.31.0",
"jose": "^6.0.0"
},
"devDependencies": {
"@eslint/js": "^9.18.0",
"@types/node": "^22.10.6",
"eslint": "^9.22.0",
"globals": "^15.11.0",
"msw": "^2.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0",
"vitest": "^3.0.9"
}
}
89 changes: 89 additions & 0 deletions examples/ai-mcp-server/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/** Reads settings from environment variables. Nothing is hard-coded. */

function required(env: NodeJS.ProcessEnv, name: string): string {
const value = env[name]?.trim();
if (!value) {
throw new Error(`Missing environment variable ${name}. See .env.example.`);
}
return value;
}

function list(value: string | undefined): string[] {
return (value ?? "").split(/\s+/).filter((entry) => entry.length > 0);
}

function port(env: NodeJS.ProcessEnv, name: string, fallback: number): number {
const raw = env[name]?.trim();
if (!raw) return fallback;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
throw new Error(`Environment variable ${name} must be a port number.`);
}
return parsed;
}

function positiveInt(
env: NodeJS.ProcessEnv,
name: string,
fallback: number,
): number {
const raw = env[name]?.trim();
if (!raw) return fallback;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < 1) {
throw new Error(`Environment variable ${name} must be a positive integer.`);
}
return parsed;
}

export interface McpServerConfig {
domain: string;
/** Public URL of the MCP endpoint. Also the audience of access tokens sent to it. */
serverUrl: string;
clientId: string;
clientSecret: string;
requiredScopes: string[];
adminScope: string;
downstreamAudience: string;
downstreamUrl: string;
downstreamScope: string;
/** Longest wait for the downstream API, in milliseconds. */
downstreamTimeoutMs: number;
port: number;
}

export interface DownstreamConfig {
domain: string;
audience: string;
requiredScope: string;
port: number;
}

export function loadMcpServerConfig(
env: NodeJS.ProcessEnv = process.env,
): McpServerConfig {
return {
domain: required(env, "AUTH0_DOMAIN"),
serverUrl: required(env, "MCP_SERVER_URL"),
clientId: required(env, "AUTH0_CLIENT_ID"),
clientSecret: required(env, "AUTH0_CLIENT_SECRET"),
requiredScopes: list(env["MCP_REQUIRED_SCOPES"]),
adminScope: required(env, "MCP_ADMIN_SCOPE"),
downstreamAudience: required(env, "DOWNSTREAM_AUDIENCE"),
downstreamUrl: required(env, "DOWNSTREAM_URL"),
downstreamScope: required(env, "DOWNSTREAM_SCOPE"),
downstreamTimeoutMs: positiveInt(env, "DOWNSTREAM_TIMEOUT_MS", 5000),
port: port(env, "MCP_PORT", 3001),
};
}

export function loadDownstreamConfig(
env: NodeJS.ProcessEnv = process.env,
): DownstreamConfig {
return {
domain: required(env, "AUTH0_DOMAIN"),
audience: required(env, "DOWNSTREAM_AUDIENCE"),
requiredScope: required(env, "DOWNSTREAM_REQUIRED_SCOPE"),
port: port(env, "DOWNSTREAM_PORT", 3002),
};
}
106 changes: 106 additions & 0 deletions examples/ai-mcp-server/src/downstream-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { createServer } from "node:http";
import type { IncomingMessage, Server, ServerResponse } from "node:http";
import { createRemoteJWKSet, jwtVerify } from "jose";
import type { JWTVerifyGetKey } from "jose";
import type { DownstreamConfig } from "./config.js";

/**
* A small resource server. It verifies every token before it answers: signature
* (from the tenant's JWKS), issuer, audience, expiry and scope. A request that
* fails any check never reaches the data.
*/

const REPORTS = [
{ id: "r-1", title: "Quarterly summary" },
{ id: "r-2", title: "Usage by region" },
];

function send(
res: ServerResponse,
status: number,
body: unknown,
headers: Record<string, string> = {},
): void {
res.writeHead(status, {
"content-type": "application/json",
"cache-control": "no-store",
...headers,
});
res.end(JSON.stringify(body));
}

function bearerToken(req: IncomingMessage): string | undefined {
const match = /^Bearer +([A-Za-z0-9\-._~+/]+=*)$/.exec(
req.headers.authorization ?? "",
);
return match?.[1];
}

export function createDownstreamListener(
config: DownstreamConfig,
keys?: JWTVerifyGetKey,
): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
const issuer = `https://${config.domain}/`;
const getKey =
keys ?? createRemoteJWKSet(new URL(`${issuer}.well-known/jwks.json`));

return async (req, res) => {
const token = bearerToken(req);
if (token === undefined) {
send(
res,
401,
{ error: "invalid_token" },
{ "www-authenticate": "Bearer" },
);
return;
}
let scopes: string[];
try {
const { payload } = await jwtVerify(token, getKey, {
issuer,
audience: config.audience,
algorithms: ["RS256"],
requiredClaims: ["exp", "sub"],
});
scopes =
typeof payload["scope"] === "string"
? payload["scope"].split(/\s+/)
: [];
} catch {
send(
res,
401,
{ error: "invalid_token" },
{ "www-authenticate": 'Bearer error="invalid_token"' },
);
return;
}
if (!scopes.includes(config.requiredScope)) {
send(
res,
403,
{ error: "insufficient_scope" },
{
"www-authenticate": `Bearer error="insufficient_scope", scope="${config.requiredScope}"`,
},
);
return;
}
if (new URL(req.url ?? "/", "http://localhost").pathname !== "/reports") {
send(res, 404, { error: "not_found" });
return;
}
send(res, 200, { reports: REPORTS });
};
}

export function createDownstreamServer(
config: DownstreamConfig,
keys?: JWTVerifyGetKey,
): Server {
const listener = createDownstreamListener(config, keys);
return createServer((req, res) => {
listener(req, res).catch(() => send(res, 500, { error: "server_error" }));
});
}
7 changes: 7 additions & 0 deletions examples/ai-mcp-server/src/downstream-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { loadDownstreamConfig } from "./config.js";
import { createDownstreamServer } from "./downstream-api.js";

const config = loadDownstreamConfig();
createDownstreamServer(config).listen(config.port, () => {
console.log(`Downstream API listening on port ${config.port}`);
});
Loading
Loading