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
45 changes: 45 additions & 0 deletions SDK_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,51 @@ if __name__ == "__main__":

See [`src/conformance/everything-server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/conformance/everything-server.ts) in the TypeScript SDK for a reference implementation that handles all server scenarios.

### SEP-2350 Request-Time Scope Challenge Fixture

The draft server scenario `sep-2350-server-scope-challenge` tests request-time
OAuth `insufficient_scope` behavior without prescribing a token format or
requiring an authorization server. Conformance fixtures use two opaque tokens:

- `mcp-conformance-scope-low` is valid and has only
`mcp:conformance:baseline`.
- `mcp-conformance-scope-full` is valid and has every scope in the table below.

Keep requests without these fixture tokens unchanged so the existing server
scenarios continue to exercise unauthenticated fixtures.

| Operation | Existing fixture | Scopes required in one challenge |
| ------------------------- | -------------------------- | ------------------------------------------------------------------------ |
| `tools/call` | `test_simple_text` | `mcp:conformance:tools:call mcp:conformance:tools:test_simple_text` |
| Static `resources/read` | `test://static-text` | `mcp:conformance:resources:read mcp:conformance:resources:static` |
| Template `resources/read` | `test://template/123/data` | `mcp:conformance:resources:read mcp:conformance:resources:template:123` |
| `prompts/get` | `test_simple_prompt` | `mcp:conformance:prompts:get mcp:conformance:prompts:test_simple_prompt` |

For the low token, each operation returns HTTP 403 with a Bearer
`WWW-Authenticate` challenge containing `error="insufficient_scope"`,
`scope` containing both listed scopes, and a quoted, absolute
`resource_metadata` URL. The URL may be an explicitly advertised metadata
location, the root well-known location, or the RFC 9728 path-derived location:

```text
{server origin}/.well-known/oauth-protected-resource{server path}
```

For example, a server URL of `http://localhost:3000/mcp` may use
`http://localhost:3000/.well-known/oauth-protected-resource/mcp` or
`http://localhost:3000/.well-known/oauth-protected-resource`. Path-derived
locations preserve a meaningful trailing path slash and query component.
Parameter and scope ordering are not significant, and the server may include
additional scopes. Retrying the same operation with the full token must return
its normal successful MCP result. Protected-resource metadata discovery and
document-content checks remain covered by the authorization discovery
scenarios.

These static tokens are test inputs only. Production servers should use their
normal access-token verification, and this fixture does not replace the
separate conformance work for token validation or authorization-server
integration.

---

## Additional Resources
Expand Down
128 changes: 128 additions & 0 deletions examples/servers/typescript/sep-2350-no-scope-challenge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* Deliberately broken SEP-2350 fixture.
*
* It implements every primitive required by the scope-challenge scenario, but
* ignores the low-scope token and returns the normal result instead of HTTP 403.
*/
import { createServer } from 'node:http';

interface JsonRpcRequest {
jsonrpc: '2.0';
id: string | number;
method: string;
params?: Record<string, unknown>;
}

function resultFor(request: JsonRpcRequest): Record<string, unknown> {
if (
request.method === 'tools/call' &&
request.params?.name === 'test_simple_text'
) {
return {
resultType: 'complete',
content: [
{
type: 'text',
text: 'This is a simple text response for testing.'
}
]
};
}

if (request.method === 'resources/read') {
const uri = request.params?.uri;
if (uri === 'test://static-text') {
return {
resultType: 'complete',
ttlMs: 0,
cacheScope: 'private',
contents: [
{
uri,
mimeType: 'text/plain',
text: 'This is the content of the static text resource.'
}
]
};
}
if (uri === 'test://template/123/data') {
return {
resultType: 'complete',
ttlMs: 0,
cacheScope: 'private',
contents: [
{
uri,
mimeType: 'application/json',
text: JSON.stringify({
id: '123',
templateTest: true,
data: 'Data for ID: 123'
})
}
]
};
}
}

if (
request.method === 'prompts/get' &&
request.params?.name === 'test_simple_prompt'
) {
return {
resultType: 'complete',
messages: [
{
role: 'user',
content: {
type: 'text',
text: 'This is a simple prompt for testing.'
}
}
]
};
}

throw new Error(`Unsupported fixture request: ${request.method}`);
}

const server = createServer((req, res) => {
let rawBody = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
rawBody += chunk;
});
req.on('end', () => {
let response: Record<string, unknown>;
try {
const request = JSON.parse(rawBody) as JsonRpcRequest;
response = {
jsonrpc: '2.0',
id: request.id,
result: resultFor(request)
};
} catch (error) {
response = {
jsonrpc: '2.0',
id: null,
error: {
code: -32600,
message: error instanceof Error ? error.message : String(error)
}
};
}

res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(response));
});
});

const port = Number.parseInt(process.env.PORT ?? '3012', 10);
server.listen(port, '127.0.0.1', () => {
console.log(
`Broken SEP-2350 fixture running on http://127.0.0.1:${port}/mcp`
);
});

process.on('SIGTERM', () => server.close());
5 changes: 5 additions & 0 deletions requirements/2026-07-28.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,8 @@ not_scored:
reason: pending
note: >-
SEP-2243; pending against the reference fixture
- scenario: sep-2350-server-scope-challenge
leg: server
reason: added-after-release
note: >-
SEP-2350 server request-time scope challenges; targeted SDK runs exercise the portable fixture contract
49 changes: 6 additions & 43 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
runServerConformanceTest,
printServerResults,
printServerSummary,
serverExitCode,
runInteractiveMode
} from './runner';
import {
Expand Down Expand Up @@ -49,9 +50,9 @@ import {
Leg,
listRequirementRevisions,
loadRequirements,
notScoredScenarios,
REASONS,
RequirementSet,
requirementsExitCode,
scoredScenarios
} from './requirements';
import {
Expand Down Expand Up @@ -108,42 +109,6 @@ function resolveRequirements(
}
}

/**
* Exit status under a requirement set. Scenarios the set runs without scoring
* (extensions, post-release additions) are reported but must not fail the run:
* otherwise the documented command red-flags an implementation that meets every
* requirement the revision actually imposes.
*/
function requirementsExitCode(
requirements: RequirementSet,
leg: Leg,
results: { scenario: string; checks: ConformanceCheck[] }[]
): number {
const scored = new Set(scoredScenarios(requirements, leg));
const unscored = results.filter((r) => !scored.has(r.scenario));
if (unscored.length > 0) {
const failing = unscored.filter((r) =>
r.checks.some((c) => c.status === 'FAILURE')
);
console.log(
`\nNot scored for ${requirements.revision}: ${unscored.length} scenario(s) run, ${failing.length} failing. These do not affect conformance.`
);
for (const r of unscored) {
const why = notScoredScenarios(requirements, leg).find(
(e) => e.scenario === r.scenario
);
const failed = r.checks.some((c) => c.status === 'FAILURE');
console.log(
` ${failed ? '\u2717' : '\u2713'} ${r.scenario} (${why?.reason ?? 'not scored'})`
);
}
}
const scoredFailed = results
.filter((r) => scored.has(r.scenario))
.some((r) => r.checks.some((c) => c.status === 'FAILURE'));
return scoredFailed ? 1 : 0;
}

/** Print one revision's requirement set. `list` is display-only, so it can show several. */
function listOneRequirementSet(revision: string, specVersion?: string): void {
const requirements = resolveRequirements(revision, { specVersion })!;
Expand Down Expand Up @@ -597,7 +562,7 @@ program
process.exit(0);
}

const { failed } = printServerResults(
const { failed, warnings } = printServerResults(
result.checks,
result.scenarioDescription,
verbose
Expand All @@ -616,7 +581,7 @@ program
process.exit(baselineResult.exitCode);
}

process.exit(failed > 0 ? 1 : 0);
process.exit(serverExitCode(failed, warnings));
} else {
// Run scenarios based on suite
const suite = options.suite?.toLowerCase() || 'active';
Expand Down Expand Up @@ -698,7 +663,7 @@ program
}
}

const { totalFailed } = printServerSummary(allResults);
const { totalFailed, totalWarnings } = printServerSummary(allResults);

if (options.expectedFailures) {
const expectedFailuresConfig = await loadExpectedFailures(
Expand All @@ -723,9 +688,7 @@ program
process.exit(
requirements
? requirementsExitCode(requirements, 'server', allResults)
: totalFailed > 0
? 1
: 0
: serverExitCode(totalFailed, totalWarnings)
);
}
} catch (error) {
Expand Down
50 changes: 50 additions & 0 deletions src/requirements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
listRequirementRevisions,
loadRequirements,
notScoredScenarios,
requirementsExitCode,
scenariosToRun,
scoredScenarios
} from './requirements';
Expand Down Expand Up @@ -133,6 +134,55 @@ describe('filterScenariosByRequirements', () => {
expect(selected).toEqual(scenariosToRun(requirements, 'server'));
});

describe('requirementsExitCode', () => {
const requirements = {
revision: '2026-07-28',
server: ['scored-scenario'],
client: [],
notScored: [
{
scenario: 'post-release-scenario',
leg: 'server' as const,
reason: 'added-after-release' as const
}
]
};
const warningCheck = {
id: 'should-check',
name: 'ShouldCheck',
description: 'A SHOULD-level check',
status: 'WARNING' as const,
timestamp: new Date().toISOString()
};

it('fails when a scored scenario emits a warning', () => {
expect(
requirementsExitCode(requirements, 'server', [
{ scenario: 'scored-scenario', checks: [warningCheck] }
])
).toBe(1);
});

it('does not fail when only a not-scored scenario emits a warning', () => {
expect(
requirementsExitCode(requirements, 'server', [
{ scenario: 'post-release-scenario', checks: [warningCheck] }
])
).toBe(0);
});

it('does not fail when a scored scenario emits diagnostic information', () => {
expect(
requirementsExitCode(requirements, 'server', [
{
scenario: 'scored-scenario',
checks: [{ ...warningCheck, status: 'INFO' }]
}
])
).toBe(0);
});
});

it('runs the not-scored entries but keeps them out of the scored set', () => {
const selected = filterScenariosByRequirements(
listClientScenarios(),
Expand Down
Loading
Loading