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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-20 09:35:55 -07:00
parent c279b8cefc
commit 860533eff2
7 changed files with 385 additions and 344 deletions

View File

@@ -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");

View File

@@ -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

View File

@@ -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`
<!-- mount-sequence:end -->
## Ordering rules

View File

@@ -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)" });
});
});

View File

@@ -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",

View File

@@ -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);
}
});
};

View File

@@ -1,3 +1,3 @@
{
"inlineRouteRegistrations": 23
"inlineRouteRegistrations": 16
}