Skip to content
Merged
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
51 changes: 48 additions & 3 deletions forge/comms/platformAutomation.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@

const { default: z } = require('zod')

/**
* Cheap, non-cryptographic fingerprint of the platform tool catalog, over each tool's
* name/title/description/inputSchema/annotations/_meta. Sorted for stability across
* enumeration order, so a caller can detect catalog changes before pulling the full list.
*
* @param {Array<{name:string,title?:string,description?:string,inputSchema?:object,annotations?:object,_meta?:object}>} tools
* @returns {string}
*/
function computeCatalogHash (tools) {
const items = (tools || []).map(t => JSON.stringify({
n: t.name,
d: t.description || '',
s: t.inputSchema || null,
a: t.annotations || null,
m: t._meta || null,
t: t.title || null
}))
items.sort()
const str = items.join('')
let h = 5381
for (let i = 0; i < str.length; i++) {
h = (((h << 5) + h) ^ str.charCodeAt(i)) >>> 0
}
// Prefix with count + length to make incidental 32-bit collisions vanishingly unlikely.
return `${items.length}-${str.length}-${h.toString(16)}`
}

/**
* PlatformAutomationHandler
* @class PlatformAutomationHandler
Expand All @@ -21,6 +48,8 @@ class PlatformAutomationHandler {
/** Tool definitions without the handler functions - for sending across the wire to the agent for tool discovery */
this._wireToolDefinitions = null
this._fullToolDefinitions = null
/** Deterministic fingerprint of the wire tool definitions - for cheap catalog change detection */
this._catalogHash = null

this.setupEventHandler()
}
Expand All @@ -33,13 +62,15 @@ class PlatformAutomationHandler {
if (!this._fullToolDefinitions) {
const { loadToolDefinitions } = require('../ee/lib/mcp/toolLoader')
this._fullToolDefinitions = loadToolDefinitions()
this._wireToolDefinitions = this._fullToolDefinitions.map(({ name, title, description, inputSchema, annotations }) => ({
this._wireToolDefinitions = this._fullToolDefinitions.map(({ name, title, description, inputSchema, annotations, _meta }) => ({
name,
title,
description,
inputSchema: inputSchema && z.toJSONSchema(z.object(inputSchema)),
annotations
annotations,
_meta
}))
this._catalogHash = computeCatalogHash(this._wireToolDefinitions)
}
}

Expand All @@ -51,6 +82,14 @@ class PlatformAutomationHandler {
return this._wireToolDefinitions
}

/**
* Returns a deterministic fingerprint of the wire tool definitions.
*/
getCatalogHash () {
this.loadTools()
return this._catalogHash
}

/**
* Finds a tool definition by name (including its handler).
*/
Expand All @@ -69,7 +108,13 @@ class PlatformAutomationHandler {

switch (command) {
case 'mcp-get-features':
result = { tools: this.getToolDefinitions() }
if (data?.hashOnly) {
// Cheap catalog change detection: return just the fingerprint so
// the caller can decide whether it needs to pull the full list.
result = { catalogHash: this.getCatalogHash() }
} else {
result = { tools: this.getToolDefinitions(), catalogHash: this.getCatalogHash() }
}
break
case 'mcp-call-tool': {
const toolName = data?.name
Expand Down
64 changes: 64 additions & 0 deletions test/unit/forge/comms/platformAutomation_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ describe('PlatformAutomationHandler', function () {
})
}

function invokeGetFeatures ({ hashOnly } = {}) {
return new Promise((resolve) => {
const onSuccess = (result) => resolve({ ok: true, result })
const onError = (message, code, err) => resolve({ ok: false, message, code, err })
handler.eventHandler(
{
command: 'mcp-get-features',
data: hashOnly ? { hashOnly: true } : {}
},
onSuccess,
onError
)
})
}

before(async function () {
app = await setup({
license,
Expand Down Expand Up @@ -177,6 +192,55 @@ describe('PlatformAutomationHandler', function () {
})
})

describe('mcp-get-features', function () {
it('returns the tool list along with a catalogHash', async function () {
const res = await invokeGetFeatures()

res.ok.should.be.true()
res.result.should.have.property('tools').which.is.an.Array()
res.result.tools.length.should.be.greaterThan(0)
res.result.should.have.property('catalogHash').which.is.a.String()
res.result.catalogHash.should.match(/^\d+-\d+-[0-9a-f]+$/)
})

it('returns only the catalogHash when hashOnly is set, omitting tools', async function () {
const res = await invokeGetFeatures({ hashOnly: true })

res.ok.should.be.true()
res.result.should.have.property('catalogHash').which.is.a.String()
res.result.should.not.have.property('tools')
})

it('returns the same catalogHash for both hashOnly and full requests', async function () {
const full = await invokeGetFeatures()
const hashOnly = await invokeGetFeatures({ hashOnly: true })

full.result.catalogHash.should.equal(hashOnly.result.catalogHash)
})

it('returns a deterministic catalogHash across repeated calls', async function () {
const first = await invokeGetFeatures()
const second = await invokeGetFeatures()

first.result.catalogHash.should.equal(second.result.catalogHash)
})

it('returns the same catalogHash from a freshly constructed handler for the same catalog', async function () {
const otherHandler = PlatformAutomationHandler(app, mockClient())

const res = await invokeGetFeatures()
const otherHash = otherHandler.getCatalogHash()

res.result.catalogHash.should.equal(otherHash)
})

it('getCatalogHash() matches the catalogHash returned over the event handler', async function () {
const res = await invokeGetFeatures()

handler.getCatalogHash().should.equal(res.result.catalogHash)
})
})

describe('end-to-end audit trail', function () {
it('tool call produces audit entry with source mcp:expert and toolName', async function () {
const tool = handler.findTool('platform_create_application')
Expand Down
Loading