Skip to content

Commit 23b61b6

Browse files
committed
feat: add step-up auth support to auth-test-server
- Add admin-action tool requiring 'admin' scope - Add scope-checking middleware for privileged tools - Returns 403 insufficient_scope for missing admin scope - Add scopes_supported to PRM response
1 parent af815c6 commit 23b61b6

2 files changed

Lines changed: 95 additions & 5 deletions

File tree

src/conformance/auth-test-server.ts

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,17 @@ import {
2121
} from '@modelcontextprotocol/server';
2222
import type { OAuthTokenVerifier, AuthInfo } from '@modelcontextprotocol/server';
2323
import { z } from 'zod';
24-
import express, { Request, Response } from 'express';
24+
import express, { Request, Response, NextFunction } from 'express';
2525
import cors from 'cors';
2626
import { randomUUID } from 'crypto';
2727

28+
// Extend Express Request type to include auth info from SDK middleware
29+
declare module 'express' {
30+
interface Request {
31+
auth?: AuthInfo;
32+
}
33+
}
34+
2835
// Check for required environment variable
2936
const AUTH_SERVER_URL = process.env.MCP_CONFORMANCE_AUTH_SERVER_URL;
3037
if (!AUTH_SERVER_URL) {
@@ -86,6 +93,23 @@ function createMcpServer(): McpServer {
8693
}
8794
);
8895

96+
// Privileged tool requiring 'admin' scope - for step-up auth testing
97+
mcpServer.tool(
98+
'admin-action',
99+
'A privileged action that requires admin scope - used for step-up auth testing',
100+
{
101+
action: z.string().optional().describe('The admin action to perform')
102+
},
103+
async (args: { action?: string }) => {
104+
const action = args.action || 'default-admin-action';
105+
return {
106+
content: [
107+
{ type: 'text', text: `Admin action performed: ${action}` }
108+
]
109+
};
110+
}
111+
);
112+
89113
return mcpServer;
90114
}
91115

@@ -154,6 +178,67 @@ function isInitializeRequest(body: unknown): boolean {
154178
);
155179
}
156180

181+
// Helper to check if request is a tools/call for admin-action
182+
function isAdminToolCall(body: unknown): boolean {
183+
if (
184+
typeof body !== 'object' ||
185+
body === null ||
186+
!('method' in body) ||
187+
!('params' in body)
188+
) {
189+
return false;
190+
}
191+
const { method, params } = body as { method: string; params: unknown };
192+
if (method !== 'tools/call') {
193+
return false;
194+
}
195+
if (
196+
typeof params !== 'object' ||
197+
params === null ||
198+
!('name' in params)
199+
) {
200+
return false;
201+
}
202+
return (params as { name: string }).name === 'admin-action';
203+
}
204+
205+
// Scope required for admin-action tool
206+
const ADMIN_SCOPE = 'admin';
207+
208+
/**
209+
* Middleware to check for admin scope on privileged tool calls.
210+
* Returns 403 insufficient_scope if the token doesn't have admin scope.
211+
*/
212+
function checkAdminScope(prmUrl: string) {
213+
return (req: Request, res: Response, next: NextFunction): void => {
214+
// Only check for tools/call with admin-action
215+
if (!isAdminToolCall(req.body)) {
216+
return next();
217+
}
218+
219+
// req.auth is set by requireBearerAuth middleware
220+
const scopes = req.auth?.scopes || [];
221+
222+
if (!scopes.includes(ADMIN_SCOPE)) {
223+
// Return 403 with insufficient_scope error
224+
res.setHeader(
225+
'WWW-Authenticate',
226+
`Bearer error="insufficient_scope", ` +
227+
`scope="${ADMIN_SCOPE}", ` +
228+
`resource_metadata="${prmUrl}", ` +
229+
`error_description="The admin-action tool requires admin scope"`
230+
);
231+
res.status(403).json({
232+
error: 'insufficient_scope',
233+
error_description: 'The admin-action tool requires admin scope'
234+
});
235+
return;
236+
}
237+
238+
next();
239+
};
240+
}
241+
157242
// ===== EXPRESS APP =====
158243

159244
async function startServer() {
@@ -186,6 +271,9 @@ async function startServer() {
186271
resourceMetadataUrl: prmUrl
187272
});
188273
274+
// Create scope-checking middleware for privileged tools
275+
const adminScopeCheck = checkAdminScope(prmUrl);
276+
189277
const app = express();
190278
app.use(express.json());
191279
@@ -209,13 +297,15 @@ async function startServer() {
209297
(_req: Request, res: Response) => {
210298
res.json({
211299
resource: getBaseUrl(),
212-
authorization_servers: [AUTH_SERVER_URL]
300+
authorization_servers: [AUTH_SERVER_URL],
301+
// List supported scopes for step-up auth testing
302+
scopes_supported: [ADMIN_SCOPE]
213303
});
214304
}
215305
);
216306
217-
// Handle POST requests to /mcp with bearer auth
218-
app.post('/mcp', bearerAuth, async (req: Request, res: Response) => {
307+
// Handle POST requests to /mcp with bearer auth and scope checking
308+
app.post('/mcp', bearerAuth, adminScopeCheck, async (req: Request, res: Response) => {
219309
const sessionId = req.headers['mcp-session-id'] as string | undefined;
220310
221311
try {

test/integration/test/taskResumability.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ import { randomUUID } from 'node:crypto';
22
import { createServer, type Server } from 'node:http';
33

44
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
5+
import type { EventStore, JSONRPCMessage } from '@modelcontextprotocol/server';
56
import {
67
CallToolResultSchema,
78
LoggingMessageNotificationSchema,
89
McpServer,
910
StreamableHTTPServerTransport
1011
} from '@modelcontextprotocol/server';
11-
import type { EventStore, JSONRPCMessage } from '@modelcontextprotocol/server';
1212
import type { ZodMatrixEntry } from '@modelcontextprotocol/test-helpers';
1313
import { listenOnRandomPort, zodTestMatrix } from '@modelcontextprotocol/test-helpers';
1414

0 commit comments

Comments
 (0)