Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion packages/mcp-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
"cors": "^2.8.5",
"jose": "^6.0.10",
"snakecase-keys": "^8.0.1",
"zod": "^3.24.3"
"zod": "^4.3.5"
},
"peerDependencies": {
"express": "^5.0.1"
Expand Down
5 changes: 5 additions & 0 deletions packages/sample-servers/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# WhoAmI server & Todo manager server
MCP_AUTH_ISSUER=https://your-tenant.logto.app/oidc

# Todo manager server only
MCP_RESOURCE_IDENTIFIER=https://todo.example.com/api/
10 changes: 8 additions & 2 deletions packages/sample-servers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,11 @@ pnpm start:todo-manager

## Environment variables

Make sure to set the following environment variable before running the servers:
- `MCP_AUTH_ISSUER`: The URL of your MCP Auth server
### WhoAmI server

- `MCP_AUTH_ISSUER`: The issuer URL of your authorization server (e.g., `https://your-tenant.logto.app/oidc`)

### Todo manager server

- `MCP_AUTH_ISSUER`: The issuer URL of your authorization server (e.g., `https://your-tenant.logto.app/oidc`)
- `MCP_RESOURCE_IDENTIFIER`: The resource identifier for the protected resource (e.g., `https://todo.example.com/api/`). Note: The trailing slash is recommended due to an MCP SDK behavior that appends `/` when constructing resource indicators.
4 changes: 2 additions & 2 deletions packages/sample-servers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@
"node": "^20.19.0 || ^22.0.0 || ^23.0.0 || ^24.0.0"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.17.1",
"@modelcontextprotocol/sdk": "^1.25.3",
"dotenv": "^16.5.0",
"express": "5.0.1",
"mcp-auth": "workspace:^",
"zod": "^3.24.3"
"zod": "^4.3.5"
},
"devDependencies": {
"@silverhand/eslint-config": "^6.0.1",
Expand Down
131 changes: 67 additions & 64 deletions packages/sample-servers/src/todo-manager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,31 @@ import assert from 'node:assert';

import { type AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { configDotenv } from 'dotenv';
import express from 'express';
import { fetchServerConfig, MCPAuth, MCPAuthBearerAuthError } from 'mcp-auth';
import { MCPAuth, MCPAuthBearerAuthError } from 'mcp-auth';
import { z } from 'zod';

import { TodoService } from './todo-service.js';

configDotenv();

const { MCP_AUTH_ISSUER, MCP_RESOURCE_IDENTIFIER } = process.env;

if (!MCP_AUTH_ISSUER) {
throw new Error('MCP_AUTH_ISSUER environment variable is required');
}

if (!MCP_RESOURCE_IDENTIFIER) {
throw new Error('MCP_RESOURCE_IDENTIFIER environment variable is required');
}

const todoService = new TodoService();

const assertUserId = (authInfo?: AuthInfo) => {
const { subject } = authInfo ?? {};
assert(subject, 'Invalid auth info');
return subject;
assert(authInfo?.subject, 'Invalid auth info');
return authInfo.subject;
};

const hasRequiredScopes = (userScopes: string[], requiredScopes: string[]): boolean => {
Expand All @@ -30,11 +39,13 @@ const server = new McpServer({
version: '0.0.0',
});

server.tool(
server.registerTool(
'create-todo',
'Create a new todo',
{ content: z.string() },
({ content }: { content: string }, { authInfo }) => {
{
description: 'Create a new todo',
inputSchema: { content: z.string() },
},
({ content }, { authInfo }) => {
const userId = assertUserId(authInfo);

/**
Expand All @@ -52,29 +63,38 @@ server.tool(
}
);

server.tool('get-todos', 'List all todos', ({ authInfo }) => {
const userId = assertUserId(authInfo);
server.registerTool(
'get-todos',
{
description: 'List all todos',
inputSchema: {},
},
(_params, { authInfo }) => {
const userId = assertUserId(authInfo);

/**
* If user has 'read:todos' scope, they can access all todos (todoOwnerId = undefined)
* If user doesn't have 'read:todos' scope, they can only access their own todos (todoOwnerId = userId)
*/
const todoOwnerId = hasRequiredScopes(authInfo?.scopes ?? [], ['read:todos'])
? undefined
: userId;
/**
* If user has 'read:todos' scope, they can access all todos (todoOwnerId = undefined)
* If user doesn't have 'read:todos' scope, they can only access their own todos (todoOwnerId = userId)
*/
const todoOwnerId = hasRequiredScopes(authInfo?.scopes ?? [], ['read:todos'])
? undefined
: userId;

const todos = todoService.getAllTodos(todoOwnerId);
const todos = todoService.getAllTodos(todoOwnerId);

return {
content: [{ type: 'text', text: JSON.stringify(todos) }],
};
});
return {
content: [{ type: 'text', text: JSON.stringify(todos) }],
};
}
);

server.tool(
server.registerTool(
'delete-todo',
'Delete a todo by id',
{ id: z.string() },
({ id }: { id: string }, { authInfo }) => {
{
description: 'Delete a todo by id',
inputSchema: { id: z.string() },
},
({ id }, { authInfo }) => {
const userId = assertUserId(authInfo);

const todo = todoService.getTodoById(id);
Expand Down Expand Up @@ -116,51 +136,34 @@ server.tool(
}
);

const { MCP_AUTH_ISSUER } = process.env;

if (!MCP_AUTH_ISSUER) {
throw new Error('MCP_AUTH_ISSUER environment variable is required');
}

const authServerConfig = await fetchServerConfig(MCP_AUTH_ISSUER, { type: 'oidc' });

const mcpAuth = new MCPAuth({
server: authServerConfig,
protectedResources: {
metadata: {
resource: MCP_RESOURCE_IDENTIFIER,
authorizationServers: [{ issuer: MCP_AUTH_ISSUER, type: 'oidc' }],
scopesSupported: ['create:todos', 'read:todos', 'delete:todos'],
},
},
});

const PORT = 3001;
const app = express();

app.use(mcpAuth.delegatedRouter());
app.use(mcpAuth.bearerAuth('jwt'));

// Below is the boilerplate code from MCP SDK documentation
const transports: Record<string, SSEServerTransport> = {};

// eslint-disable-next-line unicorn/prevent-abbreviations
app.get('/sse', async (_req, res) => {
// Create SSE transport for legacy clients
const transport = new SSEServerTransport('/messages', res);
// eslint-disable-next-line @silverhand/fp/no-mutation
transports[transport.sessionId] = transport;

res.on('close', () => {
// eslint-disable-next-line @silverhand/fp/no-delete, @typescript-eslint/no-dynamic-delete
delete transports[transport.sessionId];
});
app.use(mcpAuth.protectedResourceMetadataRouter());
app.use(
mcpAuth.bearerAuth('jwt', {
resource: MCP_RESOURCE_IDENTIFIER,
audience: MCP_RESOURCE_IDENTIFIER,
})
);

app.post('/', async (request, response) => {
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await server.connect(transport);
});

// eslint-disable-next-line unicorn/prevent-abbreviations
app.post('/messages', async (req, res) => {
const sessionId = String(req.query.sessionId);
const transport = transports[sessionId];
if (transport) {
await transport.handlePostMessage(req, res, req.body);
} else {
res.status(400).send('No transport found for sessionId');
}
await transport.handleRequest(request, response, request.body);
response.on('close', () => {
void transport.close();
});
});

app.listen(PORT);
57 changes: 21 additions & 36 deletions packages/sample-servers/src/whoami/index.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
// @ts-check

/**
* This is the JavaScript version of the WhoAmI server.
*
* @see {@link https://mcp-auth.dev/docs/tutorials/whoami Tutorial} for the full tutorial.
* @see {@link file://./whoami.ts} for the TypeScript version.
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { configDotenv } from 'dotenv';
import express from 'express';
import { fetchServerConfig, MCPAuth, MCPAuthTokenVerificationError } from 'mcp-auth';
Expand All @@ -21,13 +19,20 @@ const server = new McpServer({
});

// Add a tool to the server that returns the current user's information
server.tool('whoami', ({ authInfo }) => {
return {
content: [
{ type: 'text', text: JSON.stringify(authInfo?.claims ?? { error: 'Not authenticated' }) },
],
};
});
server.registerTool(
'whoami',
{
description: 'Get the current user information',
inputSchema: {},
},
(_params, { authInfo }) => {
return {
content: [
{ type: 'text', text: JSON.stringify(authInfo?.claims ?? { error: 'Not authenticated' }) },
],
};
}
);

const { MCP_AUTH_ISSUER } = process.env;

Expand Down Expand Up @@ -85,33 +90,13 @@ const app = express();
app.use(mcpAuth.delegatedRouter());
app.use(mcpAuth.bearerAuth(verifyToken));

// Below is the boilerplate code from MCP SDK documentation
const transports = {};

// eslint-disable-next-line unicorn/prevent-abbreviations
app.get('/sse', async (_req, res) => {
// Create SSE transport for legacy clients
const transport = new SSEServerTransport('/messages', res);
// eslint-disable-next-line @silverhand/fp/no-mutation
transports[transport.sessionId] = transport;

res.on('close', () => {
// eslint-disable-next-line @silverhand/fp/no-delete
delete transports[transport.sessionId];
});

app.post('/', async (request, response) => {
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await server.connect(transport);
});

// eslint-disable-next-line unicorn/prevent-abbreviations
app.post('/messages', async (req, res) => {
const sessionId = String(req.query.sessionId);
const transport = transports[sessionId];
if (transport) {
await transport.handlePostMessage(req, res, req.body);
} else {
res.status(400).send('No transport found for sessionId');
}
await transport.handleRequest(request, response, request.body);
response.on('close', () => {
transport.close();
});
});

app.listen(PORT);
55 changes: 21 additions & 34 deletions packages/sample-servers/src/whoami/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { configDotenv } from 'dotenv';
import express from 'express';
import {
Expand All @@ -25,13 +25,20 @@ const server = new McpServer({
});

// Add a tool to the server that returns the current user's information
server.tool('whoami', ({ authInfo }) => {
return {
content: [
{ type: 'text', text: JSON.stringify(authInfo?.claims ?? { error: 'Not authenticated' }) },
],
};
});
server.registerTool(
'whoami',
{
description: 'Get the current user information',
inputSchema: {},
},
(_params, { authInfo }) => {
return {
content: [
{ type: 'text', text: JSON.stringify(authInfo?.claims ?? { error: 'Not authenticated' }) },
],
};
}
);

const { MCP_AUTH_ISSUER } = process.env;

Expand Down Expand Up @@ -86,33 +93,13 @@ const app = express();
app.use(mcpAuth.delegatedRouter());
app.use(mcpAuth.bearerAuth(verifyToken));

// Below is the boilerplate code from MCP SDK documentation
const transports: Record<string, SSEServerTransport> = {};

// eslint-disable-next-line unicorn/prevent-abbreviations
app.get('/sse', async (_req, res) => {
// Create SSE transport for legacy clients
const transport = new SSEServerTransport('/messages', res);
// eslint-disable-next-line @silverhand/fp/no-mutation
transports[transport.sessionId] = transport;

res.on('close', () => {
// eslint-disable-next-line @silverhand/fp/no-delete, @typescript-eslint/no-dynamic-delete
delete transports[transport.sessionId];
});

app.post('/', async (request, response) => {
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await server.connect(transport);
});

// eslint-disable-next-line unicorn/prevent-abbreviations
app.post('/messages', async (req, res) => {
const sessionId = String(req.query.sessionId);
const transport = transports[sessionId];
if (transport) {
await transport.handlePostMessage(req, res, req.body);
} else {
res.status(400).send('No transport found for sessionId');
}
await transport.handleRequest(request, response, request.body);
response.on('close', () => {
void transport.close();
});
});

app.listen(PORT);
Loading