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
111 changes: 111 additions & 0 deletions forge/ee/lib/mcp/schemas.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
const { z } = require('zod')

// Entity-ID params, imported by the tools so every tool names a given resource
// with one canonical field, validator and wording. Hosted instances are Projects
// (UUID primary key); teams, applications, devices and snapshots use hashids. A
// device and a remote instance are the same entity, so both use remoteInstanceId.
// Each tool's own description explains how it uses the id, so these stay generic.
const teamId = z.string().describe('The ID or hashid of the team')
const applicationId = z.string().describe('The ID or hashid of the application')
const hostedInstanceId = z.string().uuid().describe('The UUID of the hosted instance')
const remoteInstanceId = z.string().describe('The ID or hashid of the remote instance')
const snapshotId = z.string().describe('The hashid of the snapshot')

// Shared query fragments, imported by the tools/*.js files. Compose per tool by
// spreading only the fragments whose params the backing route's finder actually
// HONORS. This is deliberately not the same as the route's declared query schema:
// most list routes reuse one generic PaginationParams schema that advertises
// page/sort/dir/order even when the finder ignores them, so matching the declared
// schema would advertise dead params. Match the finder instead.

// Cursor pagination: every list finder is cursor-based except team projects,
// which is offset/page based (see pageParam). limit defaults to 50 to bound how
// much a single call can return into an agent's context.
const cursorParam = {
cursor: z.string().optional().describe('Opaque cursor from a previous page')
}
const limitParam = {
limit: z.number().int().min(1).default(50).describe('Maximum number of records to return (default 50)')
}
const basePagination = { ...cursorParam, ...limitParam }

// Offset pagination: only Device.getAll (remote-instance lists) and
// Project.byTeam (team projects) read page and compute an offset.
const pageParam = {
page: z.number().int().min(1).optional().describe('1-based page number (offset pagination)')
}

// Free-text search: add for finders that pass search columns to
// buildPaginationSearchClause (matching name/description/username, etc).
const searchQuery = {
query: z.string().optional().describe('Free-text search filter')
}

// Sorting: only Project.byTeam honors a sort field plus direction. No finder
// reads the legacy `order` alias, so it is not offered.
const sortParams = {
sort: z.string().optional().describe('Field name to sort by'),
dir: z.enum(['asc', 'desc']).optional().describe('Sort direction')
}

// Audit-log filters, honored by every audit-log route. event matches a single
// event name or, given an array, any of them. scope is route-specific (its enum
// differs per entity and the device route has none) so each tool declares it inline.
const auditLogFilters = {
event: z.union([z.string(), z.array(z.string())]).optional().describe('Filter by audit event name, or an array of event names'),
username: z.string().optional().describe('Filter by the username that triggered the event')
}

// Field-name lists for each fragment, so a tool can serialise exactly the query
// params its route supports without re-listing them.
const cursorParamKeys = Object.keys(cursorParam)
const limitParamKeys = Object.keys(limitParam)
const basePaginationKeys = Object.keys(basePagination)
const pageParamKeys = Object.keys(pageParam)
const searchQueryKeys = Object.keys(searchQuery)
const sortParamsKeys = Object.keys(sortParams)
const auditLogFilterKeys = Object.keys(auditLogFilters)

// Serialise the given query fields from args onto a url. The single place every
// read tool builds a query string: only defined values are included, values are
// URL-encoded, and an array value (e.g. multiple audit event names) is appended
// once per element.
function appendQuery (url, args, keys) {
const params = new URLSearchParams()
for (const key of keys) {
const value = args[key]
if (value === undefined || value === null) {
continue
}
if (Array.isArray(value)) {
value.forEach(v => params.append(key, v))
} else {
params.append(key, value)
}
}
const queryString = params.toString()
return queryString ? `${url}?${queryString}` : url
}

module.exports = {
teamId,
applicationId,
hostedInstanceId,
remoteInstanceId,
snapshotId,
cursorParam,
limitParam,
basePagination,
pageParam,
searchQuery,
sortParams,
auditLogFilters,
cursorParamKeys,
limitParamKeys,
basePaginationKeys,
pageParamKeys,
searchQueryKeys,
sortParamsKeys,
auditLogFilterKeys,
appendQuery
}
68 changes: 68 additions & 0 deletions test/lib/mcpToolEquivalence.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
const should = require('should')

const EXPERT_MCP_PLATFORM_SCOPE = 'ff-expert:platform'
const EXPERT_MCP_PLATFORM_OWNER_TYPE = 'user:expert-mcp'

// Mints the nameless user:expert-mcp token the first-party Expert uses in
// production (forge/ee/lib/expert/index.js). It must be nameless: a named token
// has request.session.scope deleted (the #7446 temp hack), which would skip the
// IMPLICIT_TOKEN_SCOPES['user:expert-mcp'] allow-list check we want to exercise.
// Binds a finder to a tool module's exported array; findTool(name) returns the
// named tool and asserts it exists.
function toolFinder (tools) {
return (name) => {
const tool = tools.find(t => t.name === name)
should.exist(tool)
return tool
}
}

// A stub inject that records every call and hands back a canned response, so a
// handler's request wiring can be asserted without a running app.
function recordingInject (response = { statusCode: 200, json: () => ({}) }) {
const calls = []
const inject = async (options) => {
calls.push(options)
return response
}
return { calls, inject }
}

async function createExpertMcpToken (app, user = app.user) {
const { token } = await app.db.controllers.AccessToken.createTokenForUser(
user,
null,
[EXPERT_MCP_PLATFORM_SCOPE],
undefined,
EXPERT_MCP_PLATFORM_OWNER_TYPE
)
return token
}

// Asserts an MCP tool returns the same result as calling its backing route
// directly. The tool builds its own {method, url}; the test states the route it
// should hit. A wrong url/method/path-param diverges and fails. The response
// shape is never hard-coded, so route changes need no update here.
// transform: reshape the route response for tools that intentionally do so
// (credential redaction, redirect-to-url rewrites)
// raw: compare bodies as strings for non-JSON routes (e.g. CSV)
// normalize: reduce both bodies to their stable parts before comparing, for
// routes returning randomised/point-in-time data
async function expectToolMatchesRoute (inject, tool, args, { method, url, payload, transform, raw, normalize } = {}) {
const viaTool = await tool.handler(args, { inject })
const routeResponse = await inject({ method, url, payload })
const expected = transform
? transform(routeResponse)
: { statusCode: routeResponse.statusCode, body: raw ? routeResponse.body : routeResponse.json() }
viaTool.statusCode.should.equal(expected.statusCode)
let actualBody = raw ? viaTool.body : viaTool.json()
let expectedBody = expected.body
if (normalize) {
actualBody = normalize(actualBody)
expectedBody = normalize(expectedBody)
}
should(actualBody).eql(expectedBody)
return { viaTool, routeResponse }
}

module.exports = { expectToolMatchesRoute, createExpertMcpToken, toolFinder, recordingInject }
76 changes: 76 additions & 0 deletions test/unit/forge/ee/lib/mcp/schemas_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
const should = require('should') // eslint-disable-line no-unused-vars

const FF_UTIL = require('flowforge-test-utils')

const {
teamId,
applicationId,
hostedInstanceId,
remoteInstanceId,
snapshotId,
basePagination,
cursorParamKeys,
limitParamKeys,
basePaginationKeys,
pageParamKeys,
searchQueryKeys,
sortParamsKeys,
auditLogFilterKeys,
appendQuery
} = FF_UTIL.require('forge/ee/lib/mcp/schemas')

describe('MCP shared query schemas', function () {
describe('entity-id params', function () {
it('hashid params accept any non-empty string', function () {
for (const param of [teamId, applicationId, remoteInstanceId, snapshotId]) {
param.parse('aBc123').should.equal('aBc123')
}
})
it('hostedInstanceId accepts a UUID and rejects a hashid', function () {
hostedInstanceId.parse('4f8c1e2a-1b2c-4d3e-8f90-abcdef123456').should.be.a.String()
hostedInstanceId.safeParse('aBc123').success.should.equal(false)
})
})

describe('fragment composition', function () {
it('basePagination is cursor plus limit only (no page)', function () {
basePaginationKeys.should.eql(['cursor', 'limit'])
})
it('page, search and sort are separate opt-in fragments', function () {
cursorParamKeys.should.eql(['cursor'])
limitParamKeys.should.eql(['limit'])
pageParamKeys.should.eql(['page'])
searchQueryKeys.should.eql(['query'])
sortParamsKeys.should.eql(['sort', 'dir'])
auditLogFilterKeys.should.eql(['event', 'username'])
})
it('key list matches the shape object it describes', function () {
basePaginationKeys.should.eql(Object.keys(basePagination))
})
})

describe('appendQuery', function () {
it('returns the url unchanged when no supported params are set', function () {
appendQuery('/api/v1/x', {}, basePaginationKeys).should.equal('/api/v1/x')
})
it('returns the url unchanged when only unsupported keys are present', function () {
appendQuery('/api/v1/x', { teamId: 'abc' }, basePaginationKeys).should.equal('/api/v1/x')
})
it('serialises only the defined, supported params', function () {
appendQuery('/api/v1/x', { limit: 10, page: 2, cursor: undefined }, [...basePaginationKeys, ...pageParamKeys])
.should.equal('/api/v1/x?limit=10&page=2')
})
it('url-encodes values', function () {
appendQuery('/api/v1/x', { query: 'a b&c' }, searchQueryKeys)
.should.equal('/api/v1/x?query=a+b%26c')
})
it('appends an array value once per element', function () {
appendQuery('/api/v1/x', { event: ['flows.created', 'flows.deleted'] }, auditLogFilterKeys)
.should.equal('/api/v1/x?event=flows.created&event=flows.deleted')
})
it('skips null and undefined but keeps other values', function () {
appendQuery('/api/v1/x', { limit: null, page: 1 }, [...basePaginationKeys, ...pageParamKeys])
.should.equal('/api/v1/x?page=1')
})
})
})
Loading