Skip to content
Open
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
37 changes: 37 additions & 0 deletions examples/servers/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,40 @@ npx @modelcontextprotocol/conformance server \
```

**DO NOT** use this pattern in production servers. Always use `createMcpExpressApp()` or the `localhostHostValidation()` middleware for localhost servers.

### auth-prm-server.ts

A small auth-enabled server for the `auth/protected-resource-metadata` scenario. Requests without a bearer token get a `401` with a `WWW-Authenticate` challenge; the Protected Resource Metadata (RFC 9728) is served at the path-based well-known URL. It listens on port 3013 (`PORT` overrides).

`PRM_BREAK` picks one deliberate defect so each check has a negative case. Unknown values exit with the allowed list.

| `PRM_BREAK` | Effect | Expected result |
| -------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------- |
| `none` (default) | compliant | all checks pass |
| `no-prm` | no `resource_metadata`, no well-known document | `server-prm-discovery` fails, document checks untestable |
| `challenge-404` | `resource_metadata` points at a 404; well-known works | `server-prm-discovery` fails |
| `wrong-content-type` | metadata served as `text/html` | `server-prm-response` fails |
| `wrong-resource` | `resource` names another server | `server-prm-resource` fails |
| `missing-resource` | `resource` omitted | `server-prm-resource` fails |
| `missing-authorization-servers` | `authorization_servers` omitted | `server-prm-authorization-servers` fails |
| `empty-authorization-servers` | `authorization_servers` is `[]` | `server-prm-authorization-servers` and `server-prm-no-empty-parameters` fail |
| `empty-scopes-supported` | `scopes_supported` is `[]` | `server-prm-no-empty-parameters` fails |
| `empty-bearer-methods-supported` | `bearer_methods_supported` is `[]`, allowed by RFC 9728 §2 | all checks pass |
| `status-403` | `403` instead of `401` for a missing token | `server-auth-401` fails |
| `no-scope` | challenge omits `scope` | `server-www-authenticate-scope` warns |
| `well-known-only` | no `resource_metadata`; well-known works | passes at 2025-11-25+, discovery fails at 2025-06-18 |
| `root-well-known` | metadata only at the root URL, `resource` = origin | all checks pass |
| `no-auth` | no authorization at all | every check untestable |

```bash
# Run the compliant server
npx tsx auth-prm-server.ts

# Run one negative case
PRM_BREAK=no-scope npx tsx auth-prm-server.ts

# Grade it
npx @modelcontextprotocol/conformance server \
--url http://localhost:3013/mcp \
--scenario auth/protected-resource-metadata
```
163 changes: 163 additions & 0 deletions examples/servers/typescript/auth-prm-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
#!/usr/bin/env node

/**
* Auth-enabled fixture for the auth/protected-resource-metadata scenario.
*
* Requests without a bearer token get a 401 challenge; the Protected Resource
* Metadata (RFC 9728) is served at the path-based well-known URL. PRM_BREAK
* selects one deliberate defect so each check has a negative case:
* none compliant (default)
* no-prm no resource_metadata, no well-known document
* challenge-404 resource_metadata points at a 404; well-known works
* wrong-content-type metadata served as text/html
* wrong-resource resource names another server
* missing-resource resource omitted
* missing-authorization-servers authorization_servers omitted
* empty-authorization-servers authorization_servers is []
* empty-scopes-supported scopes_supported is []
* empty-bearer-methods-supported bearer_methods_supported is [] (allowed by RFC 9728 §2)
* status-403 403 instead of 401 for a missing token
* no-scope challenge omits scope
* well-known-only no resource_metadata; well-known works
* root-well-known metadata only at the root URL, resource = origin
* no-auth no authorization at all (2xx to every request)
*/

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express, { type Request, type Response } from 'express';
import { z } from 'zod';

const BREAK_MODES = [
'none',
'no-prm',
'challenge-404',
'wrong-content-type',
'wrong-resource',
'missing-resource',
'missing-authorization-servers',
'empty-authorization-servers',
'empty-scopes-supported',
'empty-bearer-methods-supported',
'status-403',
'no-scope',
'well-known-only',
'root-well-known',
'no-auth'
];

const PORT = parseInt(process.env.PORT || '3013', 10);
const BREAK = process.env.PRM_BREAK || 'none';
if (!BREAK_MODES.includes(BREAK)) {
console.error(
`Unknown PRM_BREAK "${BREAK}". Allowed: ${BREAK_MODES.join(', ')}`
);
process.exit(1);
}
const ORIGIN = `http://localhost:${PORT}`;
const MCP_URL = `${ORIGIN}/mcp`;
const AUTHORIZATION_SERVER = 'https://auth.example.com';
const PATH_PRM = '/.well-known/oauth-protected-resource/mcp';
const ROOT_PRM = '/.well-known/oauth-protected-resource';

function prmDocument(atRoot: boolean): Record<string, unknown> {
const doc: Record<string, unknown> = {
resource: atRoot ? ORIGIN : MCP_URL,
authorization_servers: [AUTHORIZATION_SERVER],
scopes_supported: ['mcp:read']
};
if (BREAK === 'wrong-resource') doc.resource = 'https://attacker.example/mcp';
if (BREAK === 'missing-resource') delete doc.resource;
if (BREAK === 'missing-authorization-servers')
delete doc.authorization_servers;
if (BREAK === 'empty-authorization-servers') doc.authorization_servers = [];
if (BREAK === 'empty-scopes-supported') doc.scopes_supported = [];
if (BREAK === 'empty-bearer-methods-supported')
doc.bearer_methods_supported = [];
return doc;
}

function challenge(): string {
const params: string[] = [];
if (BREAK === 'challenge-404') {
params.push(`resource_metadata="${ORIGIN}/missing-prm"`);
} else if (BREAK === 'root-well-known') {
params.push(`resource_metadata="${ORIGIN}${ROOT_PRM}"`);
} else if (BREAK !== 'no-prm' && BREAK !== 'well-known-only') {
params.push(`resource_metadata="${ORIGIN}${PATH_PRM}"`);
}
if (BREAK !== 'no-scope') params.push('scope="mcp:read"');
return `Bearer ${params.join(', ')}`;
}

function createMcpServer(): McpServer {
const server = new McpServer({
name: 'auth-prm-server',
version: '1.0.0'
});
server.registerTool(
'echo',
{
description: 'Echo the input back',
inputSchema: { message: z.string() }
},
async ({ message }) => ({
content: [{ type: 'text', text: `Echo: ${message}` }]
})
);
return server;
}

function sendPrm(res: Response, atRoot: boolean): void {
const body = JSON.stringify(prmDocument(atRoot));
if (BREAK === 'wrong-content-type') {
res.status(200).type('text/html').send(body);
return;
}
res.status(200).type('application/json').send(body);
}

const app = express();
app.use(express.json());

if (BREAK !== 'no-prm') {
if (BREAK === 'root-well-known') {
app.get(ROOT_PRM, (_req, res) => sendPrm(res, true));
} else {
app.get(PATH_PRM, (_req, res) => sendPrm(res, false));
}
}

app.post('/mcp', async (req: Request, res: Response) => {
const authorization = req.get('authorization') ?? '';
if (BREAK !== 'no-auth' && !/^bearer\s+\S+/i.test(authorization)) {
res
.status(BREAK === 'status-403' ? 403 : 401)
.set('WWW-Authenticate', challenge())
.json({ error: 'unauthorized' });
return;
}
try {
const server = createMcpServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: `Internal error: ${error instanceof Error ? error.message : String(error)}`
},
id: null
});
}
}
});

app.listen(PORT, '127.0.0.1', () => {
console.log(`Auth PRM server running on ${MCP_URL} (PRM_BREAK=${BREAK})`);
});
12 changes: 11 additions & 1 deletion src/scenarios/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import {
} from './server/prompts';

import { DNSRebindingProtectionScenario } from './server/dns-rebinding';
import { ProtectedResourceMetadataScenario } from './server/auth/protected-resource-metadata';
import { CachingScenario } from './server/caching';

// InputRequiredResult scenarios from (SEP-2322)
Expand Down Expand Up @@ -166,7 +167,13 @@ const pendingClientScenariosList: ClientScenario[] = [
// `npm start -- server --scenario sep-2640-skills-* --url <fixture>`.
new SkillsDirectoryReadScenario(),
new SkillsEnumerationScenario(),
new SkillsManifestScenario()
new SkillsManifestScenario(),

// Protected Resource Metadata discovery (RFC 9728). Pending because the
// everything-server does not run with authorization enabled; targeted runs
// point at an auth-enabled server via
// `npm start -- server --scenario auth/protected-resource-metadata --url <server>`.
new ProtectedResourceMetadataScenario()
];

// All client scenarios
Expand Down Expand Up @@ -234,6 +241,9 @@ const allClientScenariosList: ClientScenario[] = [
// Security scenarios
new DNSRebindingProtectionScenario(),

// Authorization scenarios
new ProtectedResourceMetadataScenario(),

// Caching scenarios (SEP-2549)
new CachingScenario(),
// HTTP Standardization scenarios (SEP-2243)
Expand Down
Loading
Loading