UNPKG

@flowfuse/flowfuse

Version:

An open source low-code development platform

756 lines (710 loc) 40.6 kB
/** * Expert api routes * * - /api/v1/expert * * @namespace expert * @memberof forge.routes.api */ const { default: axios } = require('axios') const semver = require('semver') const { v4: uuidv4 } = require('uuid') const { filterAccessibleMCPServerFeatures } = require('../../../services/expert.js') /** @type {typeof import('../../../comms/devices.js').DeviceCommsHandler} */ const getDeviceComms = (app) => { return app.comms?.devices } /** * Run `fn` over `items` with at most `limit` concurrent executions. * `fn` must not throw (each task should wrap its own errors) so a single failure cannot sink the * pool - this gives Promise.allSettled-like semantics. `isStopped()` is polled before pulling each * next item: when it returns true the workers stop taking new work (used to enforce the overall * deadline). It does NOT cancel in-flight work - there is no abort signal for MQTT calls. * @param {any[]} items * @param {number} limit * @param {(item:any, index:number) => Promise<void>} fn * @param {() => boolean} [isStopped] */ async function mapWithConcurrency (items, limit, fn, isStopped) { let cursor = 0 const worker = async () => { while (cursor < items.length) { if (isStopped?.()) { return } const index = cursor++ await fn(items[index], index) } } await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) } /** * Maps a platform automation tool's wire definition into a catalog entry for the * Expert permissions UI. The read/write/delete class comes from the MCP annotations * (readOnlyHint / destructiveHint), and `group: 'platform'` routes it to the platform * section. The label is the tool's own `title`, falling back to a name-derived label. */ const curatePlatformTool = (def) => { const annotations = def.annotations || {} const readOnly = annotations.readOnlyHint === true const destructive = annotations.destructiveHint === true return { key: def.name, name: def.title || def.name.replace(/^platform_/, '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()), description: def.description, toolClass: readOnly ? 'read' : (destructive ? 'delete' : 'write'), destructive, group: 'platform' } } /** * @param {import('../../forge.js').ForgeApplication} app */ module.exports = async function (app) { app.addHook('preHandler', app.verifySession) app.addHook('preHandler', async (request, reply) => { if (!app.expert.serviceEnabled || !app.expert.expertUrl) { return reply.code(501).send({ code: 'service_disabled', error: 'Expert service is not enabled' }) } // Only permit requests made by a valid user client if (!request.session || request.session.provisioning) { reply.code(401).send({ code: 'unauthorized', error: 'unauthorized' }) } else if (request.session.ownerType === 'device' || request.session.ownerType === 'project') { reply.code(401).send({ code: 'unauthorized', error: 'unauthorized' }) } else { // Get the user object request.user = await app.db.models.User.byId(request.session.User.id) if (!request.user) { return reply.code(401).send({ code: 'unauthorized', error: 'unauthorized' }) } // Ensure users team access is valid. `teamId` is the team hash provided by the // client — in the body context for POST routes (/chat, /mcp/features) or as a // query param for GET routes (/mcp/tools, which has no body). const teamId = request.body?.context?.teamId || request.query?.teamId if (!teamId) { return reply.status(404).send({ code: 'not_found', error: 'Not Found' }) } const existingRole = await request.user.getTeamMembership(teamId) if (!existingRole) { return reply.status(404).send({ code: 'not_found', error: 'Not Found' }) } request.teamMembership = existingRole request.team = await app.db.models.Team.byId(teamId) if (!request.team) { return reply.status(404).send({ code: 'not_found', error: 'Not Found' }) } const teamType = await request.team.getTeamType() const teamHttpSecurityFeature = !!teamType.properties.features?.teamHttpSecurity request.teamHttpSecurityFeature = teamHttpSecurityFeature // Check AI and expert feature flags at platform and team level await request.team.ensureTeamTypeExists() const isAiEnabled = !!(app.config.features.enabled('ai') && request.team.getFeatureProperty('ai', true)) if (!isAiEnabled) { return reply.status(404).send({ code: 'not_found', error: 'Not Found' }) } const isExpertAssistantEnabled = !!(app.config.features.enabled('expertAssistant') && request.team.getFeatureProperty('expertAssistant', true)) const isExpertInsightsEnabled = !!(app.config.features.enabled('expertInsights') && request.team.getFeatureProperty('expertInsights', true)) if (!isExpertAssistantEnabled && !isExpertInsightsEnabled) { return reply.status(404).send({ code: 'not_found', error: 'Not Found' }) } request.isExpertAssistantEnabled = isExpertAssistantEnabled request.isExpertInsightsEnabled = isExpertInsightsEnabled } }) app.post('/chat', { schema: { hide: true, // dont show in swagger body: { type: 'object', properties: { history: { type: 'array', items: { type: 'object', additionalProperties: true } }, context: { type: 'object', additionalProperties: true }, query: { type: 'string' } }, required: ['context'] }, response: { 200: { type: 'object', additionalProperties: true }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { const sessionId = request.headers['x-chat-session-id'] ?? uuidv4() const transactionId = request.headers['x-chat-transaction-id'] const context = request.body.context || {} // If MCP capabilities are provided in the context, filter them based on user access const selectedCapabilities = context.selectedCapabilities if (selectedCapabilities && Array.isArray(selectedCapabilities) && selectedCapabilities.length > 0) { const applicationCache = {} const mcpServersList = [] // Premise: // If the user has provided a list of MCP servers they wish to use in the chat session, // in order of computational cost, we need to: // 1. filter out any MCP servers the user does not have access to at all (application level) // 2. filter out any MCP server features (prompts/resources/tools) the user does not have access to (feature level) // 3. re-resolve the remaining MCP servers against the team's trusted MCP registry, verify // instance/application ownership, and get/create access tokens as needed based on the // instance's node security settings // first pass - get associated applications for the MCP servers selected by user const mcpServerIds = [] for (const server of selectedCapabilities || []) { const { application: applicationId, mcpServer } = server if (!applicationId || !mcpServer) { continue } if (!Object.hasOwnProperty.call(applicationCache, applicationId)) { applicationCache[applicationId] = await app.db.models.Application.byId(applicationId) } const application = applicationCache[applicationId] if (application) { mcpServerIds.push(mcpServer) mcpServersList.push({ server, application }) } } // second pass - filter features per MCP server based on user access to features (e.g. a tool with the destructive hint requires extra permission than a read-only tool) const accessibleServers = filterAccessibleMCPServerFeatures(app, mcpServersList, request.team, request.teamMembership) // Build the team's trusted MCP registry. This is the source of truth for which MCP servers // exist and their transport details (instance, endpoint, protocol). The client-supplied // transport fields are NEVER trusted - they should always be re-resolved from the trusted registry. const registrations = await app.db.models.MCPRegistration.byTeam(request.team.id, { includeInstance: true, filterId: mcpServerIds }) || [] const trustedRegistrations = new Map() for (const reg of registrations) { if (reg.targetType === 'instance' && reg.Project) { trustedRegistrations.set(`${reg.Project.id}::${reg.hashid}`, reg) } else if (reg.targetType === 'device' && reg.Device) { trustedRegistrations.set(`${reg.Device.hashid}::${reg.hashid}`, reg) } else { continue } } // final pass - re-resolve each accessible server against the trusted registry, verify // instance/application ownership, then apply access tokens as needed for (const server of accessibleServers) { const application = applicationCache[server.application] if (!application || !server.instance) { server._invalid = true // flag the server as invalid to be filtered out later continue } // re-resolve against the trusted registry - drops any selection that is not a // registered MCP server for this team const registration = trustedRegistrations.get(`${server.instance}::${server.mcpServer}`) const instance = registration?.Project || registration?.Device if (!instance) { server._invalid = true continue } // SECURITY: the registered instance must belong to the claimed application (the user was // only permission-checked against the claimed application in the second pass). This // ownership check MUST happen BEFORE reading or minting any access token, because the // token cache is keyed by instanceId alone. if (instance.ApplicationId !== application.id) { server._invalid = true continue } const instanceType = registration.targetType // trusted instance type ('instance') // Overwrite all transport/identity fields with trusted values - never trust the client's // copy. The agent rebuilds mcpServerUrl from instanceUrl + mcpEndpoint, as it does for // the /mcp/features response. server.team = request.team.hashid server.application = application.hashid server.instanceType = instanceType server.instanceName = instance.name server.instanceUrl = instance.url server.mcpServer = registration.hashid server.mcpServerName = registration.name server.mcpEndpoint = registration.endpointRoute server.mcpProtocol = registration.protocol server.title = registration.title server.version = registration.version server.description = registration.description delete server.mcpServerUrl // ownership verified - safe to read/create the cached access token for this instance const idString = instanceType === 'instance' ? instance.id : instance.hashid server.mcpAccessToken = await app.expert.mcp.getOrCreateToken(instance, instanceType, idString, request.teamHttpSecurityFeature) } const filteredAccessibleServers = accessibleServers.filter(s => !s._invalid) context.selectedCapabilities = filteredAccessibleServers?.length > 0 ? filteredAccessibleServers : undefined } let query = request.body.query if (request.body.history) { query = '' } try { const response = await axios.post(app.expert.expertUrl, { query, history: request.body.history, context: request.body.context }, { headers: { Origin: request.headers.origin, 'X-Chat-Session-ID': sessionId, 'X-Chat-Transaction-ID': transactionId, 'X-Chat-Namespace-ID': app.license.get('id'), ...(app.expert.serviceToken ? { Authorization: `Bearer ${app.expert.serviceToken}` } : {}) }, timeout: app.expert.requestTimeout }) if (response.data.transactionId !== transactionId) { throw new Error('Transaction ID mismatch') } reply.send(response.data) } catch (error) { reply.code(error.response?.status || 500).send({ code: error.response?.data?.code || 'unexpected_error', error: error.response?.data?.error || error.message }) } }) /** * an endpoint to retrieve MCP features (prompts/resources/tools) for the users team */ app.post('/mcp/features', { schema: { hide: true, // dont show in swagger headers: { type: 'object', properties: { 'x-chat-transaction-id': { type: 'string', minLength: 1 } }, required: ['x-chat-transaction-id'] }, body: { type: 'object', properties: { context: { type: 'object', properties: { teamId: { type: 'string', minLength: 10 } }, required: ['teamId'], additionalProperties: true } }, required: ['context'] }, response: { // Kept open: fast-json-stringify strips undeclared fields, but Ajv // response-validation runs before that — locking would reject stripped fields. 200: { type: 'object', properties: { transactionId: { type: 'string' }, servers: { type: 'array', items: { type: 'object', properties: { team: { type: 'string' }, application: { type: 'string' }, instance: { type: 'string' }, instanceType: { type: 'string', enum: ['instance', 'device'] }, instanceName: { type: 'string' }, mcpServer: { type: 'string' }, mcpServerName: { type: 'string' }, prompts: { type: 'array', items: { type: 'object', additionalProperties: true } }, resources: { type: 'array', items: { type: 'object', additionalProperties: true } }, resourceTemplates: { type: 'array', items: { type: 'object', additionalProperties: true } }, tools: { type: 'array', items: { type: 'object', additionalProperties: true } }, title: { type: 'string' }, version: { type: 'string' }, description: { type: 'string' } }, required: ['instance', 'instanceType', 'instanceName', 'mcpServer', 'mcpServerName', 'prompts', 'resources', 'resourceTemplates', 'tools'] } }, incompatibleServers: { type: 'array', items: { type: 'object', properties: { instance: { type: 'string' }, instanceType: { type: 'string', enum: ['instance', 'device'] }, instanceName: { type: 'string' }, currentVersion: { type: 'string' }, minimumSupportedVersion: { type: 'string' } }, required: ['instance', 'instanceType'] } }, summary: { type: 'object', properties: { instanceCount: { type: 'integer' }, mcpServerCount: { type: 'integer' }, enumeratedCount: { type: 'integer' }, unenumeratedCount: { type: 'integer' }, limit: { type: 'integer' }, limited: { type: 'boolean' }, deadlineReached: { type: 'boolean' } } } } }, '4xx': { $ref: 'APIError' } } } }, /** * Get MCP capabilities for the user's team * @param {import('fastify').FastifyRequest} request * @param {import('fastify').FastifyReply} reply */ async (request, reply) => { if (!request.isExpertInsightsEnabled) { return reply.status(404).send({ code: 'not_found', error: 'Not Found' }) } // MCP feature enumeration config // // Minimum launcher/agent versions that support MCP feature enumeration const MIN_HOSTED_INSTANCE_LAUNCHER_VERSION = '2.32.0' const MIN_REMOTE_INSTANCE_AGENT_VERSION = '4.0.0' // Total cap on the number of MCP servers scanned per request. Instances are scanned until this // many servers have been planned; any remainder is reported as unenumerated in the response summary. const MAX_SERVERS = app.config.expert?.insights?.maxServers ?? 20 // 20, for now, until otherwise requested. // Bounded fan-out for the per-instance scan. Kept modest to leave DB pool + broker headroom for the // rest of the platform (the endpoint itself does not need more concurrency to meet its time budget). const ENUM_CONCURRENCY = app.config.expert?.insights?.enumConcurrency ?? 5 // increasing this might risk of hitting DB pool or broker limits. 5 is a reasonable starting point. // Overall wall-clock budget for the scan. Once reached we stop scanning further instances and return // whatever has been enumerated so far (in-flight calls are left to run out their own timeouts). const ENUM_DEADLINE_MS = app.config.expert?.insights?.enumDeadlineMs ?? 10_000 // default to 10 seconds // Per-device MQTT timeouts. There is no abort for an in-flight MQTT call, so these bound a single call. const DEVICE_LIVENESS_TIMEOUT = app.config.expert?.insights?.deviceLivenessTimeout ?? 3_000 const DEVICE_FEATURES_TIMEOUT = app.config.expert?.insights?.deviceFeaturesTimeout ?? 5_000 try { // Premise: // In order to get the MCP features (prompts/resources/tools) available to the user for their // team / application RBACs, in order of computational cost, we need do the following: // 1. Get the MCP servers registered for this team // 2. For each MCP server // 1. Ensure the instance is supposed to be running (i.e. state is 'running') // 2. Get the application and check RBAC permits access // 3. Ensure the instance is actually running (live state check) // 5. For each running instance with MCP server, get/create access token as needed // based on the instance's node security settings // 6. Call to backend expert service to get the MCP features for the accessible MCP servers // 7. Filter the MCP features based on user RBACs (e.g. destructive tool access) // 3. Return the filtered MCP features to the client const transactionId = request.headers['x-chat-transaction-id'] const deviceComms = getDeviceComms(app) // Get the MCP servers registered for this team const mcpServers = await app.db.models.MCPRegistration.byTeam(request.team.id, { includeInstance: true }) || [] // PASS 1 - group registrations by instance. // An instance can have multiple MCP servers registered, but the expensive per-instance work // (live-state check + access token) should only run once per instance. So here we do only the // cheap checks (team match, expected state, application access) and group the registrations by // instance. The live-state check / token retrieval happens once per instance in pass 2. /** @type {Object<string, { instance: any, instanceType: 'instance'|'device', application: any, registrations: any[] }>} */ const instanceGroups = {} const applicationCache = {} for (const server of mcpServers) { const { hashid, name, protocol, endpointRoute, TeamId, Project, Device, title, version, description } = server if (TeamId !== request.team.id) { // shouldn't happen due to byTeam filter, but just in case continue } let instance, instanceId, instanceType if (Device) { instanceType = 'device' instance = Device instanceId = Device.hashid } else if (Project) { instanceType = 'instance' instance = Project instanceId = Project.id } else { continue } // if instance is not expected to be running, skip it (avoids unnecessary timeouts) if (instance?.state !== 'running') { continue } // if this is a device and its status column is not "online", skip it (avoids unnecessary timeouts) if (instanceType === 'device' && instance?.status !== 'online') { continue } // Ensure instance has an associated application if (!instance?.ApplicationId) { continue // e.g. skip devices without an application as they can't be validated for access } // Get the application from local cache or db (an application can appear multiple times if multiple instances are registered) const applicationHashid = app.db.models.Application.encodeHashid(instance.ApplicationId) if (!Object.hasOwnProperty.call(applicationCache, applicationHashid)) { applicationCache[applicationHashid] = await app.db.models.Application.byId(applicationHashid) } const application = applicationCache[applicationHashid] if (!application) { continue // skip - application not found } // Now we have the application & know the instance is supposed to be running, check user actually // has access before bothering to check instance live state or calling backend for MCP features! if (!app.hasPermission(request.teamMembership, 'expert:insights:mcp:allow', { application })) { continue // user doesn't have access to this instance } // Group the registration under its instance so per-instance work runs only once in pass 2 if (!instanceGroups[instanceId]) { instanceGroups[instanceId] = { instance, instanceType, application, registrations: [] } } instanceGroups[instanceId].registrations.push({ hashid, name, protocol, endpointRoute, title, version, description }) } // PASS 2 + 3 - for each unique instance, confirm it is actually reachable/running (live-state // check) and that its launcher/agent version supports MCP features, then fetch those features. // This runs as a bounded, pipelined per-instance scan: each instance flows live-state -> token // -> features independently, so one slow instance does not hold up the others, and the whole // scan is capped by an overall deadline. const incompatibleServers = [] const mcpServersResponse = [] // flat [{ spec, features }] - one entry per successfully enumerated server // Apply the total cap: take whole instances up to MAX_MCP_SERVERS, slicing the boundary instance // if needed so we never exceed the cap and always make progress. (Selection is in registration // order for now - prioritisation could be added later.) const candidateGroups = Object.entries(instanceGroups).map(([instanceId, group]) => ({ instanceId, ...group })) const candidateInstanceCount = candidateGroups.length const candidateServerCount = candidateGroups.reduce((total, group) => total + group.registrations.length, 0) const groupsToScan = [] let plannedServerCount = 0 for (const group of candidateGroups) { if (plannedServerCount >= MAX_SERVERS) { break } const remaining = MAX_SERVERS - plannedServerCount const registrations = group.registrations.length <= remaining ? group.registrations : group.registrations.slice(0, remaining) groupsToScan.push({ ...group, registrations }) plannedServerCount += registrations.length } // Scan a single instance: version gate -> live-state -> token -> features. Never throws: any // failure (timeout, unreachable, driver error) skips the instance so it cannot sink the pool. // Results are pushed into the shared collectors as they complete. const scanInstance = async ({ instanceId, instance, instanceType, application, registrations }) => { try { if (instanceType === 'instance' && instance) { // Check that instance launcher supports the required features before attempting to get the live state. if (!instance.versions?.launcher?.current || semver.lt(instance.versions.launcher.current, MIN_HOSTED_INSTANCE_LAUNCHER_VERSION)) { incompatibleServers.push({ instance: instanceId, instanceType, instanceName: instance.name, currentVersion: instance.versions?.launcher?.current, minimumSupportedVersion: MIN_HOSTED_INSTANCE_LAUNCHER_VERSION }) return // skip - launcher version too old to support MCP features via the admin API } // Next check that the instance is actually running before calling MCP features (querying a non-running instance would cause timeouts) const liveState = await instance.liveState({ omitStorageFlows: true }) if (liveState?.meta?.state !== 'running') { return } } else if (instanceType === 'device' && instance) { // Check that device agent version supports the required features before attempting to get the live state. if (!instance.agentVersion || semver.lt(instance.agentVersion, MIN_REMOTE_INSTANCE_AGENT_VERSION)) { incompatibleServers.push({ instance: instanceId, instanceType, instanceName: instance.name, currentVersion: instance.agentVersion, minimumSupportedVersion: MIN_REMOTE_INSTANCE_AGENT_VERSION }) return // skip - agent version too old to support MCP features } // Next check that the device is actually running before offering MCP features (querying a non-running device would cause timeouts) const liveState = await deviceComms.sendCommandAwaitReply(request.team.hashid, instanceId, 'get-liveState', {}, { timeout: DEVICE_LIVENESS_TIMEOUT }) if (liveState?.state !== 'running') { return } } else { return // unsupported instance type or instance not found, skip it. } // Check instance settings for node security. If FlowFuse auth is enabled, generate a short-lived (5 mins) // auth token for the instance with a scope limited to MCP access and cache it in memory for subsequent requests const mcpAccessToken = await app.expert.mcp.getOrCreateToken(instance, instanceType, instanceId, request.teamHttpSecurityFeature) const specs = registrations.map(reg => ({ team: request.team.hashid, application: application.hashid, instance: instanceId, instanceType, instanceName: instance.name, instanceUrl: instance.url, mcpServer: reg.hashid, mcpServerName: reg.name, mcpEndpoint: reg.endpointRoute, mcpProtocol: reg.protocol, mcpAccessToken, accessToken: mcpAccessToken, title: reg.title, version: reg.version, description: reg.description })) // Fetch features: hosted instances via the launcher admin API, remote instances (devices) via MQTT. let responses if (instanceType === 'instance') { responses = await app.containers.getMCPFeatures(instance, specs) } else { responses = await deviceComms.sendCommandAwaitReply(request.team.hashid, instance.hashid, 'mcp:get-features', { mcpEndPoints: specs }, { timeout: DEVICE_FEATURES_TIMEOUT }) } for (const item of (responses || [])) { mcpServersResponse.push(item) // { spec, features } } } catch (error) { // assume instance is offline/unreachable/errored and skip it - never sink the pool app.log.debug(`[expert/mcp] skipping instance ${instanceId} during MCP feature scan: ${error.message}`) } } // Drive the scan under a bounded concurrency limit and an overall deadline. The deadline only // stops the pool from scanning further instances; in-flight MQTT/launcher calls run out their // own per-call timeouts (there is no abort for MQTT). let deadlineReached = false let deadlineTimer const deadline = new Promise(resolve => { deadlineTimer = setTimeout(() => { deadlineReached = true; resolve() }, ENUM_DEADLINE_MS) }) const scan = mapWithConcurrency(groupsToScan, ENUM_CONCURRENCY, scanInstance, () => deadlineReached) await Promise.race([scan, deadline]) clearTimeout(deadlineTimer) // Summary of scan coverage. `unenumeratedCount` is a single derived difference so it can never drift as // it captures all servers not enumerated for any reason (cap, deadline, not-running, old version, error). const enumeratedCount = mcpServersResponse.length const summary = { instanceCount: candidateInstanceCount, mcpServerCount: candidateServerCount, enumeratedCount, unenumeratedCount: candidateServerCount - enumeratedCount, limit: MAX_SERVERS, limited: candidateServerCount > MAX_SERVERS, deadlineReached } // if no servers were enumerated, return early if (mcpServersResponse.length === 0) { return reply.send({ servers: [], incompatibleServers, summary, transactionId }) } const serverList = [] // load the associate application models so that we can filter features based on user access for (const serverItem of mcpServersResponse) { const application = applicationCache[serverItem.spec.application] if (application) { serverList.push({ server: { ...serverItem.features, ...serverItem.spec }, application }) } } // now check tools/resources/prompts access per server based on team membership // response.data.servers = filterAccessibleMCPServerFeatures(app, serverList, request.team, request.teamMembership) const finalServers = filterAccessibleMCPServerFeatures(app, serverList, request.team, request.teamMembership) const finalServersView = finalServers.map(s => { return { // ownership info team: s.team, application: s.application, instance: s.instance, instanceType: s.instanceType, instanceName: s.instanceName, // mcp info mcpServer: s.mcpServer, // the MCP Servers ID - for keying the UI list and for the client to send back in the chat context to select which MCP server to use for a given query title: s.title || s.mcpServerName || s.mcpServer, description: s.description, mcpServerName: s.mcpServerName, version: s.version, tools: s.tools, resources: s.resources, resourceTemplates: s.resourceTemplates, prompts: s.prompts } }) reply.send({ servers: finalServersView, incompatibleServers, summary, transactionId }) } catch (error) { reply.code(error.response?.status || 500).send({ code: error.response?.data?.code || 'unexpected_error', error: error.response?.data?.error || error.message }) } }) /** * Returns the merged tool catalog for the Expert permissions UI: flow-building tools * proxied from the agent's /mcp/flow-tools endpoint, plus curated platform tools. A * `hash` of the flow-building catalog rides along so the browser refetches only when * it changes. Team access and feature gating are enforced by the shared preHandler. */ app.get('/mcp/tools', { schema: { hide: true, // dont show in swagger querystring: { type: 'object', properties: { teamId: { type: 'string', minLength: 10 } }, required: ['teamId'] }, response: { 200: { type: 'object', properties: { catalog: { type: 'array', items: { type: 'object', additionalProperties: true } }, hash: { type: ['string', 'null'] } } }, '4xx': { $ref: 'APIError' } } } }, async (request, reply) => { if (!request.isExpertAssistantEnabled) { return reply.status(404).send({ code: 'not_found', error: 'Not Found' }) } try { const toolsUrl = `${app.expert.expertUrl.split('/').slice(0, -1).join('/')}/mcp/flow-tools` const response = await axios.get(toolsUrl, { headers: { Origin: request.headers.origin, ...(app.expert.serviceToken ? { Authorization: `Bearer ${app.expert.serviceToken}` } : {}) }, timeout: app.expert.requestTimeout }) const catalog = response.data?.catalog || [] // Merge in the FlowFuse platform tools. They are global (no per-team filtering) // and served from the handler singleton already constructed on app.comms, so we // reuse it rather than newing one up — constructing re-registers its MQTT event // listener. getToolDefinitions() is synchronous and takes no args. const platformHandler = app.comms?.platformAutomation if (platformHandler) { const platformDefs = platformHandler.getToolDefinitions() || [] catalog.push(...platformDefs.map(curatePlatformTool)) } reply.send({ catalog, hash: response.data?.hash || null }) } catch (error) { // The tool catalog is a non-fatal enhancement (the client swallows failures and // gates safely with defaults). Never forward an upstream auth failure as our own // 401. The SPA's axios interceptor treats any 401 as session-expiry and logs the // user out, which an unrelated expert-service token rejection must not trigger. const upstreamStatus = error.response?.status app.log.warn(`[expert/mcp/tools] upstream tool-catalog fetch failed: status=${upstreamStatus} msg=${error.message}`) if (upstreamStatus === 401 || upstreamStatus === 403) { return reply.send({ catalog: [], hash: null }) } reply.code(upstreamStatus || 500).send({ code: error.response?.data?.code || 'unexpected_error', error: error.response?.data?.error || error.message }) } }) } /** * @typedef {Object} MCPServerItem MCP server info for a team * @property {string} team * @property {string} application * @property {string} instance * @property {string} instanceType * @property {string} instanceName * @property {string} instanceUrl * @property {string} mcpServer * @property {string} mcpServerName * @property {string} mcpEndpoint * @property {string} mcpProtocol * @property {string} [title] * @property {string} [version] * @property {string} [description] */