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
63 changes: 61 additions & 2 deletions forge/ee/routes/mcp/server.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js')
const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js')

const { loadToolDefinitions, registerTools } = require('./toolLoader')

// Load tool definitions once at startup
const toolDefinitions = loadToolDefinitions()

/**
* MCP Platform Tools Server
*
* Exposes FlowFuse platform management capabilities as MCP tools.
* Stateless Streamable HTTP: each POST creates a fresh McpServer and transport.
* Auth via Bearer token (PAT), forwarded through app.inject() to existing routes.
*
* @param {import('../../../forge').ForgeApplication} app
*/
Expand All @@ -18,9 +28,58 @@ module.exports = async function (app) {
}
})

// POST handler will be implemented in #7429
/**
* POST / - MCP protocol endpoint (Streamable HTTP)
*
* Each request creates a fresh McpServer instance with a stateless transport.
* The auth token is forwarded to all internal route calls via app.inject().
*/
app.post('/', async (request, reply) => {
reply.code(501).send({ code: 'not_implemented', error: 'MCP endpoint not yet implemented' })
const server = new McpServer(
{ name: 'FlowFuse Platform', version: '1.0.0' },
{ capabilities: { tools: {} } }
)

const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined // stateless, no server-side sessions
})

// Bind inject to this request's auth token
const inject = (opts) => {
return app.inject({
...opts,
headers: {
...opts.headers,
authorization: request.headers.authorization
}
})
}

// Stub scope check: will enforce PAT scopes once scoped PATs (#7411) land.
// When implemented, this will check tool.annotations against the PAT's
// readOnly flag and team scope restrictions.
const checkScope = (_tool) => {
return null // no restriction for now
}

registerTools(server, toolDefinitions, inject, checkScope)

await server.connect(transport)

// Hand off response handling to the MCP transport.
// reply.hijack() tells Fastify we're managing the response directly.
reply.hijack()

// The MCP SDK's transport uses @hono/node-server internally, which sets
// a drain timeout that calls socket.destroySoon(). Fastify's app.inject()
// creates mock sockets that lack this method, so we polyfill it.
const socket = request.raw.socket
if (socket && !socket.destroySoon) {
socket.destroySoon = () => socket.destroy?.()
}

await transport.handleRequest(request.raw, reply.raw, request.body)
await server.close()
})

// GET and DELETE are not supported in stateless mode
Expand Down
69 changes: 69 additions & 0 deletions forge/ee/routes/mcp/toolLoader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
const fs = require('fs')
const path = require('path')

const toolsDir = path.join(__dirname, 'tools')

/**
* Loads all tool definition files from the tools/ directory.
* Each file should export an array of tool definitions with:
* { name, description, inputSchema, annotations, handler }
*
* Definitions are loaded once at startup and reused across requests.
*/
function loadToolDefinitions () {
const files = fs.readdirSync(toolsDir).filter(f => f.endsWith('.js'))
const allTools = []
for (const file of files) {
const tools = require(path.join(toolsDir, file))
allTools.push(...tools)
}
return allTools
}

/**
* Registers all tool definitions on a McpServer instance.
* Called once per request since the server is stateless (fresh per request).
*
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
* @param {Array} toolDefinitions - loaded tool definitions
* @param {Function} inject - app.inject helper bound to the request's auth token
* @param {Function} checkScope - scope check function (stub for now)
*/
function registerTools (server, toolDefinitions, inject, checkScope) {
for (const tool of toolDefinitions) {
const config = {
description: tool.description,
annotations: tool.annotations
}
if (tool.inputSchema && Object.keys(tool.inputSchema).length > 0) {
config.inputSchema = tool.inputSchema
}

server.registerTool(tool.name, config, async (args) => {
const scopeError = checkScope(tool)
if (scopeError) {
return scopeError
}
const response = await tool.handler(args, { inject })
return formatResponse(response)
})
}
}

/**
* Formats an app.inject() response into an MCP CallToolResult.
*/
function formatResponse (response) {
const body = response.json()
if (response.statusCode >= 400) {
return {
content: [{ type: 'text', text: JSON.stringify(body) }],
isError: true
}
}
return {
content: [{ type: 'text', text: JSON.stringify(body, null, 2) }]
}
}

module.exports = { loadToolDefinitions, registerTools }
26 changes: 26 additions & 0 deletions forge/ee/routes/mcp/tools/teams.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const { z } = require('zod')

module.exports = [
{
name: 'list-teams',
description: 'List all teams the authenticated user belongs to. Returns team names, slugs, IDs, and membership roles.',
annotations: { readOnlyHint: true, destructiveHint: false },
inputSchema: {},
handler: async (args, { inject }) => {
const response = await inject({ method: 'GET', url: '/api/v1/user/teams' })
return response
}
},
{
name: 'get-team',
description: 'Get details of a specific team by its ID, including team type, member count, and instance counts.',
annotations: { readOnlyHint: true, destructiveHint: false },
inputSchema: {
teamId: z.string().describe('The ID or hashid of the team')
},
handler: async (args, { inject }) => {
const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}` })
return response
}
}
]
Loading
Loading