Skip to content

Commit 8bfb22e

Browse files
author
andypalmi
committed
test(mcp): add expert-mcp token helper and raw/normalize equivalence options
1 parent 2935347 commit 8bfb22e

1 file changed

Lines changed: 43 additions & 5 deletions

File tree

test/lib/mcpToolEquivalence.js

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,31 @@
11
const 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

Comments
 (0)