Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 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
4a12338
feat(ai-mcp): add a configurable token exchange timeout
tusharpandey13 Oct 21, 2026
14f5b9a
fix(ai-mcp): clear request timers when the body settles
tusharpandey13 Oct 21, 2026
407501d
docs(ai-mcp): turn the example into an online store with tenant setup…
tusharpandey13 Oct 21, 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
27 changes: 27 additions & 0 deletions examples/ai-mcp-server/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# 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 token exchanges and the client credentials grant.
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=

# Identifiers (audiences) of the three store APIs.
ORDERS_AUDIENCE=https://orders.api/
INVENTORY_AUDIENCE=https://inventory.api/
ANALYTICS_AUDIENCE=https://analytics.api/

# Base URL of the store API process the tools call.
STORE_API_URL=http://localhost:3003
# Longest wait for the store API, in milliseconds. Optional, defaults to 5000.
DOWNSTREAM_TIMEOUT_MS=5000

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

A small MCP server built with `@auth0/ai-mcp` and the MCP TypeScript SDK. It plays an online store assistant. It checks the access token on every request, then calls the store's Orders, Inventory and Analytics APIs, either as the signed-in shopper (on-behalf-of token exchange) or as itself (client credentials). A second small process plays the store APIs and verifies every token it receives.

## 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).
- Every tool is wrapped in `withRequiredScopes`. A caller without the tool's scope gets an error result for that tool only (HTTP 200); the other tools stay available.
- `order_status_report` (scope `tool:order_status_report`, optional `order_id`) exchanges the caller's token twice, once for Orders (`read:orders`) and once for Inventory (`read:inventory`), and lists the orders with stock levels.
- `fulfill_order` (scope `tool:fulfill_order`, required `order_id`) asks Orders for the same `read:orders` token, so a call right after a status report reuses the saved token, and asks Inventory for `read:inventory write:inventory` to reserve stock. Fulfilling the same order twice changes stock only once.
- `sales_analytics_report` (scope `tool:sales_analytics_report`) uses `getClientCredentialsToken`: the server acts as itself, with no shopper involved.
- Only the exchanged token is sent downstream, never the caller's own token. Failures come back as fixed messages, and each call stops after `DOWNSTREAM_TIMEOUT_MS`.
- `src/downstream-api.ts` verifies every token (RS256 signature from the tenant JWKS, issuer, the audience of the service being called, expiry, subject and scope) before it returns data. Each answer includes a `verified` block naming the audience, scope and acting client it checked, never the token.

Seed data lives in memory and resets on restart: orders ORD-1001 to ORD-1003, and stock for SKU-HEAD-01 (24), SKU-KEYB-02 (12) and SKU-DOCK-03 (7).

## Set up your tenant

On-behalf-of token exchange must be enabled on your tenant. Enabling it may require Auth0 support; ask them if the exchange is rejected with an `on_behalf_of_token_exchange` error. The commands use the [Auth0 CLI](https://github.com/auth0/auth0-cli). Replace each `YOUR_...` with the id from the previous command's output.

```bash
auth0 login --scopes create:client_grants,update:connections
# 1. APIs: the MCP server (identifier = MCP_SERVER_URL) and the three store services
auth0 apis create --name "Store MCP" --identifier "http://localhost:3001/mcp" --signing-alg RS256 \
--scopes "tool:order_status_report,tool:fulfill_order,tool:sales_analytics_report"
auth0 apis create --name "Orders Service" --identifier "https://orders.api/" --signing-alg RS256 --scopes "read:orders"
auth0 apis create --name "Inventory Service" --identifier "https://inventory.api/" --signing-alg RS256 --scopes "read:inventory,write:inventory"
auth0 apis create --name "Analytics Service" --identifier "https://analytics.api/" --signing-alg RS256 --scopes "read:analytics"
# 2. Exchange client: a Custom API client tied to the MCP API (a regular app cannot exchange tokens)
auth0 api post clients --data '{"name":"Store exchange client","app_type":"resource_server","resource_server_identifier":"http://localhost:3001/mcp"}'
auth0 api patch "clients/YOUR_EXCHANGE_CLIENT_ID" --data '{"token_exchange":{"allow_any_profile_of_type":["on_behalf_of_token_exchange"]}}'
# A PATCH replaces grant_types, so read the list first and send it back with client_credentials added
auth0 api get "clients/YOUR_EXCHANGE_CLIENT_ID?fields=grant_types"
auth0 api patch "clients/YOUR_EXCHANGE_CLIENT_ID" --data '{"grant_types":["EXISTING_GRANT_TYPES","client_credentials"]}'
# 3. Grants for the exchange client (subject_type user = on behalf of a shopper; none = its own token)
auth0 api post client-grants --data '{"client_id":"YOUR_EXCHANGE_CLIENT_ID","audience":"https://orders.api/","scope":["read:orders"],"subject_type":"user"}'
auth0 api post client-grants --data '{"client_id":"YOUR_EXCHANGE_CLIENT_ID","audience":"https://inventory.api/","scope":["read:inventory","write:inventory"],"subject_type":"user"}'
auth0 api post client-grants --data '{"client_id":"YOUR_EXCHANGE_CLIENT_ID","audience":"https://analytics.api/","scope":["read:analytics"]}'
# 4. Skip the consent screen for the exchanged tokens
auth0 api patch "resource-servers/YOUR_ORDERS_API_ID" --data '{"skip_consent_for_verifiable_first_party_clients":true}'
auth0 api patch "resource-servers/YOUR_INVENTORY_API_ID" --data '{"skip_consent_for_verifiable_first_party_clients":true}'
# 5. Test shopper in a database connection that third-party clients (Claude Code) may use
auth0 users create --connection-name Username-Password-Authentication --email shopper@example.com --name "Test Shopper" --password "CHOOSE_A_STRONG_PASSWORD"
auth0 api patch "connections/YOUR_CONNECTION_ID" --data '{"is_domain_connection":true}'
# 6. Claude Code client: turn on Settings > Advanced > Client ID Metadata Document Registration, then register it
auth0 api post "clients/cimd/register" --data '{"external_client_id":"https://claude.ai/oauth/claude-code-client-metadata"}'
auth0 api post client-grants --data '{"client_id":"YOUR_TPC_CLIENT_ID","audience":"http://localhost:3001/mcp","scope":["tool:order_status_report","tool:fulfill_order","tool:sales_analytics_report"],"subject_type":"user"}'
```

Making the connection domain-level (step 5) lets every client on the tenant use it, so run it on a development tenant. The exchange client's id and secret go in `AUTH0_CLIENT_ID` and `AUTH0_CLIENT_SECRET`.

## Run

```sh
cp .env.example .env # fill in the domain, client id and secret
npm run start:api # store APIs on port 3003
npm run start # MCP server on port 3001, in a second terminal
```

Connect Claude Code. The URL must end in `/mcp`.

```sh
claude mcp add --transport http store http://localhost:3001/mcp
```

Start `claude`, type `/mcp`, pick `store` and choose Authenticate. Sign in as the test shopper in the browser and allow the three permissions if asked. Then try:

- `show my recent orders`: two token exchanges, for Orders and Inventory, and the three orders with stock.
- `fulfill order ORD-1002`: stock for the keyboard goes from 12 to 11. Only the Inventory write token is new.
- `run the sales analytics report`: the server fetches revenue with its own token.

With `MCP_REQUIRED_SCOPES` unset, any signed-in caller reaches the server and each tool checks its own scope. Remove the server again with `claude mcp remove store`.

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,
];
37 changes: 37 additions & 0 deletions examples/ai-mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "@auth0/auth0-ai-js-examples-ai-mcp-server",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Online store MCP server that authenticates callers with Auth0 and calls store APIs 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",
"zod": "^3.25.76"
},
"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"
}
}
92 changes: 92 additions & 0 deletions examples/ai-mcp-server/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/** 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[];
ordersAudience: string;
inventoryAudience: string;
analyticsAudience: string;
/** Base URL of the store API. */
storeApiUrl: string;
/** Longest wait for the store API, in milliseconds. */
downstreamTimeoutMs: number;
port: number;
}

export interface DownstreamConfig {
domain: string;
ordersAudience: string;
inventoryAudience: string;
analyticsAudience: 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"]),
ordersAudience: required(env, "ORDERS_AUDIENCE"),
inventoryAudience: required(env, "INVENTORY_AUDIENCE"),
analyticsAudience: required(env, "ANALYTICS_AUDIENCE"),
storeApiUrl: required(env, "STORE_API_URL"),
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"),
ordersAudience: required(env, "ORDERS_AUDIENCE"),
inventoryAudience: required(env, "INVENTORY_AUDIENCE"),
analyticsAudience: required(env, "ANALYTICS_AUDIENCE"),
port: port(env, "DOWNSTREAM_PORT", 3003),
};
}
Loading
Loading