[scenario] authz metadata discovery tests: compatibility with 2025-03-26 spec, prm root, and www-authenticate (#28)
* clean up constructor calls for server lifecycle * add back compat test * fix dup logging, rm prm * avoid fallbacks * better error from example client * add final endpoint method * add test for OASM priority order * s/prepack/prepare/ for npx * try switching to .js * make tsdown version happy default * fix lock * fix prmPath issue * back to main package-lock * fix bin path * Add comprehensive auth scenario test suite - Add index.test.ts to test all auth scenarios with auth-test.ts client - Upgrade @modelcontextprotocol/sdk from 1.20.1 to 1.22.0 - All 5 auth scenarios now pass including auth/basic-metadata-var2 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * consolidate tests into index.test.ts * rename metadata * fix comments * rework auth scenario listing * add 3rd variation of metadata * fix some type issues introduced by version bump * add reusable spec references * more refs * fix up spec references --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
38443d1a43
commit
f7c8c4460f
20 changed files with 724 additions and 230 deletions
|
|
@ -67,7 +67,9 @@ export class ConformanceOAuthProvider implements OAuthClientProvider {
|
||||||
throw new Error('No auth code in redirect URL');
|
throw new Error('No auth code in redirect URL');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error('No redirect location received');
|
throw new Error(
|
||||||
|
`No redirect location received, from '${authorizationUrl.toString()}'`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch authorization URL:', error);
|
console.error('Failed to fetch authorization URL:', error);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ export const handle401 = async (
|
||||||
serverUrl: string | URL
|
serverUrl: string | URL
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const resourceMetadataUrl = extractResourceMetadataUrl(response);
|
const resourceMetadataUrl = extractResourceMetadataUrl(response);
|
||||||
|
|
||||||
let result = await auth(provider, {
|
let result = await auth(provider, {
|
||||||
serverUrl,
|
serverUrl,
|
||||||
resourceMetadataUrl,
|
resourceMetadataUrl,
|
||||||
|
|
|
||||||
113
package-lock.json
generated
113
package-lock.json
generated
|
|
@ -9,14 +9,14 @@
|
||||||
"version": "0.1.5",
|
"version": "0.1.5",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.22.0",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"lefthook": "^2.0.2",
|
"lefthook": "^2.0.2",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"conformance": "dist/index.mjs"
|
"conformance": "dist/index.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.8.0",
|
"@eslint/js": "^9.8.0",
|
||||||
|
|
@ -838,11 +838,13 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.20.2",
|
"version": "1.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.2.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.22.0.tgz",
|
||||||
"integrity": "sha512-6rqTdFt67AAAzln3NOKsXRmv5ZzPkgbfaebKBqUbts7vK1GZudqnrun5a8d3M/h955cam9RHZ6Jb4Y1XhnmFPg==",
|
"integrity": "sha512-VUpl106XVTCpDmTBil2ehgJZjhyLY2QZikzF8NvTXtLRF1CvO5iEE2UNZdVIUer35vFOwMKYeUGbjJtvPWan3g==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^6.12.6",
|
"ajv": "^8.17.1",
|
||||||
|
"ajv-formats": "^3.0.1",
|
||||||
"content-type": "^1.0.5",
|
"content-type": "^1.0.5",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"cross-spawn": "^7.0.5",
|
"cross-spawn": "^7.0.5",
|
||||||
|
|
@ -857,8 +859,38 @@
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@cfworker/json-schema": "^4.1.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@cfworker/json-schema": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
|
||||||
|
"version": "8.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||||
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"fast-uri": "^3.0.1",
|
||||||
|
"json-schema-traverse": "^1.0.0",
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz",
|
||||||
|
|
@ -2157,6 +2189,7 @@
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
|
|
@ -2169,6 +2202,45 @@
|
||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ajv-formats": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ajv": "^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"ajv": "^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"ajv": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ajv-formats/node_modules/ajv": {
|
||||||
|
"version": "8.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||||
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"fast-uri": "^3.0.1",
|
||||||
|
"json-schema-traverse": "^1.0.0",
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ajv-formats/node_modules/json-schema-traverse": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ansi-styles": {
|
"node_modules/ansi-styles": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
|
@ -3034,6 +3106,7 @@
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-levenshtein": {
|
"node_modules/fast-levenshtein": {
|
||||||
|
|
@ -3043,6 +3116,22 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-uri": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/fastq": {
|
"node_modules/fastq": {
|
||||||
"version": "1.19.1",
|
"version": "1.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||||
|
|
@ -3521,6 +3610,7 @@
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/json-stable-stringify-without-jsonify": {
|
"node_modules/json-stable-stringify-without-jsonify": {
|
||||||
|
|
@ -4129,6 +4219,7 @@
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
|
|
@ -4241,6 +4332,15 @@
|
||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve-from": {
|
"node_modules/resolve-from": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||||
|
|
@ -4942,6 +5042,7 @@
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||||
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"bin": {
|
"bin": {
|
||||||
"conformance": "dist/index.mjs"
|
"conformance": "dist/index.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.8.0",
|
"@eslint/js": "^9.8.0",
|
||||||
|
|
@ -43,7 +43,7 @@
|
||||||
"vitest": "^4.0.5"
|
"vitest": "^4.0.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.22.0",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"lefthook": "^2.0.2",
|
"lefthook": "^2.0.2",
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import {
|
|
||||||
runClientAgainstScenario,
|
|
||||||
SpawnedClientRunner
|
|
||||||
} from './test_helpers/testClient.js';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
describe('PRM Path-Based Discovery', () => {
|
|
||||||
test('client discovers PRM at path-based location before root', async () => {
|
|
||||||
const clientPath = path.join(
|
|
||||||
process.cwd(),
|
|
||||||
'examples/clients/typescript/auth-test.ts'
|
|
||||||
);
|
|
||||||
const runner = new SpawnedClientRunner(clientPath);
|
|
||||||
await runClientAgainstScenario(runner, 'auth/basic-dcr');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('bad client requests root PRM location', async () => {
|
|
||||||
const clientPath = path.join(
|
|
||||||
process.cwd(),
|
|
||||||
'examples/clients/typescript/auth-test-broken1.ts'
|
|
||||||
);
|
|
||||||
const runner = new SpawnedClientRunner(clientPath);
|
|
||||||
await runClientAgainstScenario(runner, 'auth/basic-dcr', [
|
|
||||||
// There will be other failures, but this is the one that matters
|
|
||||||
'prm-priority-order'
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -4,27 +4,26 @@ import { createAuthServer } from './helpers/createAuthServer.js';
|
||||||
import { createServer } from './helpers/createServer.js';
|
import { createServer } from './helpers/createServer.js';
|
||||||
import { ServerLifecycle } from './helpers/serverLifecycle.js';
|
import { ServerLifecycle } from './helpers/serverLifecycle.js';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { SpecReferences } from './spec-references.js';
|
||||||
|
|
||||||
export class AuthBasicDCRScenario implements Scenario {
|
export class AuthBasicDCRScenario implements Scenario {
|
||||||
name = 'auth/basic-dcr';
|
name = 'auth/basic-dcr';
|
||||||
description =
|
description =
|
||||||
'Tests Basic OAuth flow with DCR, PRM at path-based location, OAuth metadata at root location, and no scopes required';
|
'Tests Basic OAuth flow with DCR, PRM at path-based location, OAuth metadata at root location, and no scopes required';
|
||||||
private authServer = new ServerLifecycle(() => this.authBaseUrl);
|
private authServer = new ServerLifecycle();
|
||||||
private server = new ServerLifecycle(() => this.baseUrl);
|
private server = new ServerLifecycle();
|
||||||
private checks: ConformanceCheck[] = [];
|
private checks: ConformanceCheck[] = [];
|
||||||
private baseUrl: string = '';
|
|
||||||
private authBaseUrl: string = '';
|
|
||||||
|
|
||||||
async start(): Promise<ScenarioUrls> {
|
async start(): Promise<ScenarioUrls> {
|
||||||
this.checks = [];
|
this.checks = [];
|
||||||
|
|
||||||
const authApp = createAuthServer(this.checks, () => this.authBaseUrl);
|
const authApp = createAuthServer(this.checks, this.authServer.getUrl);
|
||||||
this.authBaseUrl = await this.authServer.start(authApp);
|
await this.authServer.start(authApp);
|
||||||
|
|
||||||
const app = createServer(
|
const app = createServer(
|
||||||
this.checks,
|
this.checks,
|
||||||
() => this.baseUrl,
|
this.server.getUrl,
|
||||||
() => this.authBaseUrl
|
this.authServer.getUrl
|
||||||
);
|
);
|
||||||
|
|
||||||
// For this scenario, reject PRM requests at root location since we have the path-based PRM.
|
// For this scenario, reject PRM requests at root location since we have the path-based PRM.
|
||||||
|
|
@ -39,10 +38,8 @@ export class AuthBasicDCRScenario implements Scenario {
|
||||||
status: 'FAILURE',
|
status: 'FAILURE',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
specReferences: [
|
specReferences: [
|
||||||
{
|
SpecReferences.RFC_PRM_DISCOVERY,
|
||||||
id: 'mcp-authorization-prm',
|
SpecReferences.MCP_PRM_DISCOVERY
|
||||||
url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#protected-resource-metadata-discovery-requirements'
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
details: {
|
details: {
|
||||||
url: req.url,
|
url: req.url,
|
||||||
|
|
@ -58,9 +55,9 @@ export class AuthBasicDCRScenario implements Scenario {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
this.baseUrl = await this.server.start(app);
|
await this.server.start(app);
|
||||||
|
|
||||||
return { serverUrl: `${this.baseUrl}/mcp` };
|
return { serverUrl: `${this.server.getUrl()}/mcp` };
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop() {
|
async stop() {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import {
|
|
||||||
runClientAgainstScenario,
|
|
||||||
SpawnedClientRunner
|
|
||||||
} from './test_helpers/testClient.js';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
describe('OAuth Metadata at OpenID Configuration Path', () => {
|
|
||||||
test('client discovers OAuth metadata at OpenID configuration path', async () => {
|
|
||||||
const clientPath = path.join(
|
|
||||||
process.cwd(),
|
|
||||||
'examples/clients/typescript/auth-test.ts'
|
|
||||||
);
|
|
||||||
const runner = new SpawnedClientRunner(clientPath);
|
|
||||||
await runClientAgainstScenario(runner, 'auth/basic-metadata-var1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
import type { Scenario, ConformanceCheck } from '../../../types.js';
|
|
||||||
import { ScenarioUrls } from '../../../types.js';
|
|
||||||
import { createAuthServer } from './helpers/createAuthServer.js';
|
|
||||||
import { createServer } from './helpers/createServer.js';
|
|
||||||
import { ServerLifecycle } from './helpers/serverLifecycle.js';
|
|
||||||
|
|
||||||
export class AuthBasicMetadataVar1Scenario implements Scenario {
|
|
||||||
// TODO: name should match what we put in the scenario map
|
|
||||||
name = 'auth/basic-metadata-var1';
|
|
||||||
description =
|
|
||||||
'Tests Basic OAuth flow with DCR, PRM at root location, OAuth metadata at OpenID discovery path, and no scopes required';
|
|
||||||
private authServer = new ServerLifecycle(() => this.authBaseUrl);
|
|
||||||
private server = new ServerLifecycle(() => this.baseUrl);
|
|
||||||
private checks: ConformanceCheck[] = [];
|
|
||||||
private baseUrl: string = '';
|
|
||||||
private authBaseUrl: string = '';
|
|
||||||
|
|
||||||
async start(): Promise<ScenarioUrls> {
|
|
||||||
this.checks = [];
|
|
||||||
|
|
||||||
const authApp = createAuthServer(this.checks, () => this.authBaseUrl, {
|
|
||||||
metadataPath: '/.well-known/openid-configuration',
|
|
||||||
isOpenIdConfiguration: true
|
|
||||||
});
|
|
||||||
this.authBaseUrl = await this.authServer.start(authApp);
|
|
||||||
|
|
||||||
const app = createServer(
|
|
||||||
this.checks,
|
|
||||||
() => this.baseUrl,
|
|
||||||
() => this.authBaseUrl,
|
|
||||||
{
|
|
||||||
// TODO: this will put this path in the WWW-Authenticate header
|
|
||||||
// but RFC 9728 states that in that case, the resource in the PRM
|
|
||||||
// must match the URL used to make the request to the resource server.
|
|
||||||
// We'll need to establish an opinion on whether that means the
|
|
||||||
// URL for the metadata fetch, or the URL for the MCP endpoint,
|
|
||||||
// or more generally what are the valid scenarios / combos.
|
|
||||||
prmPath: '/.well-known/oauth-protected-resource'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
this.baseUrl = await this.server.start(app);
|
|
||||||
|
|
||||||
return { serverUrl: `${this.baseUrl}/mcp` };
|
|
||||||
}
|
|
||||||
|
|
||||||
async stop() {
|
|
||||||
await this.authServer.stop();
|
|
||||||
await this.server.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
getChecks(): ConformanceCheck[] {
|
|
||||||
const expectedSlugs = [
|
|
||||||
'authorization-server-metadata',
|
|
||||||
'client-registration',
|
|
||||||
'authorization-request',
|
|
||||||
'token-request'
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const slug of expectedSlugs) {
|
|
||||||
if (!this.checks.find((c) => c.id === slug)) {
|
|
||||||
this.checks.push({
|
|
||||||
id: slug,
|
|
||||||
name: `Expected Check Missing: ${slug}`,
|
|
||||||
description: `Expected Check Missing: ${slug}`,
|
|
||||||
status: 'FAILURE',
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.checks;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
210
src/scenarios/client/auth/basic-metadata.ts
Normal file
210
src/scenarios/client/auth/basic-metadata.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
import type { Scenario, ConformanceCheck } from '../../../types.js';
|
||||||
|
import { ScenarioUrls } from '../../../types.js';
|
||||||
|
import { createAuthServer } from './helpers/createAuthServer.js';
|
||||||
|
import { createServer } from './helpers/createServer.js';
|
||||||
|
import { ServerLifecycle } from './helpers/serverLifecycle.js';
|
||||||
|
import { SpecReferences } from './spec-references.js';
|
||||||
|
|
||||||
|
export class AuthBasicMetadataVar1Scenario implements Scenario {
|
||||||
|
name = 'auth/basic-metadata-var1';
|
||||||
|
description =
|
||||||
|
'Tests Basic OAuth flow with DCR, PRM at root location, OAuth metadata at OpenID discovery path, and no scopes required';
|
||||||
|
private authServer = new ServerLifecycle();
|
||||||
|
private server = new ServerLifecycle();
|
||||||
|
private checks: ConformanceCheck[] = [];
|
||||||
|
|
||||||
|
async start(): Promise<ScenarioUrls> {
|
||||||
|
this.checks = [];
|
||||||
|
|
||||||
|
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
|
||||||
|
metadataPath: '/.well-known/openid-configuration',
|
||||||
|
isOpenIdConfiguration: true
|
||||||
|
});
|
||||||
|
await this.authServer.start(authApp);
|
||||||
|
|
||||||
|
const app = createServer(
|
||||||
|
this.checks,
|
||||||
|
this.server.getUrl,
|
||||||
|
this.authServer.getUrl,
|
||||||
|
{
|
||||||
|
prmPath: '/.well-known/oauth-protected-resource'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await this.server.start(app);
|
||||||
|
|
||||||
|
return { serverUrl: `${this.server.getUrl()}/mcp` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
await this.authServer.stop();
|
||||||
|
await this.server.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
getChecks(): ConformanceCheck[] {
|
||||||
|
const expectedSlugs = [
|
||||||
|
'authorization-server-metadata',
|
||||||
|
'client-registration',
|
||||||
|
'authorization-request',
|
||||||
|
'token-request'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const slug of expectedSlugs) {
|
||||||
|
if (!this.checks.find((c) => c.id === slug)) {
|
||||||
|
this.checks.push({
|
||||||
|
id: slug,
|
||||||
|
name: `Expected Check Missing: ${slug}`,
|
||||||
|
description: `Expected Check Missing: ${slug}`,
|
||||||
|
status: 'FAILURE',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.checks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthBasicMetadataVar2Scenario implements Scenario {
|
||||||
|
name = 'auth/basic-metadata-var2';
|
||||||
|
description =
|
||||||
|
'Tests Basic OAuth flow with DCR, PRM at root location, OAuth metadata at path-based OAuth discovery path';
|
||||||
|
private authServer = new ServerLifecycle();
|
||||||
|
private server = new ServerLifecycle();
|
||||||
|
private checks: ConformanceCheck[] = [];
|
||||||
|
|
||||||
|
async start(): Promise<ScenarioUrls> {
|
||||||
|
this.checks = [];
|
||||||
|
|
||||||
|
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
|
||||||
|
metadataPath: '/tenant1/.well-known/openid-configuration',
|
||||||
|
isOpenIdConfiguration: true,
|
||||||
|
routePrefix: '/tenant1'
|
||||||
|
});
|
||||||
|
|
||||||
|
authApp.get('/.well-known/oauth-authorization-server', (req, res) => {
|
||||||
|
this.checks.push({
|
||||||
|
id: 'authorization-server-metadata-wrong-path',
|
||||||
|
name: 'AuthorizationServerMetadataWrongPath',
|
||||||
|
description:
|
||||||
|
'Client requested authorization server at the root path when the AS URL has a path-based location',
|
||||||
|
status: 'FAILURE',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
specReferences: [
|
||||||
|
SpecReferences.RFC_AUTH_SERVER_METADATA_REQUEST,
|
||||||
|
SpecReferences.MCP_AUTH_DISCOVERY
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
url: req.url
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.status(404).send('Not Found');
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.authServer.start(authApp);
|
||||||
|
|
||||||
|
const app = createServer(
|
||||||
|
this.checks,
|
||||||
|
this.server.getUrl,
|
||||||
|
() => `${this.authServer.getUrl()}/tenant1`,
|
||||||
|
{
|
||||||
|
prmPath: '/.well-known/oauth-protected-resource'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await this.server.start(app);
|
||||||
|
|
||||||
|
return { serverUrl: `${this.server.getUrl()}/mcp` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
await this.authServer.stop();
|
||||||
|
await this.server.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
getChecks(): ConformanceCheck[] {
|
||||||
|
const expectedSlugs = [
|
||||||
|
'authorization-server-metadata',
|
||||||
|
'client-registration',
|
||||||
|
'authorization-request',
|
||||||
|
'token-request'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const slug of expectedSlugs) {
|
||||||
|
if (!this.checks.find((c) => c.id === slug)) {
|
||||||
|
this.checks.push({
|
||||||
|
id: slug,
|
||||||
|
name: `Expected Check Missing: ${slug}`,
|
||||||
|
description: `Expected Check Missing: ${slug}`,
|
||||||
|
status: 'FAILURE',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.checks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthBasicMetadataVar3Scenario implements Scenario {
|
||||||
|
name = 'auth/basic-metadata-var3';
|
||||||
|
description =
|
||||||
|
'Tests Basic OAuth flow with DCR, PRM at custom location listed in WWW-Authenticate header, OAuth metadata is at nested OpenID discovery path, and no scopes required';
|
||||||
|
private authServer = new ServerLifecycle();
|
||||||
|
private server = new ServerLifecycle();
|
||||||
|
private checks: ConformanceCheck[] = [];
|
||||||
|
|
||||||
|
async start(): Promise<ScenarioUrls> {
|
||||||
|
this.checks = [];
|
||||||
|
|
||||||
|
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
|
||||||
|
metadataPath: '/tenant1/.well-known/openid-configuration',
|
||||||
|
isOpenIdConfiguration: true,
|
||||||
|
routePrefix: '/tenant1'
|
||||||
|
});
|
||||||
|
await this.authServer.start(authApp);
|
||||||
|
|
||||||
|
const app = createServer(
|
||||||
|
this.checks,
|
||||||
|
this.server.getUrl,
|
||||||
|
() => {
|
||||||
|
return `${this.authServer.getUrl()}/tenant1`;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// This is a custom path, so unable to get via probing, it's only available
|
||||||
|
// via following the `resource_metadata_url` in the WWW-Authenticate header.
|
||||||
|
// The resource must match the original request URL per RFC 9728.
|
||||||
|
prmPath: '/custom/metadata/location.json'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await this.server.start(app);
|
||||||
|
|
||||||
|
return { serverUrl: `${this.server.getUrl()}/mcp` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
await this.authServer.stop();
|
||||||
|
await this.server.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
getChecks(): ConformanceCheck[] {
|
||||||
|
const expectedSlugs = [
|
||||||
|
'authorization-server-metadata',
|
||||||
|
'client-registration',
|
||||||
|
'authorization-request',
|
||||||
|
'token-request'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const slug of expectedSlugs) {
|
||||||
|
if (!this.checks.find((c) => c.id === slug)) {
|
||||||
|
this.checks.push({
|
||||||
|
id: slug,
|
||||||
|
name: `Expected Check Missing: ${slug}`,
|
||||||
|
description: `Expected Check Missing: ${slug}`,
|
||||||
|
status: 'FAILURE',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.checks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
import type { ConformanceCheck } from '../../../../types.js';
|
import type { ConformanceCheck } from '../../../../types.js';
|
||||||
import { createRequestLogger } from '../../../request-logger.js';
|
import { createRequestLogger } from '../../../request-logger.js';
|
||||||
|
import { SpecReferences } from '../spec-references.js';
|
||||||
|
|
||||||
export interface AuthServerOptions {
|
export interface AuthServerOptions {
|
||||||
metadataPath?: string;
|
metadataPath?: string;
|
||||||
isOpenIdConfiguration?: boolean;
|
isOpenIdConfiguration?: boolean;
|
||||||
|
loggingEnabled?: boolean;
|
||||||
|
routePrefix?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAuthServer(
|
export function createAuthServer(
|
||||||
|
|
@ -14,18 +17,29 @@ export function createAuthServer(
|
||||||
): express.Application {
|
): express.Application {
|
||||||
const {
|
const {
|
||||||
metadataPath = '/.well-known/oauth-authorization-server',
|
metadataPath = '/.well-known/oauth-authorization-server',
|
||||||
isOpenIdConfiguration = false
|
isOpenIdConfiguration = false,
|
||||||
|
loggingEnabled = true,
|
||||||
|
routePrefix = ''
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
|
const authRoutes = {
|
||||||
|
authorization_endpoint: `${routePrefix}/authorize`,
|
||||||
|
token_endpoint: `${routePrefix}/token`,
|
||||||
|
registration_endpoint: `${routePrefix}/register`
|
||||||
|
};
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
app.use(
|
if (loggingEnabled) {
|
||||||
createRequestLogger(checks, {
|
app.use(
|
||||||
incomingId: 'incoming-auth-request',
|
createRequestLogger(checks, {
|
||||||
outgoingId: 'outgoing-auth-response'
|
incomingId: 'incoming-auth-request',
|
||||||
})
|
outgoingId: 'outgoing-auth-response'
|
||||||
);
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
app.get(metadataPath, (req: Request, res: Response) => {
|
app.get(metadataPath, (req: Request, res: Response) => {
|
||||||
checks.push({
|
checks.push({
|
||||||
|
|
@ -35,10 +49,8 @@ export function createAuthServer(
|
||||||
status: 'SUCCESS',
|
status: 'SUCCESS',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
specReferences: [
|
specReferences: [
|
||||||
{
|
SpecReferences.RFC_AUTH_SERVER_METADATA_REQUEST,
|
||||||
id: 'RFC-8414',
|
SpecReferences.MCP_AUTH_DISCOVERY
|
||||||
url: 'https://tools.ietf.org/html/rfc8414'
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
details: {
|
details: {
|
||||||
url: req.url,
|
url: req.url,
|
||||||
|
|
@ -48,9 +60,9 @@ export function createAuthServer(
|
||||||
|
|
||||||
const metadata: any = {
|
const metadata: any = {
|
||||||
issuer: getAuthBaseUrl(),
|
issuer: getAuthBaseUrl(),
|
||||||
authorization_endpoint: `${getAuthBaseUrl()}/authorize`,
|
authorization_endpoint: `${getAuthBaseUrl()}${authRoutes.authorization_endpoint}`,
|
||||||
token_endpoint: `${getAuthBaseUrl()}/token`,
|
token_endpoint: `${getAuthBaseUrl()}${authRoutes.token_endpoint}`,
|
||||||
registration_endpoint: `${getAuthBaseUrl()}/register`,
|
registration_endpoint: `${getAuthBaseUrl()}${authRoutes.registration_endpoint}`,
|
||||||
response_types_supported: ['code'],
|
response_types_supported: ['code'],
|
||||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||||
code_challenge_methods_supported: ['S256'],
|
code_challenge_methods_supported: ['S256'],
|
||||||
|
|
@ -67,19 +79,14 @@ export function createAuthServer(
|
||||||
res.json(metadata);
|
res.json(metadata);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/authorize', (req: Request, res: Response) => {
|
app.get(authRoutes.authorization_endpoint, (req: Request, res: Response) => {
|
||||||
checks.push({
|
checks.push({
|
||||||
id: 'authorization-request',
|
id: 'authorization-request',
|
||||||
name: 'AuthorizationRequest',
|
name: 'AuthorizationRequest',
|
||||||
description: 'Client made authorization request',
|
description: 'Client made authorization request',
|
||||||
status: 'SUCCESS',
|
status: 'SUCCESS',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
specReferences: [
|
specReferences: [SpecReferences.OAUTH_2_1_AUTHORIZATION_ENDPOINT],
|
||||||
{
|
|
||||||
id: 'RFC-6749-4.1.1',
|
|
||||||
url: 'https://tools.ietf.org/html/rfc6749#section-4.1.1'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
details: {
|
details: {
|
||||||
response_type: req.query.response_type,
|
response_type: req.query.response_type,
|
||||||
client_id: req.query.client_id,
|
client_id: req.query.client_id,
|
||||||
|
|
@ -101,19 +108,14 @@ export function createAuthServer(
|
||||||
res.redirect(redirectUrl.toString());
|
res.redirect(redirectUrl.toString());
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/token', (req: Request, res: Response) => {
|
app.post(authRoutes.token_endpoint, (req: Request, res: Response) => {
|
||||||
checks.push({
|
checks.push({
|
||||||
id: 'token-request',
|
id: 'token-request',
|
||||||
name: 'TokenRequest',
|
name: 'TokenRequest',
|
||||||
description: 'Client requested access token',
|
description: 'Client requested access token',
|
||||||
status: 'SUCCESS',
|
status: 'SUCCESS',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
specReferences: [
|
specReferences: [SpecReferences.OAUTH_2_1_TOKEN],
|
||||||
{
|
|
||||||
id: 'RFC-6749-4.1.3',
|
|
||||||
url: 'https://tools.ietf.org/html/rfc6749#section-4.1.3'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
details: {
|
details: {
|
||||||
endpoint: '/token',
|
endpoint: '/token',
|
||||||
grantType: req.body.grant_type
|
grantType: req.body.grant_type
|
||||||
|
|
@ -127,19 +129,14 @@ export function createAuthServer(
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/register', (req: Request, res: Response) => {
|
app.post(authRoutes.registration_endpoint, (req: Request, res: Response) => {
|
||||||
checks.push({
|
checks.push({
|
||||||
id: 'client-registration',
|
id: 'client-registration',
|
||||||
name: 'ClientRegistration',
|
name: 'ClientRegistration',
|
||||||
description: 'Client registered with authorization server',
|
description: 'Client registered with authorization server',
|
||||||
status: 'SUCCESS',
|
status: 'SUCCESS',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
specReferences: [
|
specReferences: [SpecReferences.MCP_DCR],
|
||||||
{
|
|
||||||
id: 'RFC-7591-2',
|
|
||||||
url: 'https://tools.ietf.org/html/rfc7591#section-2'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
details: {
|
details: {
|
||||||
endpoint: '/register',
|
endpoint: '/register',
|
||||||
clientName: req.body.client_name
|
clientName: req.body.client_name
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,10 @@ import express, { Request, Response, NextFunction } from 'express';
|
||||||
import type { ConformanceCheck } from '../../../../types.js';
|
import type { ConformanceCheck } from '../../../../types.js';
|
||||||
import { createRequestLogger } from '../../../request-logger.js';
|
import { createRequestLogger } from '../../../request-logger.js';
|
||||||
import { MockTokenVerifier } from './mockTokenVerifier.js';
|
import { MockTokenVerifier } from './mockTokenVerifier.js';
|
||||||
|
import { SpecReferences } from '../spec-references.js';
|
||||||
|
|
||||||
export interface ServerOptions {
|
export interface ServerOptions {
|
||||||
prmPath?: string;
|
prmPath?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServer(
|
export function createServer(
|
||||||
|
|
@ -47,30 +48,37 @@ export function createServer(
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
app.get(prmPath, (req: Request, res: Response) => {
|
if (prmPath !== null) {
|
||||||
checks.push({
|
app.get(prmPath, (req: Request, res: Response) => {
|
||||||
id: 'prm-pathbased-requested',
|
checks.push({
|
||||||
name: 'PRMPathBasedRequested',
|
id: 'prm-pathbased-requested',
|
||||||
description: 'Client requested PRM metadata at path-based location',
|
name: 'PRMPathBasedRequested',
|
||||||
status: 'SUCCESS',
|
description: 'Client requested PRM metadata at path-based location',
|
||||||
timestamp: new Date().toISOString(),
|
status: 'SUCCESS',
|
||||||
specReferences: [
|
timestamp: new Date().toISOString(),
|
||||||
{
|
specReferences: [
|
||||||
id: 'RFC-9728-3',
|
SpecReferences.RFC_PRM_DISCOVERY,
|
||||||
url: 'https://tools.ietf.org/html/rfc9728#section-3'
|
SpecReferences.MCP_PRM_DISCOVERY
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
url: req.url,
|
||||||
|
path: req.path
|
||||||
}
|
}
|
||||||
],
|
});
|
||||||
details: {
|
|
||||||
url: req.url,
|
|
||||||
path: req.path
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
// Resource is usually $baseUrl/mcp, but if PRM is at the root,
|
||||||
resource: getBaseUrl(),
|
// the resource identifier is the root.
|
||||||
authorization_servers: [getAuthServerUrl()]
|
const resource =
|
||||||
|
prmPath === '/.well-known/oauth-protected-resource'
|
||||||
|
? getBaseUrl()
|
||||||
|
: `${getBaseUrl()}/mcp`;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
resource,
|
||||||
|
authorization_servers: [getAuthServerUrl()]
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
app.post('/mcp', async (req: Request, res: Response, next: NextFunction) => {
|
app.post('/mcp', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
// Apply bearer token auth per-request in order to delay setting PRM URL
|
// Apply bearer token auth per-request in order to delay setting PRM URL
|
||||||
|
|
@ -79,7 +87,9 @@ export function createServer(
|
||||||
const authMiddleware = requireBearerAuth({
|
const authMiddleware = requireBearerAuth({
|
||||||
verifier: new MockTokenVerifier(checks),
|
verifier: new MockTokenVerifier(checks),
|
||||||
requiredScopes: [],
|
requiredScopes: [],
|
||||||
resourceMetadataUrl: `${getBaseUrl()}${prmPath}`
|
...(prmPath !== null && {
|
||||||
|
resourceMetadataUrl: `${getBaseUrl()}${prmPath}`
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
authMiddleware(req, res, async (err?: any) => {
|
authMiddleware(req, res, async (err?: any) => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { OAuthTokenVerifier } from '@modelcontextprotocol/sdk/server/auth/provider.js';
|
import { OAuthTokenVerifier } from '@modelcontextprotocol/sdk/server/auth/provider.js';
|
||||||
import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
|
import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
|
||||||
import type { ConformanceCheck } from '../../../../types.js';
|
import type { ConformanceCheck } from '../../../../types.js';
|
||||||
|
import { SpecReferences } from '../spec-references.js';
|
||||||
|
|
||||||
export class MockTokenVerifier implements OAuthTokenVerifier {
|
export class MockTokenVerifier implements OAuthTokenVerifier {
|
||||||
constructor(private checks: ConformanceCheck[]) {}
|
constructor(private checks: ConformanceCheck[]) {}
|
||||||
|
|
@ -13,12 +14,7 @@ export class MockTokenVerifier implements OAuthTokenVerifier {
|
||||||
description: 'Client provided valid bearer token',
|
description: 'Client provided valid bearer token',
|
||||||
status: 'SUCCESS',
|
status: 'SUCCESS',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
specReferences: [
|
specReferences: [SpecReferences.MCP_ACCESS_TOKEN_USAGE],
|
||||||
{
|
|
||||||
id: 'MCP-Authorization',
|
|
||||||
url: 'https://spec.modelcontextprotocol.io/specification/architecture/#authorization'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
details: {
|
details: {
|
||||||
token: token.substring(0, 10) + '...'
|
token: token.substring(0, 10) + '...'
|
||||||
}
|
}
|
||||||
|
|
@ -37,12 +33,7 @@ export class MockTokenVerifier implements OAuthTokenVerifier {
|
||||||
description: 'Client provided invalid bearer token',
|
description: 'Client provided invalid bearer token',
|
||||||
status: 'FAILURE',
|
status: 'FAILURE',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
specReferences: [
|
specReferences: [SpecReferences.MCP_ACCESS_TOKEN_USAGE],
|
||||||
{
|
|
||||||
id: 'MCP-Authorization',
|
|
||||||
url: 'https://spec.modelcontextprotocol.io/specification/architecture/#authorization'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
details: {
|
details: {
|
||||||
message: 'Token verification failed',
|
message: 'Token verification failed',
|
||||||
token: token ? token.substring(0, 10) + '...' : 'missing'
|
token: token ? token.substring(0, 10) + '...' : 'missing'
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@ export class ServerLifecycle {
|
||||||
private httpServer: any = null;
|
private httpServer: any = null;
|
||||||
private baseUrl: string = '';
|
private baseUrl: string = '';
|
||||||
|
|
||||||
constructor(private getBaseUrl: () => string) {}
|
// Arrow function to avoid needing lots of .bind(this)
|
||||||
|
getUrl = (): string => {
|
||||||
|
return this.baseUrl;
|
||||||
|
};
|
||||||
|
|
||||||
async start(app: express.Application): Promise<string> {
|
async start(app: express.Application): Promise<string> {
|
||||||
this.app = app;
|
this.app = app;
|
||||||
|
|
@ -25,8 +28,4 @@ export class ServerLifecycle {
|
||||||
}
|
}
|
||||||
this.app = null;
|
this.app = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getUrl(): string {
|
|
||||||
return this.baseUrl;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
src/scenarios/client/auth/index.test.ts
Normal file
37
src/scenarios/client/auth/index.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { authScenariosList } from './index.js';
|
||||||
|
import {
|
||||||
|
runClientAgainstScenario,
|
||||||
|
SpawnedClientRunner
|
||||||
|
} from './test_helpers/testClient.js';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
describe('Client Auth Scenarios', () => {
|
||||||
|
const clientPath = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
'examples/clients/typescript/auth-test.ts'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate individual test for each auth scenario
|
||||||
|
for (const scenario of authScenariosList) {
|
||||||
|
test(`${scenario.name} passes`, async () => {
|
||||||
|
const runner = new SpawnedClientRunner(clientPath);
|
||||||
|
await runClientAgainstScenario(runner, scenario.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Negative tests', () => {
|
||||||
|
test('bad client requests root PRM location', async () => {
|
||||||
|
const clientPath = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
'examples/clients/typescript/auth-test-broken1.ts'
|
||||||
|
);
|
||||||
|
const runner = new SpawnedClientRunner(clientPath);
|
||||||
|
await runClientAgainstScenario(runner, 'auth/basic-dcr', [
|
||||||
|
// There will be other failures, but this is the one that matters
|
||||||
|
'prm-priority-order'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Add more negative tests here
|
||||||
|
});
|
||||||
20
src/scenarios/client/auth/index.ts
Normal file
20
src/scenarios/client/auth/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { Scenario } from '../../../types';
|
||||||
|
import { AuthBasicDCRScenario } from './basic-dcr.js';
|
||||||
|
import {
|
||||||
|
AuthBasicMetadataVar1Scenario,
|
||||||
|
AuthBasicMetadataVar2Scenario,
|
||||||
|
AuthBasicMetadataVar3Scenario
|
||||||
|
} from './basic-metadata.js';
|
||||||
|
import {
|
||||||
|
Auth20250326OAuthMetadataBackcompatScenario,
|
||||||
|
Auth20250326OEndpointFallbackScenario
|
||||||
|
} from './march-spec-backcompat.js';
|
||||||
|
|
||||||
|
export const authScenariosList: Scenario[] = [
|
||||||
|
new AuthBasicDCRScenario(),
|
||||||
|
new AuthBasicMetadataVar1Scenario(),
|
||||||
|
new AuthBasicMetadataVar2Scenario(),
|
||||||
|
new AuthBasicMetadataVar3Scenario(),
|
||||||
|
new Auth20250326OAuthMetadataBackcompatScenario(),
|
||||||
|
new Auth20250326OEndpointFallbackScenario()
|
||||||
|
];
|
||||||
192
src/scenarios/client/auth/march-spec-backcompat.ts
Normal file
192
src/scenarios/client/auth/march-spec-backcompat.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
import type { Scenario, ConformanceCheck } from '../../../types.js';
|
||||||
|
import { ScenarioUrls } from '../../../types.js';
|
||||||
|
import { createAuthServer } from './helpers/createAuthServer.js';
|
||||||
|
import { createServer } from './helpers/createServer.js';
|
||||||
|
import { ServerLifecycle } from './helpers/serverLifecycle.js';
|
||||||
|
import express, { Request, Response } from 'express';
|
||||||
|
import { SpecReferences } from './spec-references.js';
|
||||||
|
|
||||||
|
export class Auth20250326OAuthMetadataBackcompatScenario implements Scenario {
|
||||||
|
name = 'auth/2025-03-26-oauth-metadata-backcompat';
|
||||||
|
description =
|
||||||
|
'Tests 2025-03-26 spec OAuth flow: no PRM (Protected Resource Metadata), OAuth metadata at root location';
|
||||||
|
private server = new ServerLifecycle();
|
||||||
|
private checks: ConformanceCheck[] = [];
|
||||||
|
|
||||||
|
async start(): Promise<ScenarioUrls> {
|
||||||
|
this.checks = [];
|
||||||
|
// Legacy server, so we create the auth server endpoints on the
|
||||||
|
// same URL as the main server (rather than separating AS / RS).
|
||||||
|
const authApp = createAuthServer(this.checks, this.server.getUrl, {
|
||||||
|
// Disable logging since the main server will already have logging enabled
|
||||||
|
loggingEnabled: false,
|
||||||
|
// Add a prefix to auth endpoints to avoid being caught by auth fallbacks
|
||||||
|
routePrefix: '/oauth'
|
||||||
|
});
|
||||||
|
const app = createServer(
|
||||||
|
this.checks,
|
||||||
|
this.server.getUrl,
|
||||||
|
this.server.getUrl,
|
||||||
|
// Explicitly set to null to indicate no PRM available
|
||||||
|
{ prmPath: null }
|
||||||
|
);
|
||||||
|
app.use(authApp);
|
||||||
|
|
||||||
|
await this.server.start(app);
|
||||||
|
|
||||||
|
return { serverUrl: `${this.server.getUrl()}/mcp` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
await this.server.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
getChecks(): ConformanceCheck[] {
|
||||||
|
const expectedSlugs = [
|
||||||
|
'authorization-server-metadata',
|
||||||
|
'client-registration',
|
||||||
|
'authorization-request',
|
||||||
|
'token-request'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const slug of expectedSlugs) {
|
||||||
|
if (!this.checks.find((c) => c.id === slug)) {
|
||||||
|
this.checks.push({
|
||||||
|
id: slug,
|
||||||
|
name: `Expected Check Missing: ${slug}`,
|
||||||
|
description: `Expected Check Missing: ${slug}`,
|
||||||
|
status: 'FAILURE',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
specReferences: [SpecReferences.LEGACY_2025_03_26_AUTH_DISCOVERY]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.checks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Auth20250326OEndpointFallbackScenario implements Scenario {
|
||||||
|
name = 'auth/2025-03-26-oauth-endpoint-fallback';
|
||||||
|
description =
|
||||||
|
'Tests OAuth flow with no metadata endpoints, relying on fallback to standard OAuth endpoints at server root (2025-03-26 spec behavior)';
|
||||||
|
private server = new ServerLifecycle();
|
||||||
|
private checks: ConformanceCheck[] = [];
|
||||||
|
|
||||||
|
async start(): Promise<ScenarioUrls> {
|
||||||
|
this.checks = [];
|
||||||
|
|
||||||
|
const app = createServer(
|
||||||
|
this.checks,
|
||||||
|
this.server.getUrl,
|
||||||
|
this.server.getUrl,
|
||||||
|
{ prmPath: null }
|
||||||
|
);
|
||||||
|
|
||||||
|
// needed for /token endpoint
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
app.get('/authorize', (req: Request, res: Response) => {
|
||||||
|
this.checks.push({
|
||||||
|
id: 'authorization-request',
|
||||||
|
name: 'AuthorizationRequest',
|
||||||
|
description: 'Client made authorization request to fallback endpoint',
|
||||||
|
status: 'SUCCESS',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
specReferences: [SpecReferences.LEGACY_2025_03_26_AUTH_URL_FALLBACK],
|
||||||
|
details: {
|
||||||
|
response_type: req.query.response_type,
|
||||||
|
client_id: req.query.client_id,
|
||||||
|
redirect_uri: req.query.redirect_uri,
|
||||||
|
state: req.query.state,
|
||||||
|
code_challenge: req.query.code_challenge ? 'present' : 'missing',
|
||||||
|
code_challenge_method: req.query.code_challenge_method
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const redirectUri = req.query.redirect_uri as string;
|
||||||
|
const state = req.query.state as string;
|
||||||
|
const redirectUrl = new URL(redirectUri);
|
||||||
|
redirectUrl.searchParams.set('code', 'test-auth-code');
|
||||||
|
if (state) {
|
||||||
|
redirectUrl.searchParams.set('state', state);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect(redirectUrl.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/token', (req: Request, res: Response) => {
|
||||||
|
this.checks.push({
|
||||||
|
id: 'token-request',
|
||||||
|
name: 'TokenRequest',
|
||||||
|
description: 'Client requested access token from fallback endpoint',
|
||||||
|
status: 'SUCCESS',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
specReferences: [SpecReferences.LEGACY_2025_03_26_AUTH_URL_FALLBACK],
|
||||||
|
details: {
|
||||||
|
endpoint: '/token',
|
||||||
|
grantType: req.body.grant_type
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
access_token: 'test-token',
|
||||||
|
token_type: 'Bearer',
|
||||||
|
expires_in: 3600
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/register', (req: Request, res: Response) => {
|
||||||
|
this.checks.push({
|
||||||
|
id: 'client-registration',
|
||||||
|
name: 'ClientRegistration',
|
||||||
|
description:
|
||||||
|
'Client registered with authorization server at fallback endpoint',
|
||||||
|
status: 'SUCCESS',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
specReferences: [SpecReferences.LEGACY_2025_03_26_AUTH_URL_FALLBACK],
|
||||||
|
details: {
|
||||||
|
endpoint: '/register',
|
||||||
|
clientName: req.body.client_name
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
client_id: 'test-client-id',
|
||||||
|
client_secret: 'test-client-secret',
|
||||||
|
client_name: req.body.client_name || 'test-client',
|
||||||
|
redirect_uris: req.body.redirect_uris || []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.server.start(app);
|
||||||
|
|
||||||
|
return { serverUrl: `${this.server.getUrl()}/mcp` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
await this.server.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
getChecks(): ConformanceCheck[] {
|
||||||
|
const expectedSlugs = [
|
||||||
|
'client-registration',
|
||||||
|
'authorization-request',
|
||||||
|
'token-request'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const slug of expectedSlugs) {
|
||||||
|
if (!this.checks.find((c) => c.id === slug)) {
|
||||||
|
this.checks.push({
|
||||||
|
id: slug,
|
||||||
|
name: `Expected Check Missing: ${slug}`,
|
||||||
|
description: `Expected Check Missing: ${slug}`,
|
||||||
|
status: 'FAILURE',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.checks;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/scenarios/client/auth/spec-references.ts
Normal file
44
src/scenarios/client/auth/spec-references.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { SpecReference } from '../../../types';
|
||||||
|
|
||||||
|
export const SpecReferences: { [key: string]: SpecReference } = {
|
||||||
|
RFC_PRM_DISCOVERY: {
|
||||||
|
id: 'RFC-9728',
|
||||||
|
url: 'https://www.rfc-editor.org/rfc/rfc9728.html#section-3.1'
|
||||||
|
},
|
||||||
|
RFC_AUTH_SERVER_METADATA_REQUEST: {
|
||||||
|
id: 'RFC-8414-metadata-request',
|
||||||
|
url: 'https://www.rfc-editor.org/rfc/rfc8414.html#section-3.1'
|
||||||
|
},
|
||||||
|
LEGACY_2025_03_26_AUTH_DISCOVERY: {
|
||||||
|
id: 'MCP-2025-03-26-Authorization-metadata-discovery',
|
||||||
|
url: 'https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#server-metadata-discovery'
|
||||||
|
},
|
||||||
|
LEGACY_2025_03_26_AUTH_URL_FALLBACK: {
|
||||||
|
id: 'MCP-2025-03-26-Authorization-metadata-url-fallback',
|
||||||
|
url: 'https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery'
|
||||||
|
},
|
||||||
|
MCP_PRM_DISCOVERY: {
|
||||||
|
id: 'MCP-2025-06-18-PRM-discovery',
|
||||||
|
url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#protected-resource-metadata-discovery-requirements'
|
||||||
|
},
|
||||||
|
MCP_AUTH_DISCOVERY: {
|
||||||
|
id: 'MCP-Authorization-metadata-discovery',
|
||||||
|
url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-metadata-discovery'
|
||||||
|
},
|
||||||
|
MCP_DCR: {
|
||||||
|
id: 'MCP-Dynamic-client-registration',
|
||||||
|
url: 'https://modelcontextprotocol.io/specification/draft/basic/client#dynamic-client-registration'
|
||||||
|
},
|
||||||
|
OAUTH_2_1_AUTHORIZATION_ENDPOINT: {
|
||||||
|
id: 'OAUTH-2.1-authorization-endpoint',
|
||||||
|
url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#name-authorization-endpoint'
|
||||||
|
},
|
||||||
|
OAUTH_2_1_TOKEN: {
|
||||||
|
id: 'OAUTH-2.1-token-request',
|
||||||
|
url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#name-token-request'
|
||||||
|
},
|
||||||
|
MCP_ACCESS_TOKEN_USAGE: {
|
||||||
|
id: 'MCP-Access-token-usage',
|
||||||
|
url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#access-token-usage'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { Scenario, ClientScenario } from '../types.js';
|
import { Scenario, ClientScenario } from '../types.js';
|
||||||
import { InitializeScenario } from './client/initialize.js';
|
import { InitializeScenario } from './client/initialize.js';
|
||||||
import { ToolsCallScenario } from './client/tools_call.js';
|
import { ToolsCallScenario } from './client/tools_call.js';
|
||||||
import { AuthBasicDCRScenario } from './client/auth/basic-dcr.js';
|
|
||||||
import { AuthBasicMetadataVar1Scenario } from './client/auth/basic-metadata-var1.js';
|
|
||||||
import { ElicitationClientDefaultsScenario } from './client/elicitation-defaults.js';
|
import { ElicitationClientDefaultsScenario } from './client/elicitation-defaults.js';
|
||||||
|
|
||||||
// Import all new server test scenarios
|
// Import all new server test scenarios
|
||||||
|
|
@ -47,6 +45,8 @@ import {
|
||||||
PromptsGetWithImageScenario
|
PromptsGetWithImageScenario
|
||||||
} from './server/prompts.js';
|
} from './server/prompts.js';
|
||||||
|
|
||||||
|
import { authScenariosList } from './client/auth/index.js';
|
||||||
|
|
||||||
// Pending client scenarios (not yet fully tested/implemented)
|
// Pending client scenarios (not yet fully tested/implemented)
|
||||||
const pendingClientScenariosList: ClientScenario[] = [
|
const pendingClientScenariosList: ClientScenario[] = [
|
||||||
// Elicitation scenarios (SEP-1330)
|
// Elicitation scenarios (SEP-1330)
|
||||||
|
|
@ -115,9 +115,8 @@ export const clientScenarios = new Map<string, ClientScenario>(
|
||||||
const scenariosList: Scenario[] = [
|
const scenariosList: Scenario[] = [
|
||||||
new InitializeScenario(),
|
new InitializeScenario(),
|
||||||
new ToolsCallScenario(),
|
new ToolsCallScenario(),
|
||||||
new AuthBasicDCRScenario(),
|
new ElicitationClientDefaultsScenario(),
|
||||||
new AuthBasicMetadataVar1Scenario(),
|
...authScenariosList
|
||||||
new ElicitationClientDefaultsScenario()
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Scenarios map - built from list
|
// Scenarios map - built from list
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ export function createRequestLogger(
|
||||||
let requestDescription = `Received ${req.method} request for ${req.path}`;
|
let requestDescription = `Received ${req.method} request for ${req.path}`;
|
||||||
const requestDetails: any = {
|
const requestDetails: any = {
|
||||||
method: req.method,
|
method: req.method,
|
||||||
path: req.path
|
path: req.path,
|
||||||
|
body: req.body
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add query parameters to details if they exist
|
// Add query parameters to details if they exist
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@
|
||||||
|
|
||||||
import { ClientScenario, ConformanceCheck } from '../../types.js';
|
import { ClientScenario, ConformanceCheck } from '../../types.js';
|
||||||
import { connectToServer } from './client-helper.js';
|
import { connectToServer } from './client-helper.js';
|
||||||
|
import {
|
||||||
|
TextResourceContents,
|
||||||
|
BlobResourceContents
|
||||||
|
} from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
export class ResourcesListScenario implements ClientScenario {
|
export class ResourcesListScenario implements ClientScenario {
|
||||||
name = 'resources-list';
|
name = 'resources-list';
|
||||||
|
|
@ -122,7 +126,7 @@ Implement resource \`test://static-text\` that returns:
|
||||||
errors.push('contents is not an array');
|
errors.push('contents is not an array');
|
||||||
if (result.contents.length === 0) errors.push('contents array is empty');
|
if (result.contents.length === 0) errors.push('contents array is empty');
|
||||||
|
|
||||||
const content = result.contents[0];
|
const content = result.contents[0] as TextResourceContents | undefined;
|
||||||
if (content) {
|
if (content) {
|
||||||
if (!content.uri) errors.push('Content missing uri');
|
if (!content.uri) errors.push('Content missing uri');
|
||||||
if (!content.mimeType) errors.push('Content missing mimeType');
|
if (!content.mimeType) errors.push('Content missing mimeType');
|
||||||
|
|
@ -206,7 +210,7 @@ Implement resource \`test://static-binary\` that returns:
|
||||||
if (!result.contents) errors.push('Missing contents array');
|
if (!result.contents) errors.push('Missing contents array');
|
||||||
if (result.contents.length === 0) errors.push('contents array is empty');
|
if (result.contents.length === 0) errors.push('contents array is empty');
|
||||||
|
|
||||||
const content = result.contents[0];
|
const content = result.contents[0] as BlobResourceContents | undefined;
|
||||||
if (content) {
|
if (content) {
|
||||||
if (!content.uri) errors.push('Content missing uri');
|
if (!content.uri) errors.push('Content missing uri');
|
||||||
if (!content.mimeType) errors.push('Content missing mimeType');
|
if (!content.mimeType) errors.push('Content missing mimeType');
|
||||||
|
|
@ -297,11 +301,15 @@ Returns (for \`uri: "test://template/123/data"\`):
|
||||||
const content = result.contents[0];
|
const content = result.contents[0];
|
||||||
if (content) {
|
if (content) {
|
||||||
if (!content.uri) errors.push('Content missing uri');
|
if (!content.uri) errors.push('Content missing uri');
|
||||||
if (!content.text && !content.blob)
|
const hasText = 'text' in content;
|
||||||
errors.push('Content missing text or blob');
|
const hasBlob = 'blob' in content;
|
||||||
|
if (!hasText && !hasBlob) errors.push('Content missing text or blob');
|
||||||
|
|
||||||
// Check if parameter was substituted
|
const text = hasText
|
||||||
const text = content.text || (content.blob ? '[binary]' : '');
|
? (content as TextResourceContents).text
|
||||||
|
: hasBlob
|
||||||
|
? '[binary]'
|
||||||
|
: '';
|
||||||
if (typeof text === 'string' && !text.includes('123')) {
|
if (typeof text === 'string' && !text.includes('123')) {
|
||||||
errors.push('Parameter substitution not reflected in content');
|
errors.push('Parameter substitution not reflected in content');
|
||||||
}
|
}
|
||||||
|
|
@ -322,7 +330,11 @@ Returns (for \`uri: "test://template/123/data"\`):
|
||||||
],
|
],
|
||||||
details: {
|
details: {
|
||||||
uri: content?.uri,
|
uri: content?.uri,
|
||||||
content: content?.text || content?.blob
|
content: content
|
||||||
|
? 'text' in content
|
||||||
|
? (content as TextResourceContents).text
|
||||||
|
: (content as BlobResourceContents).blob
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue