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:
@@ -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");
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)" });
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"inlineRouteRegistrations": 23
|
||||
"inlineRouteRegistrations": 16
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user