11const should = require ( 'should' )
22
3+ // Mints the token the first-party Expert uses in production (see
4+ // forge/ee/lib/expert/index.js getOrCreateMcpPlatformToken): a nameless
5+ // user:expert-mcp token scoped to ff-expert:platform. Being nameless,
6+ // request.session.scope survives (a named token would have its scope deleted
7+ // - the #7446 temp hack), so every route the tool reaches is checked against
8+ // IMPLICIT_TOKEN_SCOPES['user:expert-mcp']. That is the exact path the Expert
9+ // takes, so an equivalence test using this token also proves the tool's
10+ // backing scope is in the allow-list. Test-only helper.
11+ const EXPERT_MCP_PLATFORM_SCOPE = 'ff-expert:platform'
12+ const EXPERT_MCP_PLATFORM_OWNER_TYPE = 'user:expert-mcp'
13+
14+ async function createExpertMcpToken ( app , user = app . user ) {
15+ const { token } = await app . db . controllers . AccessToken . createTokenForUser (
16+ user ,
17+ null ,
18+ [ EXPERT_MCP_PLATFORM_SCOPE ] ,
19+ undefined ,
20+ EXPERT_MCP_PLATFORM_OWNER_TYPE
21+ )
22+ return token
23+ }
24+
325// Asserts that an MCP tool produces the same result as calling its backing
426// route directly. The tool handler builds its own {method, url} from the args;
527// the test independently states the route it is expected to hit. Both are made
6- // with the same PAT -bearing inject, so:
28+ // with the same expert-mcp-token -bearing inject, so:
729// - a wrong url/method/path-param in the tool hits a different route and the
830// status/body diverge -> the test fails (this is what proves correctness),
931// - the endpoint's response shape is never written down here, it is whatever
@@ -19,15 +41,31 @@ const should = require('should')
1941// returns the { statusCode, body } the tool is expected to produce from that
2042// same live route response - still drift-proof, since it references the live
2143// response rather than a hard-coded shape.
22- async function expectToolMatchesRoute ( inject , tool , args , { method, url, payload, transform } = { } ) {
44+ //
45+ // Some routes (e.g. CSV exports) don't return JSON, so `.json()` would throw.
46+ // Pass `raw: true` to compare the response bodies as plain strings instead.
47+ //
48+ // Some routes proxy live/randomised data (e.g. a point-in-time resource usage
49+ // snapshot) that is regenerated on every call, so two independent calls never
50+ // produce byte-identical bodies. Pass `normalize(body) => body` to reduce both
51+ // the tool's and the route's body to the parts that should be stable (shape,
52+ // counts, keys) before comparing - still derived from the live response on
53+ // both sides, just tolerant of fields that are expected to vary per call.
54+ async function expectToolMatchesRoute ( inject , tool , args , { method, url, payload, transform, raw, normalize } = { } ) {
2355 const viaTool = await tool . handler ( args , { inject } )
2456 const routeResponse = await inject ( { method, url, payload } )
2557 const expected = transform
2658 ? transform ( routeResponse )
27- : { statusCode : routeResponse . statusCode , body : routeResponse . json ( ) }
59+ : { statusCode : routeResponse . statusCode , body : raw ? routeResponse . body : routeResponse . json ( ) }
2860 viaTool . statusCode . should . equal ( expected . statusCode )
29- should ( viaTool . json ( ) ) . eql ( expected . body )
61+ let actualBody = raw ? viaTool . body : viaTool . json ( )
62+ let expectedBody = expected . body
63+ if ( normalize ) {
64+ actualBody = normalize ( actualBody )
65+ expectedBody = normalize ( expectedBody )
66+ }
67+ should ( actualBody ) . eql ( expectedBody )
3068 return { viaTool, routeResponse }
3169}
3270
33- module . exports = { expectToolMatchesRoute }
71+ module . exports = { expectToolMatchesRoute, createExpertMcpToken }
0 commit comments