From 860533eff2c1fc358c61c0e4e4045b35c3f3588e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 20 Jul 2026 09:35:55 -0700 Subject: [PATCH] FN-8402: extract config, MCP, and Pi settings routes Extract config, MCP, and Pi-settings handlers into a dedicated dashboard route registrar. - Move seven configuration and MCP endpoint handlers out of the API-route orchestrator. - Preserve registrar mount precedence and document the expanded route map. - Add registrar coverage and update the inline-route modularity baseline. Files changed: .../src/__tests__/mcp-documentation.test.ts | 2 +- packages/dashboard/src/routes.ts | 303 +-------------------- packages/dashboard/src/routes/README.md | 84 +++--- .../register-config-mcp-pi-settings-routes.test.ts | 61 +++++ .../src/routes/create-api-routes-mount-sequence.ts | 2 +- .../register-config-mcp-pi-settings-routes.ts | 275 +++++++++++++++++++ scripts/lib/routes-modular-baseline.json | 2 +- 7 files changed, 385 insertions(+), 344 deletions(-) Fusion-Task-Id: FN-8402 Fusion-Task-Lineage: f5f71f64-03cf-41cf-9fb2-33046b0c04bf Co-authored-by: Fusion (runfusion.ai) --- .../src/__tests__/mcp-documentation.test.ts | 2 +- packages/dashboard/src/routes.ts | 303 +----------------- packages/dashboard/src/routes/README.md | 84 ++--- ...ster-config-mcp-pi-settings-routes.test.ts | 61 ++++ .../create-api-routes-mount-sequence.ts | 2 +- .../register-config-mcp-pi-settings-routes.ts | 275 ++++++++++++++++ scripts/lib/routes-modular-baseline.json | 2 +- 7 files changed, 385 insertions(+), 344 deletions(-) create mode 100644 packages/dashboard/src/routes/__tests__/register-config-mcp-pi-settings-routes.test.ts create mode 100644 packages/dashboard/src/routes/register-config-mcp-pi-settings-routes.ts diff --git a/packages/dashboard/src/__tests__/mcp-documentation.test.ts b/packages/dashboard/src/__tests__/mcp-documentation.test.ts index 6f9829284f..edb5c6ff03 100644 --- a/packages/dashboard/src/__tests__/mcp-documentation.test.ts +++ b/packages/dashboard/src/__tests__/mcp-documentation.test.ts @@ -37,7 +37,7 @@ describe("MCP documentation contract", () => { it("keeps documented MCP implementation surfaces aligned with source", () => { const mcpGuide = readDoc("docs/mcp.md"); - const routeSource = readDoc("packages/dashboard/src/routes.ts"); + const routeSource = readDoc("packages/dashboard/src/routes/register-config-mcp-pi-settings-routes.ts"); const cliSource = readDoc("packages/cli/src/commands/mcp.ts"); const settingsModalSource = readDoc("packages/dashboard/app/components/SettingsModal.tsx"); diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 1f9de5073f..26476f6831 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -10,7 +10,7 @@ import multer from "multer"; import { resolve, sep, join, isAbsolute } from "node:path"; import * as nodeFs from "node:fs"; -import type { AnthropicProviderRegistration, TaskStore, ModelPreset, McpServerDefinition, ThinkingLevel } from "@fusion/core"; +import type { AnthropicProviderRegistration, TaskStore, ModelPreset, ThinkingLevel } from "@fusion/core"; import { type Task, type PiExtensionEntry, @@ -23,7 +23,6 @@ import { listAgentMemoryFiles, readAgentMemoryFile, writeAgentMemoryFile, - validateMcpServerDefinitionDetailed, } from "@fusion/core"; import type { ServerOptions } from "./server.js"; import { SESSION_CLEANUP_DEFAULT_MAX_AGE_MS, type AiSessionType } from "./ai-session-store.js"; @@ -95,6 +94,7 @@ import { registerWorktrunkRoutes } from "./routes/register-worktrunk-routes.js"; import { registerSystemMaintenanceRoutes } from "./routes/register-system-maintenance-routes.js"; import { registerAiTextAssistantRoutes } from "./routes/register-ai-text-assistant-routes.js"; import { registerActivityLogRoutes, registerSetupActivityRoutes } from "./routes/register-setup-activity-routes.js"; +import { registerConfigMcpPiSettingsRoutes } from "./routes/register-config-mcp-pi-settings-routes.js"; import { runGitCommand } from "./routes/resolve-diff-base.js"; const TASK_DETAIL_ACTIVITY_LOG_LIMIT = 500; @@ -284,96 +284,8 @@ import { getExemptToolNames as engineGetExemptToolNames, reloadExemptTools as engineReloadExemptTools, resolveIntegrationBranch, - discoverMcpServers, - resolveMcpServersForRuntime, - resolveMcpServersForStore, - validateMcpServer, } from "@fusion/engine"; -interface McpValidateRequestBody { - name?: unknown; - server?: unknown; - definition?: unknown; - timeoutMs?: unknown; -} - -function parseMcpValidationTimeout(value: unknown): number | undefined { - if (value === undefined) return undefined; - if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { - throw badRequest("timeoutMs must be a positive number when provided"); - } - return Math.min(value, 30_000); -} - -function parseMcpValidationBody(body: unknown): { name?: string; definition?: McpServerDefinition; timeoutMs?: number } { - if (!body || typeof body !== "object" || Array.isArray(body)) { - throw badRequest("Request body must be an object"); - } - - const input = body as McpValidateRequestBody; - const name = typeof input.name === "string" ? input.name.trim() : undefined; - const rawDefinition = input.server ?? input.definition; - if (!name && rawDefinition === undefined) { - throw badRequest("Provide either name or server"); - } - if (input.name !== undefined && !name) { - throw badRequest("name must be a non-empty string when provided"); - } - - let definition: McpServerDefinition | undefined; - if (rawDefinition !== undefined) { - const parsed = validateMcpServerDefinitionDetailed(rawDefinition, "server"); - if (!parsed.value) { - throw badRequest("Invalid MCP server definition", { errors: parsed.errors.map((error) => error.message) }); - } - definition = parsed.value; - } - - return { name, definition, timeoutMs: parseMcpValidationTimeout(input.timeoutMs) }; -} - -function parseMcpDiscoveryScope(value: unknown): "global" | "project" { - if (value === undefined) return "project"; - const raw = Array.isArray(value) ? value[0] : value; - if (raw === "global" || raw === "project") return raw; - throw badRequest("scope must be either global or project"); -} - -function stripMcpSecretDescriptor(secret: { field: "env" | "headers" | "token"; key: string; suggestedKey: string; scope: "global" | "project" }) { - return { - field: secret.field, - key: secret.key, - suggestedKey: secret.suggestedKey, - scope: secret.scope, - }; -} - -async function resolveMcpServerForValidation( - scopedStore: TaskStore, - request: { name?: string; definition?: McpServerDefinition }, -) { - if (request.definition) { - const secrets = await scopedStore.getSecretsStore(); - const resolved = await resolveMcpServersForRuntime({ - globalSettings: { mcpServers: { enabled: true, servers: [request.definition] } }, - projectSettings: undefined, - secrets, - reader: {}, - }); - if (resolved.errors.length > 0 || resolved.servers.length === 0) { - throw badRequest("Unable to resolve MCP server secrets", { errors: resolved.errors.map((error) => ({ serverName: error.serverName, path: error.path, message: error.message })) }); - } - return resolved.servers[0]; - } - - const resolved = await resolveMcpServersForStore(scopedStore); - const server = resolved.servers.find((candidate) => candidate.name === request.name); - if (!server) { - throw badRequest("MCP server was not found or could not be resolved"); - } - return server; -} - // Test-injectable override; defaults to the statically imported engine binding. let createFnAgentForRefine: typeof import("@fusion/engine").createFnAgent | undefined = engineCreateFnAgentForRefine; @@ -1235,216 +1147,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout }); }; - // Scheduler config (includes persisted settings — only needs maxConcurrent/maxTriageConcurrent/maxWorktrees) - router.get("/config", async (req, res) => { - try { - const { store: scopedStore } = await getProjectContext(req); - const settings = await scopedStore.getSettingsFast(); - res.json({ - maxConcurrent: settings.maxConcurrent ?? options?.maxConcurrent ?? 2, - maxTriageConcurrent: settings.maxTriageConcurrent ?? settings.maxConcurrent ?? 2, - maxWorktrees: settings.maxWorktrees ?? 4, - rootDir: scopedStore.getRootDir(), - }); - } catch { - const { store: scopedStore } = await getProjectContext(req); - res.json({ maxConcurrent: options?.maxConcurrent ?? 2, maxTriageConcurrent: options?.maxConcurrent ?? 2, maxWorktrees: 4, rootDir: scopedStore.getRootDir() }); - } - }); - - router.get("/mcp/discovered", async (req, res) => { - try { - const { store: scopedStore } = await getProjectContext(req); - const scope = parseMcpDiscoveryScope(req.query.scope); - const [discovered, settingsByScope] = await Promise.all([ - discoverMcpServers({ scope, projectRootDir: scopedStore.getRootDir() }), - scopedStore.getSettingsByScopeFast(), - ]); - const configured = new Set((scope === "global" ? settingsByScope.global.mcpServers?.servers : settingsByScope.project.mcpServers?.servers)?.map((server) => server.name) ?? []); - /* - * FNXC:McpConfig 2026-06-26-10:31: - * The discovery API is read-only: it reports inert third-party MCP definitions for explicit user opt-in and strips plaintextValue before crossing the wire. The dashboard can create Fusion-managed secret references from these descriptors, but API clients never receive raw env/header/token material. - */ - res.json({ - sources: discovered.sources, - servers: discovered.servers.map((server) => ({ - source: server.source, - definition: server.definition, - alreadyConfigured: configured.has(server.definition.name), - hasPlaintextSecrets: server.secretsToCreate.length > 0, - secretDescriptors: server.secretsToCreate.map(stripMcpSecretDescriptor), - })), - errors: discovered.errors, - }); - } catch (error) { - rethrowAsApiError(error, "Failed to discover MCP servers"); - } - }); - - router.post("/mcp/validate", async (req, res) => { - try { - const { store: scopedStore } = await getProjectContext(req); - const request = parseMcpValidationBody(req.body); - const server = await resolveMcpServerForValidation(scopedStore, request); - // FNXC:McpConfig 2026-06-25-23:38: The validation API materializes MCP secrets only for the bounded probe and returns only status metadata, never resolved env/header values. - const result = await validateMcpServer(server, { - timeoutMs: request.timeoutMs, - cwd: scopedStore.getRootDir(), - }); - res.json(result); - } catch (error) { - rethrowAsApiError(error, "Failed to validate MCP server"); - } - }); - - router.get("/pi-settings", async (_req, res) => { - try { - const { SettingsManager, getAgentDir } = await import("@earendil-works/pi-coding-agent"); - const agentDir = getAgentDir(); - const settingsManager = SettingsManager.create(process.cwd(), agentDir); - const packages = settingsManager.getPackages(); - const extensions = settingsManager.getExtensionPaths(); - const skills = settingsManager.getSkillPaths(); - const prompts = settingsManager.getPromptTemplatePaths(); - const themes = settingsManager.getThemePaths(); - res.json({ packages, extensions, skills, prompts, themes }); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err); - } - }); - - /** - * PUT /api/pi-settings - * Updates the user's global pi extension settings in ~/.pi/agent/settings.json. - * Accepts partial updates: only provided fields are updated. - * Body: { packages?: PackageSource[], extensions?: string[], skills?: string[], prompts?: string[], themes?: string[] } - */ - router.put("/pi-settings", async (req, res) => { - try { - const { packages, extensions, skills, prompts, themes } = req.body as { - packages?: unknown; - extensions?: unknown; - skills?: unknown; - prompts?: unknown; - themes?: unknown; - }; - - // Validate that at least one field is provided - if (packages === undefined && extensions === undefined && skills === undefined && prompts === undefined && themes === undefined) { - throw badRequest("At least one setting field must be provided (packages, extensions, skills, prompts, or themes)"); - } - - const { SettingsManager, getAgentDir } = await import("@earendil-works/pi-coding-agent"); - const agentDir = getAgentDir(); - const settingsManager = SettingsManager.create(process.cwd(), agentDir); - - if (packages !== undefined) { - if (!Array.isArray(packages)) { - throw badRequest("packages must be an array"); - } - settingsManager.setPackages(packages as string[]); - } - if (extensions !== undefined) { - if (!Array.isArray(extensions)) { - throw badRequest("extensions must be an array of strings"); - } - settingsManager.setExtensionPaths(extensions as string[]); - } - if (skills !== undefined) { - if (!Array.isArray(skills)) { - throw badRequest("skills must be an array of strings"); - } - settingsManager.setSkillPaths(skills as string[]); - } - if (prompts !== undefined) { - if (!Array.isArray(prompts)) { - throw badRequest("prompts must be an array of strings"); - } - settingsManager.setPromptTemplatePaths(prompts as string[]); - } - if (themes !== undefined) { - if (!Array.isArray(themes)) { - throw badRequest("themes must be an array of strings"); - } - settingsManager.setThemePaths(themes as string[]); - } - - await settingsManager.flush(); - res.json({ success: true }); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err); - } - }); - - /** - * POST /api/pi-settings/packages - * Installs a new pi package source and adds it to the global settings. - * Body: { source: string } - */ - router.post("/pi-settings/packages", async (req, res) => { - try { - const { source } = req.body as { source?: unknown }; - if (typeof source !== "string" || !source.trim()) { - throw badRequest("source must be a non-empty string"); - } - - const { SettingsManager, DefaultPackageManager, getAgentDir } = await import("@earendil-works/pi-coding-agent"); - const agentDir = getAgentDir(); - const cwd = process.cwd(); - const settingsManager = SettingsManager.create(process.cwd(), agentDir); - const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); - - await packageManager.install(source.trim()); - const added = packageManager.addSourceToSettings(source.trim()); - if (!added) { - // Already in settings (setPackages deduplicates), treat as success - res.json({ success: true }); - return; - } - - await settingsManager.flush(); - res.json({ success: true }); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err); - } - }); - - /** - * POST /api/pi-settings/reinstall-fusion - * Reinstalls Fusion's bundled pi package and ensures it remains configured in global settings. - */ - router.post("/pi-settings/reinstall-fusion", async (_req, res) => { - try { - const source = "npm:@runfusion/fusion"; - const { SettingsManager, DefaultPackageManager, getAgentDir } = await import("@earendil-works/pi-coding-agent"); - const agentDir = getAgentDir(); - const cwd = process.cwd(); - const settingsManager = SettingsManager.create(process.cwd(), agentDir); - const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); - - await packageManager.install(source); - const added = packageManager.addSourceToSettings(source); - if (added) { - await settingsManager.flush(); - } - - res.json({ success: true, source }); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err); - } - }); + registrarMounter.mount("registerConfigMcpPiSettingsRoutes", () => registerConfigMcpPiSettingsRoutes(routeContext)); /** * POST /api/action-gate/reload diff --git a/packages/dashboard/src/routes/README.md b/packages/dashboard/src/routes/README.md index ccb325344a..75f9061b3a 100644 --- a/packages/dashboard/src/routes/README.md +++ b/packages/dashboard/src/routes/README.md @@ -25,6 +25,7 @@ The following is the complete top-level registrar map currently imported by `rou - `registerPluginsAutomationRoutes` — automation and routine CRUD/manual-run/webhook endpoints plus live SSE streams, and plugin-management endpoints. It preserves the `/plugins/:id` registry pass-through; `createPluginRouter` remains mounted later by `routes.ts` so `/plugins/registry` retains precedence. Its co-located `automation-live-run.ts`, `automation-step-execution.ts`, and `plugin-bundled-runtimes.ts` helpers own replayable output, execution, and bundled-runtime fallback metadata. - `registerApprovalRoutes` — domain registrar mounted by `createApiRoutes`. - `registerWorktrunkRoutes` — domain registrar mounted by `createApiRoutes`. +- `registerConfigMcpPiSettingsRoutes` (`register-config-mcp-pi-settings-routes.ts`) — config/MCP/Pi-settings registrar with 7 endpoint registrations: `GET /config`; `GET /mcp/discovered`; `POST /mcp/validate`; `GET /pi-settings`; `PUT /pi-settings`; `POST /pi-settings/packages`; `POST /pi-settings/reinstall-fusion`. - `registerSystemMaintenanceRoutes` — early-mounted system stats, vitest, maintenance-stamp, and backup routes; distinct from the late `/system/*` Command Center panel registrar. - `registerModelRoutes` — domain registrar mounted by `createApiRoutes`. - `registerCustomProviderRoutes` — domain registrar mounted by `createApiRoutes`. @@ -89,47 +90,48 @@ Express matches in registration order. `create-api-routes-mount-sequence.ts` is 13. `registerPluginsAutomationRoutes` 14. `registerApprovalRoutes` 15. `registerWorktrunkRoutes` -16. `registerSystemMaintenanceRoutes` -17. `registerModelRoutes` -18. `registerCustomProviderRoutes` -19. `registerAuthRoutes` -20. `registerRuntimeProviderRoutes` -21. `registerFnBinaryRoutes` -22. `registerAiTextAssistantRoutes` -23. `registerUsageRoutes` -24. `registerCommandCenterRoutes` -25. `registerKnowledgeRoutes` -26. `registerReportRoutes` -27. `registerSignalRoutes` -28. `registerMonitorRoutes` -29. `registerUpdateCheckRoutes` -30. `registerDiagnosticsRoutes` -31. `registerCliAgentHooksRoute` -32. `registerCliAgentSettingsRoutes` -33. `registerActivityLogRoutes` -34. `registerAgentCoreListCreateRoutes` -35. `registerAgentImportExportRoutes` -36. `registerOrgPortabilityRoutes` -37. `registerAgentCoreRoutes` -38. `registerAgentRuntimeRoutes` -39. `registerSystemRoutes` -40. `registerAgentReflectionRatingRoutes` -41. `registerAgentGenerationRoutes` -42. `registerIntegratedRouters` -43. `registerProjectRoutes` -44. `registerNodeRoutes` -45. `registerDockerNodeRoutes` -46. `registerDockerProvisioningRoutes` -47. `registerSettingsSyncRoutes` -48. `registerSecretsSyncRoutes` -49. `registerMeshRoutes` -50. `registerDiscoveryRoutes` -51. `registerSettingsSyncInboundRoutes` -52. `registerSecretsSyncInboundRoutes` -53. `registerSetupActivityRoutes` -54. `registerIntegratedDevServerRouter` -55. `registerAgentSkillsRoutes` -56. `registerProxyRoutes` +16. `registerConfigMcpPiSettingsRoutes` +17. `registerSystemMaintenanceRoutes` +18. `registerModelRoutes` +19. `registerCustomProviderRoutes` +20. `registerAuthRoutes` +21. `registerRuntimeProviderRoutes` +22. `registerFnBinaryRoutes` +23. `registerAiTextAssistantRoutes` +24. `registerUsageRoutes` +25. `registerCommandCenterRoutes` +26. `registerKnowledgeRoutes` +27. `registerReportRoutes` +28. `registerSignalRoutes` +29. `registerMonitorRoutes` +30. `registerUpdateCheckRoutes` +31. `registerDiagnosticsRoutes` +32. `registerCliAgentHooksRoute` +33. `registerCliAgentSettingsRoutes` +34. `registerActivityLogRoutes` +35. `registerAgentCoreListCreateRoutes` +36. `registerAgentImportExportRoutes` +37. `registerOrgPortabilityRoutes` +38. `registerAgentCoreRoutes` +39. `registerAgentRuntimeRoutes` +40. `registerSystemRoutes` +41. `registerAgentReflectionRatingRoutes` +42. `registerAgentGenerationRoutes` +43. `registerIntegratedRouters` +44. `registerProjectRoutes` +45. `registerNodeRoutes` +46. `registerDockerNodeRoutes` +47. `registerDockerProvisioningRoutes` +48. `registerSettingsSyncRoutes` +49. `registerSecretsSyncRoutes` +50. `registerMeshRoutes` +51. `registerDiscoveryRoutes` +52. `registerSettingsSyncInboundRoutes` +53. `registerSecretsSyncInboundRoutes` +54. `registerSetupActivityRoutes` +55. `registerIntegratedDevServerRouter` +56. `registerAgentSkillsRoutes` +57. `registerProxyRoutes` ## Ordering rules diff --git a/packages/dashboard/src/routes/__tests__/register-config-mcp-pi-settings-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-config-mcp-pi-settings-routes.test.ts new file mode 100644 index 0000000000..a8c7bd0979 --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/register-config-mcp-pi-settings-routes.test.ts @@ -0,0 +1,61 @@ +// @vitest-environment node + +import express from "express"; +import { describe, expect, it } from "vitest"; +import { ApiError } from "../../api-error.js"; +import { request } from "../../test-request.js"; +import { registerConfigMcpPiSettingsRoutes } from "../register-config-mcp-pi-settings-routes.js"; +import type { ApiRoutesContext } from "../types.js"; + +function createApp(settings = { maxConcurrent: 6, maxTriageConcurrent: 3, maxWorktrees: 2 }) { + const app = express(); + app.use(express.json()); + const store = { + getRootDir: () => "/workspace", + getSettingsFast: async () => settings, + }; + const context = { + router: app, + options: { maxConcurrent: 9 }, + getProjectContext: async () => ({ store, engine: undefined, projectId: undefined }), + rethrowAsApiError(error: unknown): never { + throw error; + }, + } as unknown as ApiRoutesContext; + registerConfigMcpPiSettingsRoutes(context); + app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + const apiError = error instanceof ApiError ? error : new ApiError(500, "Internal server error"); + res.status(apiError.statusCode).json({ error: apiError.message }); + }); + return app; +} + +describe("registerConfigMcpPiSettingsRoutes", () => { + it("returns stored scheduler concurrency values", async () => { + const response = await request(createApp(), "GET", "/config"); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ maxConcurrent: 6, maxTriageConcurrent: 3, maxWorktrees: 2, rootDir: "/workspace" }); + }); + + it("uses option and fixed defaults for missing scheduler settings", async () => { + const response = await request(createApp({}), "GET", "/config"); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ maxConcurrent: 9, maxTriageConcurrent: 2, maxWorktrees: 4, rootDir: "/workspace" }); + }); + + it("rejects malformed MCP validation bodies", async () => { + const response = await request(createApp(), "POST", "/mcp/validate", JSON.stringify({ timeoutMs: 1000 }), { "content-type": "application/json" }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: "Provide either name or server" }); + }); + + it("rejects pi-settings updates with no fields", async () => { + const response = await request(createApp(), "PUT", "/pi-settings", JSON.stringify({}), { "content-type": "application/json" }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: "At least one setting field must be provided (packages, extensions, skills, prompts, or themes)" }); + }); +}); diff --git a/packages/dashboard/src/routes/create-api-routes-mount-sequence.ts b/packages/dashboard/src/routes/create-api-routes-mount-sequence.ts index 69032dbffe..2ef9aaf286 100644 --- a/packages/dashboard/src/routes/create-api-routes-mount-sequence.ts +++ b/packages/dashboard/src/routes/create-api-routes-mount-sequence.ts @@ -9,7 +9,7 @@ export const CREATE_API_ROUTES_REGISTRAR_MOUNT_SEQUENCE = [ "registerSettingsMemoryRoutes", "registerSecretsRoutes", "registerTaskWorkflowRoutes", "registerWorkflowRoutes", "registerPlanningSubtaskRoutes", "registerChatRoutes", "registerChatRoomRoutes", "registerMessagingScriptRoutes", "registerGitGitHubRoutes", "registerGitLabRoutes", "registerFilesTerminalWorkspaceRoutes", "registerAgentsProjectsNodesRoutes", - "registerPluginsAutomationRoutes", "registerApprovalRoutes", "registerWorktrunkRoutes", "registerSystemMaintenanceRoutes", "registerModelRoutes", + "registerPluginsAutomationRoutes", "registerApprovalRoutes", "registerWorktrunkRoutes", "registerConfigMcpPiSettingsRoutes", "registerSystemMaintenanceRoutes", "registerModelRoutes", "registerCustomProviderRoutes", "registerAuthRoutes", "registerRuntimeProviderRoutes", "registerFnBinaryRoutes", "registerAiTextAssistantRoutes", "registerUsageRoutes", "registerCommandCenterRoutes", "registerKnowledgeRoutes", "registerReportRoutes", "registerSignalRoutes", "registerMonitorRoutes", "registerUpdateCheckRoutes", "registerDiagnosticsRoutes", diff --git a/packages/dashboard/src/routes/register-config-mcp-pi-settings-routes.ts b/packages/dashboard/src/routes/register-config-mcp-pi-settings-routes.ts new file mode 100644 index 0000000000..9ca8b4b17f --- /dev/null +++ b/packages/dashboard/src/routes/register-config-mcp-pi-settings-routes.ts @@ -0,0 +1,275 @@ +import type { McpServerDefinition, TaskStore } from "@fusion/core"; +import { validateMcpServerDefinitionDetailed } from "@fusion/core"; +import { + discoverMcpServers, + resolveMcpServersForRuntime, + resolveMcpServersForStore, + validateMcpServer, +} from "@fusion/engine"; +import { ApiError, badRequest } from "../api-error.js"; +import type { ApiRouteRegistrar } from "./types.js"; + +/* +FNXC:RouteModularity 2026-07-19-14:30: +The FN-8365 modular-routing ratchet requires configuration, MCP, and Pi-settings +endpoints to leave routes.ts while retaining their historical mount precedence. +*/ + +interface McpValidateRequestBody { + name?: unknown; + server?: unknown; + definition?: unknown; + timeoutMs?: unknown; +} + +function parseMcpValidationTimeout(value: unknown): number | undefined { + if (value === undefined) return undefined; + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + throw badRequest("timeoutMs must be a positive number when provided"); + } + return Math.min(value, 30_000); +} + +function parseMcpValidationBody(body: unknown): { name?: string; definition?: McpServerDefinition; timeoutMs?: number } { + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw badRequest("Request body must be an object"); + } + + const input = body as McpValidateRequestBody; + const name = typeof input.name === "string" ? input.name.trim() : undefined; + const rawDefinition = input.server ?? input.definition; + if (!name && rawDefinition === undefined) { + throw badRequest("Provide either name or server"); + } + if (input.name !== undefined && !name) { + throw badRequest("name must be a non-empty string when provided"); + } + + let definition: McpServerDefinition | undefined; + if (rawDefinition !== undefined) { + const parsed = validateMcpServerDefinitionDetailed(rawDefinition, "server"); + if (!parsed.value) { + throw badRequest("Invalid MCP server definition", { errors: parsed.errors.map((error) => error.message) }); + } + definition = parsed.value; + } + + return { name, definition, timeoutMs: parseMcpValidationTimeout(input.timeoutMs) }; +} + +function parseMcpDiscoveryScope(value: unknown): "global" | "project" { + if (value === undefined) return "project"; + const raw = Array.isArray(value) ? value[0] : value; + if (raw === "global" || raw === "project") return raw; + throw badRequest("scope must be either global or project"); +} + +function stripMcpSecretDescriptor(secret: { field: "env" | "headers" | "token"; key: string; suggestedKey: string; scope: "global" | "project" }) { + return { + field: secret.field, + key: secret.key, + suggestedKey: secret.suggestedKey, + scope: secret.scope, + }; +} + +async function resolveMcpServerForValidation( + scopedStore: TaskStore, + request: { name?: string; definition?: McpServerDefinition }, +) { + if (request.definition) { + const secrets = await scopedStore.getSecretsStore(); + const resolved = await resolveMcpServersForRuntime({ + globalSettings: { mcpServers: { enabled: true, servers: [request.definition] } }, + projectSettings: undefined, + secrets, + reader: {}, + }); + if (resolved.errors.length > 0 || resolved.servers.length === 0) { + throw badRequest("Unable to resolve MCP server secrets", { errors: resolved.errors.map((error) => ({ serverName: error.serverName, path: error.path, message: error.message })) }); + } + return resolved.servers[0]; + } + + const resolved = await resolveMcpServersForStore(scopedStore); + const server = resolved.servers.find((candidate) => candidate.name === request.name); + if (!server) { + throw badRequest("MCP server was not found or could not be resolved"); + } + return server; +} + +export const registerConfigMcpPiSettingsRoutes: ApiRouteRegistrar = (ctx) => { + const { router, getProjectContext, options, rethrowAsApiError } = ctx; + + router.get("/config", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const settings = await scopedStore.getSettingsFast(); + res.json({ + maxConcurrent: settings.maxConcurrent ?? options?.maxConcurrent ?? 2, + maxTriageConcurrent: settings.maxTriageConcurrent ?? settings.maxConcurrent ?? 2, + maxWorktrees: settings.maxWorktrees ?? 4, + rootDir: scopedStore.getRootDir(), + }); + } catch { + const { store: scopedStore } = await getProjectContext(req); + res.json({ maxConcurrent: options?.maxConcurrent ?? 2, maxTriageConcurrent: options?.maxConcurrent ?? 2, maxWorktrees: 4, rootDir: scopedStore.getRootDir() }); + } + }); + + router.get("/mcp/discovered", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const scope = parseMcpDiscoveryScope(req.query.scope); + const [discovered, settingsByScope] = await Promise.all([ + discoverMcpServers({ scope, projectRootDir: scopedStore.getRootDir() }), + scopedStore.getSettingsByScopeFast(), + ]); + const configured = new Set((scope === "global" ? settingsByScope.global.mcpServers?.servers : settingsByScope.project.mcpServers?.servers)?.map((server) => server.name) ?? []); + /* + * FNXC:McpConfig 2026-06-26-10:31: + * The discovery API is read-only: it reports inert third-party MCP definitions for explicit user opt-in and strips plaintextValue before crossing the wire. The dashboard can create Fusion-managed secret references from these descriptors, but API clients never receive raw env/header/token material. + */ + res.json({ + sources: discovered.sources, + servers: discovered.servers.map((server) => ({ + source: server.source, + definition: server.definition, + alreadyConfigured: configured.has(server.definition.name), + hasPlaintextSecrets: server.secretsToCreate.length > 0, + secretDescriptors: server.secretsToCreate.map(stripMcpSecretDescriptor), + })), + errors: discovered.errors, + }); + } catch (error) { + rethrowAsApiError(error, "Failed to discover MCP servers"); + } + }); + + router.post("/mcp/validate", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const request = parseMcpValidationBody(req.body); + const server = await resolveMcpServerForValidation(scopedStore, request); + // FNXC:McpConfig 2026-06-25-23:38: The validation API materializes MCP secrets only for the bounded probe and returns only status metadata, never resolved env/header values. + const result = await validateMcpServer(server, { + timeoutMs: request.timeoutMs, + cwd: scopedStore.getRootDir(), + }); + res.json(result); + } catch (error) { + rethrowAsApiError(error, "Failed to validate MCP server"); + } + }); + + router.get("/pi-settings", async (_req, res) => { + try { + const { SettingsManager, getAgentDir } = await import("@earendil-works/pi-coding-agent"); + const agentDir = getAgentDir(); + const settingsManager = SettingsManager.create(process.cwd(), agentDir); + const packages = settingsManager.getPackages(); + const extensions = settingsManager.getExtensionPaths(); + const skills = settingsManager.getSkillPaths(); + const prompts = settingsManager.getPromptTemplatePaths(); + const themes = settingsManager.getThemePaths(); + res.json({ packages, extensions, skills, prompts, themes }); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err); + } + }); + + router.put("/pi-settings", async (req, res) => { + try { + const { packages, extensions, skills, prompts, themes } = req.body as { + packages?: unknown; + extensions?: unknown; + skills?: unknown; + prompts?: unknown; + themes?: unknown; + }; + + if (packages === undefined && extensions === undefined && skills === undefined && prompts === undefined && themes === undefined) { + throw badRequest("At least one setting field must be provided (packages, extensions, skills, prompts, or themes)"); + } + + const { SettingsManager, getAgentDir } = await import("@earendil-works/pi-coding-agent"); + const agentDir = getAgentDir(); + const settingsManager = SettingsManager.create(process.cwd(), agentDir); + + if (packages !== undefined) { + if (!Array.isArray(packages)) throw badRequest("packages must be an array"); + settingsManager.setPackages(packages as string[]); + } + if (extensions !== undefined) { + if (!Array.isArray(extensions)) throw badRequest("extensions must be an array of strings"); + settingsManager.setExtensionPaths(extensions as string[]); + } + if (skills !== undefined) { + if (!Array.isArray(skills)) throw badRequest("skills must be an array of strings"); + settingsManager.setSkillPaths(skills as string[]); + } + if (prompts !== undefined) { + if (!Array.isArray(prompts)) throw badRequest("prompts must be an array of strings"); + settingsManager.setPromptTemplatePaths(prompts as string[]); + } + if (themes !== undefined) { + if (!Array.isArray(themes)) throw badRequest("themes must be an array of strings"); + settingsManager.setThemePaths(themes as string[]); + } + + await settingsManager.flush(); + res.json({ success: true }); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err); + } + }); + + router.post("/pi-settings/packages", async (req, res) => { + try { + const { source } = req.body as { source?: unknown }; + if (typeof source !== "string" || !source.trim()) throw badRequest("source must be a non-empty string"); + + const { SettingsManager, DefaultPackageManager, getAgentDir } = await import("@earendil-works/pi-coding-agent"); + const agentDir = getAgentDir(); + const cwd = process.cwd(); + const settingsManager = SettingsManager.create(process.cwd(), agentDir); + const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); + + await packageManager.install(source.trim()); + const added = packageManager.addSourceToSettings(source.trim()); + if (!added) { + res.json({ success: true }); + return; + } + + await settingsManager.flush(); + res.json({ success: true }); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err); + } + }); + + router.post("/pi-settings/reinstall-fusion", async (_req, res) => { + try { + const source = "npm:@runfusion/fusion"; + const { SettingsManager, DefaultPackageManager, getAgentDir } = await import("@earendil-works/pi-coding-agent"); + const agentDir = getAgentDir(); + const cwd = process.cwd(); + const settingsManager = SettingsManager.create(process.cwd(), agentDir); + const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); + + await packageManager.install(source); + const added = packageManager.addSourceToSettings(source); + if (added) await settingsManager.flush(); + + res.json({ success: true, source }); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err); + } + }); +}; diff --git a/scripts/lib/routes-modular-baseline.json b/scripts/lib/routes-modular-baseline.json index ac9c6ff3b3..14c061ba21 100644 --- a/scripts/lib/routes-modular-baseline.json +++ b/scripts/lib/routes-modular-baseline.json @@ -1,3 +1,3 @@ { - "inlineRouteRegistrations": 23 + "inlineRouteRegistrations": 16 }