diff --git a/.claude/commands/bugfix.md b/.claude/commands/bugfix.md index e82d14f4..3b920715 100644 --- a/.claude/commands/bugfix.md +++ b/.claude/commands/bugfix.md @@ -59,7 +59,8 @@ Write at least one Playwright e2e test that exercises the fixed behaviour throug ### Step 6 — On completion -- Move bug-fix doc: `to-be-implemented/.md` → `implemented/v[version]/.md` +- Move bug-fix doc: `to-be-implemented/.md` → `implemented/alpha-/v[version]/.md` + (`alpha-` is the current release line — `alpha-2` for `2.x.x`; see `docs/guides/versioning.md`) - Write an implementation summary: root cause, fix applied, regression test added, e2e test added - Apply a PATCH version bump - Update `VERSION` and root `package.json` `version` diff --git a/.claude/commands/build.md b/.claude/commands/build.md index 903d9a68..610a2854 100644 --- a/.claude/commands/build.md +++ b/.claude/commands/build.md @@ -47,8 +47,9 @@ Once all sub-components pass validation, write at least one Playwright e2e test ### Step 4 — On completion -- Move phase doc: `to-be-implemented/.md` → `implemented/v[version]/.md` -- Write an implementation summary in `implemented/v[version]/` covering: +- Move phase doc: `to-be-implemented/.md` → `implemented/alpha-/v[version]/.md` + where `alpha-` is the current release line — `alpha-2` for `2.x.x`, `alpha-1` for `1.x.x` (see `docs/guides/versioning.md`) +- Write an implementation summary in `implemented/alpha-/v[version]/` covering: what was built, files created/modified, migrations run, known limitations, e2e tests added - Update `VERSION` file and root `package.json` `version` (they must match) - Run `./validate.sh` one final time — fix all failures before declaring done diff --git a/.claude/commands/enhance.md b/.claude/commands/enhance.md index e8bc85e1..c43087bf 100644 --- a/.claude/commands/enhance.md +++ b/.claude/commands/enhance.md @@ -37,7 +37,7 @@ Ask all of these via `AskUserQuestion` before proceeding: - Cover the primary user-facing behaviour introduced or modified by this enhancement - The test must pass against the updated code before moving on 5. On completion: - - Move phase doc to `implemented/v[version]/` + - Move phase doc to `implemented/alpha-/v[version]/` (current release line — `alpha-2` for `2.x.x`; see `docs/guides/versioning.md`) - Write implementation summary (include which e2e test covers the change) - Apply the version bump - Run `./validate.sh` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5919c699..66f722db 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,8 +35,9 @@ the code, validate, document, version, ship.** A few things to know going in: implementation, in-memory fakes for ports (never mock what you own), and run `./validate.sh` after every sub-component — not just at the end. - Every code-writing skill finishes by moving its phase doc into - `docs/development/implemented/v[version]/`, writing an implementation - summary, and bumping `VERSION` / root `package.json` (they must match). + `docs/development/implemented/alpha-/v[version]/`, writing an + implementation summary, and bumping `VERSION` / root `package.json` (they + must match). - If you're not using a skill — a tiny fix, a doc typo — that's fine, but still follow the same discipline by hand: test first, `./validate.sh` before you're done, version bump if the change touches shipped behaviour. diff --git a/VERSION b/VERSION index 227cea21..ccbccc3d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.0 +2.2.0 diff --git a/apps/web/e2e/enhance-approval-context.spec.ts b/apps/web/e2e/enhance-approval-context.spec.ts index 37d6c8ee..d26a54f4 100644 --- a/apps/web/e2e/enhance-approval-context.spec.ts +++ b/apps/web/e2e/enhance-approval-context.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from "@playwright/test"; // E2E for the approval-screen context & decision UX enhancement. -// (docs/development/implemented/v1.47.5/approval-context-ux.phase.md) +// (docs/development/implemented/alpha-1/v1.47.5/approval-context-ux.phase.md) // // Driven by the /e2e (Playwright MCP) skill against a running stack — excluded // from the vitest unit run. Assumes the seeded pending approval from diff --git a/apps/web/e2e/fix-confidence-threshold-scale.spec.ts b/apps/web/e2e/fix-confidence-threshold-scale.spec.ts index f2b4f20c..b7023027 100644 --- a/apps/web/e2e/fix-confidence-threshold-scale.spec.ts +++ b/apps/web/e2e/fix-confidence-threshold-scale.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test"; // E2E for bug #1: a node whose advanceConfidenceThreshold was authored as a // fraction (0.7) instead of a percentage (70) must NOT auto-advance on its own // opening message before the user has answered anything. -// (docs/development/implemented/v1.49.0/fix-flow-authored-data-trust.md) +// (docs/development/implemented/alpha-1/v1.49.0/fix-flow-authored-data-trust.md) // // Driven by the /e2e (Playwright MCP) skill against a running stack with a real // AI key. On the unfixed code, `5 >= 0.7` was always true, so every step diff --git a/apps/web/e2e/fix-document-generation-context-overflow.spec.ts b/apps/web/e2e/fix-document-generation-context-overflow.spec.ts index 2775cde1..21db4857 100644 --- a/apps/web/e2e/fix-document-generation-context-overflow.spec.ts +++ b/apps/web/e2e/fix-document-generation-context-overflow.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test"; // E2E for bug #3: producing a document for a flow whose reference documents are // very large must no longer overflow the model context window. The context-doc // section is budget-capped and generation runs in field batches. -// (docs/development/implemented/v1.49.0/fix-flow-authored-data-trust.md) +// (docs/development/implemented/alpha-1/v1.49.0/fix-flow-authored-data-trust.md) // // Driven by the /e2e (Playwright MCP) skill against a running stack with a real // AI key. On the unfixed code, document generation dumped the full extracted diff --git a/apps/web/e2e/fix-fork-advance-threshold.spec.ts b/apps/web/e2e/fix-fork-advance-threshold.spec.ts index 1acbff0e..68767ec6 100644 --- a/apps/web/e2e/fix-fork-advance-threshold.spec.ts +++ b/apps/web/e2e/fix-fork-advance-threshold.spec.ts @@ -5,7 +5,7 @@ import { expect, test } from "@playwright/test"; // threshold. The regression gated the branch-choice call on a hardcoded 90, so a // fork at threshold 70 reported "complete" at confidence 80 yet never advanced — // the session silently stalled on the fork, turn after turn, with no error. -// (docs/development/implemented/v1.54.0/codebase-bug-fixes.phase.md) +// (docs/development/implemented/alpha-1/v1.54.0/codebase-bug-fixes.phase.md) // // Driven by the /e2e (Playwright MCP) skill against a running stack in AI-mock // mode — excluded from the vitest unit run. The pure gating predicate is unit diff --git a/apps/web/e2e/fix-session-upload-not-reaching-ai.spec.ts b/apps/web/e2e/fix-session-upload-not-reaching-ai.spec.ts index 4c19120b..825f29b0 100644 --- a/apps/web/e2e/fix-session-upload-not-reaching-ai.spec.ts +++ b/apps/web/e2e/fix-session-upload-not-reaching-ai.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"; // E2E for the bug fix: a deliberately-attached session upload must reach the AI // even when the user's message is only loosely related to the document body. -// (docs/development/implemented/v1.47.1/fix-session-upload-not-reaching-ai.md) +// (docs/development/implemented/alpha-1/v1.47.1/fix-session-upload-not-reaching-ai.md) // // Driven by the /e2e (Playwright MCP) skill against a running stack — it is // excluded from the vitest unit run and requires real embeddings + an AI key, diff --git a/apps/web/e2e/fix-sticky-link-navigation.spec.ts b/apps/web/e2e/fix-sticky-link-navigation.spec.ts index dd10701d..ff59fbfc 100644 --- a/apps/web/e2e/fix-sticky-link-navigation.spec.ts +++ b/apps/web/e2e/fix-sticky-link-navigation.spec.ts @@ -4,7 +4,7 @@ import { expect, test } from "@playwright/test"; // instead of feeling sticky. Before the fix there was no loading.tsx boundary and // no navigation indicator, so the previous page stayed fully on screen with zero // feedback while the destination resolved its session + tRPC prefetch. -// (docs/development/implemented/v1.48.4/fix-sticky-link-navigation.md) +// (docs/development/implemented/alpha-1/v1.48.4/fix-sticky-link-navigation.md) // // Driven by the /e2e (Playwright MCP) skill against a running, signed-in stack. // Assumes a seeded user session lands on /chats. diff --git a/apps/web/e2e/phase-flow-skills.spec.ts b/apps/web/e2e/phase-flow-skills.spec.ts new file mode 100644 index 00000000..40dca9ec --- /dev/null +++ b/apps/web/e2e/phase-flow-skills.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from "@playwright/test"; + +// E2E for Flow Skills — uploading a SKILL.md to the library and attaching it to a +// conversational step (PRD: flow-skills-and-mcp, ADR-031). +// +// Driven by the /e2e (Playwright MCP) skill against a running stack — it is +// excluded from the vitest unit run. Assumes an authenticated admin storageState +// (the same global setup the other admin specs rely on). + +const SKILL_MD = `--- +name: E2E Contract Reviewer +description: Flags unusual contract clauses +--- + +# Contract review + +Read the contract and flag unusual indemnity clauses.`; + +test.describe("flow skills", () => { + test("an admin can upload a SKILL.md and see it in the library", async ({ page }) => { + await page.goto("/admin/skills"); + + await page.getByLabel("SKILL.md").fill(SKILL_MD); + await page.getByRole("button", { name: /upload skill/i }).click(); + + // The parsed name appears in the library table. + await expect(page.getByText("E2E Contract Reviewer")).toBeVisible(); + await expect(page.getByText("Flags unusual contract clauses")).toBeVisible(); + }); + + test("an invalid SKILL.md surfaces a validation error and stores nothing", async ({ page }) => { + await page.goto("/admin/skills"); + + // Frontmatter with no name and no heading cannot be parsed into a skill. + await page.getByLabel("SKILL.md").fill("---\ndescription: no name\n---\n"); + await page.getByRole("button", { name: /upload skill/i }).click(); + + await expect(page.getByText(/must declare a name/i)).toBeVisible(); + }); + + test("an uploaded skill can be archived from the library", async ({ page }) => { + await page.goto("/admin/skills"); + + const row = page.getByRole("row", { name: /E2E Contract Reviewer/i }).first(); + await row.getByRole("button", { name: /archive/i }).click(); + + await expect(row.getByRole("button", { name: /restore/i })).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/phase-mcp-flow-consumption.spec.ts b/apps/web/e2e/phase-mcp-flow-consumption.spec.ts new file mode 100644 index 00000000..2abcae2c --- /dev/null +++ b/apps/web/e2e/phase-mcp-flow-consumption.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from "@playwright/test"; + +// E2E for MCP flow consumption (Phase 2b, ADR-032): adding a deterministic MCP +// Tool step to a flow, and allowing MCP tools on a conversational step. +// +// Driven by the /e2e (Playwright MCP) skill against a running stack — excluded +// from the vitest unit run. Assumes an authenticated admin storageState and at +// least one flow plus one registered MCP server. Selectors mirror the canvas +// node-type picker and the step config modal. + +test.describe("mcp flow consumption", () => { + test("an author can add an MCP Tool step from the node picker", async ({ page }) => { + await page.goto("/admin/flows"); + await page.getByRole("link", { name: /flow/i }).first().click(); + + await page.getByRole("button", { name: /add step/i }).first().click(); + await page.getByText("MCP Tool", { exact: true }).click(); + + // The MCP step config exposes a server selector. + await expect(page.getByLabel("MCP server")).toBeVisible(); + }); + + test("a conversational step exposes an allowed MCP tools picker", async ({ page }) => { + await page.goto("/admin/flows"); + await page.getByRole("link", { name: /flow/i }).first().click(); + + // Open the first conversational step's config (double-click its canvas node). + await page.locator(".react-flow__node").first().dblclick(); + + await expect(page.getByText("MCP tools", { exact: true })).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/phase-mcp-integration.spec.ts b/apps/web/e2e/phase-mcp-integration.spec.ts new file mode 100644 index 00000000..b3f32b1a --- /dev/null +++ b/apps/web/e2e/phase-mcp-integration.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from "@playwright/test"; + +// E2E for MCP server registration (Phase 2a of the Flow Skills & MCP PRD, +// ADR-032). +// +// Driven by the /e2e (Playwright MCP) skill against a running stack — it is +// excluded from the vitest unit run. Assumes an authenticated admin storageState. +// Connection Test is not asserted here because it requires a live MCP server. + +test.describe("mcp servers", () => { + test("an admin can register a remote MCP server and see it listed", async ({ page }) => { + await page.goto("/admin/mcp-servers"); + + await page.getByLabel("Label").fill("E2E GitHub"); + await page.getByLabel("SSE URL").fill("https://mcp.example.com/sse"); + await page.getByLabel(/credential ref/i).fill("MCP_E2E_TOKEN"); + await page.getByRole("button", { name: /register server/i }).click(); + + const row = page.getByRole("row", { name: /E2E GitHub/i }).first(); + await expect(row).toBeVisible(); + await expect(row.getByText("active")).toBeVisible(); + }); + + test("an invalid URL surfaces a validation error", async ({ page }) => { + await page.goto("/admin/mcp-servers"); + + await page.getByLabel("Label").fill("Bad Server"); + await page.getByLabel("SSE URL").fill("not-a-url"); + await page.getByRole("button", { name: /register server/i }).click(); + + await expect(page.getByText(/valid http\(s\) URL/i)).toBeVisible(); + }); + + test("a registered server can be disabled", async ({ page }) => { + await page.goto("/admin/mcp-servers"); + + const row = page.getByRole("row", { name: /E2E GitHub/i }).first(); + await row.getByRole("button", { name: /^disable$/i }).click(); + + await expect(row.getByRole("button", { name: /^enable$/i })).toBeVisible(); + }); +}); diff --git a/apps/web/src/app/(admin)/admin/flows/[id]/_content.tsx b/apps/web/src/app/(admin)/admin/flows/[id]/_content.tsx index d219827b..3dc55f57 100644 --- a/apps/web/src/app/(admin)/admin/flows/[id]/_content.tsx +++ b/apps/web/src/app/(admin)/admin/flows/[id]/_content.tsx @@ -30,6 +30,7 @@ import type { ConversationalNodeData } from "@/components/canvas/conversational- import { ConversationalNode } from "@/components/canvas/conversational-node"; import type { AutoNodeData } from "@/components/canvas/auto-node"; import { AutoNode } from "@/components/canvas/auto-node"; +import { McpNode, type McpNodeData } from "@/components/canvas/mcp-node"; import type { ScheduledNodeData } from "@/components/canvas/scheduled-node"; import { ScheduledNode } from "@/components/canvas/scheduled-node"; import type { ApprovalNodeData } from "@/components/canvas/approval-node"; @@ -55,6 +56,7 @@ const NODE_TYPES = { autoNode: AutoNode, scheduledNode: ScheduledNode, approvalNode: ApprovalNode, + mcpNode: McpNode, }; const DEBOUNCE_MS = 600; @@ -63,7 +65,7 @@ interface RawNode { id: string; name: string; colour: string | null; - type?: "conversational" | "auto" | "scheduled" | "approval"; + type?: "conversational" | "auto" | "scheduled" | "approval" | "mcp"; positionX: number; positionY: number; config: Record; @@ -110,6 +112,17 @@ const toRfNode = (node: RawNode, stepNumber: number | null): Node => { return { id: node.id, type: "approvalNode", position: { x: node.positionX, y: node.positionY }, data }; } + if (node.type === "mcp") { + const data: McpNodeData = { + name: node.name, + colour: node.colour, + toolName: (node.config.toolName as string | null) ?? null, + stepNumber, + config: node.config, + }; + return { id: node.id, type: "mcpNode", position: { x: node.positionX, y: node.positionY }, data }; + } + const data: ConversationalNodeData = { name: node.name, colour: node.colour, @@ -142,6 +155,7 @@ function CanvasInner({ flowId }: { flowId: string }) { const [rfNodes, setRfNodes] = useState([]); const [rfEdges, setRfEdges] = useState([]); const [contextDocs, setContextDocs] = useState([]); + const [contextMcpServerIds, setContextMcpServerIds] = useState([]); const [flowName, setFlowName] = useState(""); const [flowStatus, setFlowStatus] = useState<"draft" | "published">("draft"); const [flowVisibility, setFlowVisibility] = useState<"private" | "global">("private"); @@ -171,6 +185,8 @@ function CanvasInner({ flowId }: { flowId: string }) { const autoNodeEnabled = trpc.featureFlag.isEnabledForMe.useQuery({ key: "auto_node" }).data ?? false; const scheduledNodeEnabled = trpc.featureFlag.isEnabledForMe.useQuery({ key: "scheduled_node" }).data ?? false; + const mcpEnabled = trpc.featureFlag.isEnabledForMe.useQuery({ key: "mcp" }).data ?? false; + const skillsEnabled = trpc.featureFlag.isEnabledForMe.useQuery({ key: "skills" }).data ?? false; const [configOpen, setConfigOpen] = useState(false); const [typePickerOpen, setTypePickerOpen] = useState(false); @@ -217,6 +233,7 @@ function CanvasInner({ flowId }: { flowId: string }) { setRfNodes(data.nodes.map((n) => toRfNode(n, null))); setRfEdges(data.edges.map(toRfEdge)); setContextDocs(data.flow.contextDocs); + setContextMcpServerIds(data.flow.contextMcpServerIds ?? []); setFlowName(data.flow.name); setFlowStatus(data.flow.status); setFlowVisibility(data.flow.visibility.kind); @@ -373,6 +390,15 @@ function CanvasInner({ flowId }: { flowId: string }) { notifyOnComplete: values.notifyOnComplete, }; } + if (values.type === "mcp") { + return { + instruction: values.instruction, + serverId: values.mcpServerId, + allowedToolNames: values.mcpAllowedToolNames, + responseFields: values.responseFields, + requireConfirmation: values.requireConfirmation, + }; + } const hasTemplate = values.outputType === "generate_document" && !!values.documentTemplatePath; return { aiInstruction: values.aiInstruction, @@ -386,6 +412,8 @@ function CanvasInner({ flowId }: { flowId: string }) { documentTemplateStructuredContent: hasTemplate ? (existingNodeConfig.documentTemplateStructuredContent ?? null) : null, allowManualEdit: values.allowManualEdit, requireConfirmation: values.neverDone ? false : values.requireConfirmation, + skillRefs: values.skillRefs, + allowedMcpToolRefs: values.allowedMcpToolRefs, notifyOnComplete: values.notifyOnComplete, }; }; @@ -619,10 +647,19 @@ function CanvasInner({ flowId }: { flowId: string }) { documentTemplateContent: (editingConfig.documentTemplateContent as string | null) ?? null, allowManualEdit: (editingConfig.allowManualEdit as boolean | undefined) ?? true, requireConfirmation: Boolean(editingConfig.requireConfirmation), + skillRefs: (editingConfig.skillRefs as string[] | undefined) ?? [], + allowedMcpToolRefs: + (editingConfig.allowedMcpToolRefs as NodeConfigValues["allowedMcpToolRefs"] | undefined) ?? + [], instruction: (editingConfig.instruction as string | null) ?? "", executor: (editingConfig.executor as "n8n" | "mock" | undefined) ?? "n8n", workflowId: (editingConfig.workflowId as string | null) ?? null, webhookUrl: (editingConfig.webhookUrl as string | null) ?? "", + mcpServerId: (editingConfig.serverId as string | null) ?? "", + mcpToolName: (editingConfig.toolName as string | null) ?? "", + mcpAllowedToolNames: + (editingConfig.allowedToolNames as string[] | undefined) ?? + (editingConfig.toolName ? [editingConfig.toolName as string] : []), requestFields: readFields(editingConfig.requestFields), requestFieldValues: (editingConfig.requestFieldValues as Record | undefined) ?? {}, @@ -885,12 +922,16 @@ function CanvasInner({ flowId }: { flowId: string }) { flowId={flowId} docs={contextDocs} onDocsChange={setContextDocs} + mcpEnabled={mcpEnabled} + contextMcpServerIds={contextMcpServerIds} + onContextMcpServerIdsChange={setContextMcpServerIds} /> { setTypePickerOpen(false); @@ -906,6 +947,8 @@ function CanvasInner({ flowId }: { flowId: string }) { onDelete={editingNodeId ? handleNodeDelete : undefined} onClose={handleConfigClose} isSaving={isSavingConfig} + skillsEnabled={skillsEnabled} + mcpEnabled={mcpEnabled} priorStepFields={priorStepFields} onUploadTemplate={editingNodeId ? handleUploadTemplate : undefined} /> diff --git a/apps/web/src/app/(admin)/admin/mcp-servers/_content.tsx b/apps/web/src/app/(admin)/admin/mcp-servers/_content.tsx new file mode 100644 index 00000000..d49c0ede --- /dev/null +++ b/apps/web/src/app/(admin)/admin/mcp-servers/_content.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { trpc } from "@/trpc/client"; + +export function AdminMcpServersContent() { + const utils = trpc.useUtils(); + const featureQuery = trpc.featureFlag.isEnabledForMe.useQuery({ key: "mcp" }); + const serversQuery = trpc.mcpServer.list.useQuery({ includeDisabled: true }); + + const [label, setLabel] = useState(""); + const [url, setUrl] = useState(""); + const [kind, setKind] = useState<"context" | "actions">("context"); + const [credentialRef, setCredentialRef] = useState(""); + const [error, setError] = useState(null); + const [testResult, setTestResult] = useState>({}); + + const invalidate = () => void utils.mcpServer.list.invalidate(); + + const register = trpc.mcpServer.register.useMutation({ + onSuccess: () => { + setLabel(""); + setUrl(""); + setKind("context"); + setCredentialRef(""); + setError(null); + invalidate(); + }, + onError: (cause) => setError(cause.message), + }); + const disable = trpc.mcpServer.disable.useMutation({ onSuccess: invalidate }); + const enable = trpc.mcpServer.enable.useMutation({ onSuccess: invalidate }); + const test = trpc.mcpServer.test.useMutation({ + onSuccess: (data, variables) => + setTestResult((prev) => ({ + ...prev, + [variables.id]: `OK — ${data.toolCount} tool(s)`, + })), + onError: (cause, variables) => + setTestResult((prev) => ({ ...prev, [variables.id]: `Failed — ${cause.message}` })), + }); + + const submit = () => { + if (!label.trim() || !url.trim()) return; + register.mutate({ + label, + url, + kind, + credentialRef: credentialRef.trim() ? credentialRef.trim() : null, + }); + }; + + if (featureQuery.data === false) { + return ( +
+
+ + + MCP servers unavailable + + +

+ The MCP feature is turned off for your account. An administrator can + enable the mcp feature flag to manage + servers here. +

+
+
+
+
+ ); + } + + return ( +
+
+ + + Register an MCP server + + +

+ Register a remote (SSE) Model Context Protocol server. Flow authors + can then use its tools without seeing credentials. The credential + reference names an environment variable holding the bearer token — + the secret itself is never stored here. +

+
+
+ + setLabel(event.target.value)} + placeholder="GitHub" + /> +
+
+ + setUrl(event.target.value)} + placeholder="https://mcp.example.com/sse" + /> +
+
+ + +
+
+ + setCredentialRef(event.target.value)} + placeholder="MCP_GITHUB_TOKEN" + /> +
+
+ {error ?

{error}

: null} + +
+
+ + + + Registered servers + + + {serversQuery.isLoading ? ( +

Loading…

+ ) : serversQuery.data && serversQuery.data.length > 0 ? ( + + + + Label + Type + URL + Credential + Status + Actions + + + + {serversQuery.data.map((server) => ( + + {server.label} + + + {server.kind === "actions" ? "Actions" : "Context"} + + + {server.url} + + {server.credentialRef ?? "—"} + + + + {server.status} + + {testResult[server.id] ? ( + + {testResult[server.id]} + + ) : null} + + + + {server.status === "active" ? ( + + ) : ( + + )} + + + ))} + +
+ ) : ( +

+ No MCP servers registered yet. +

+ )} +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(admin)/admin/mcp-servers/page.tsx b/apps/web/src/app/(admin)/admin/mcp-servers/page.tsx new file mode 100644 index 00000000..2a400815 --- /dev/null +++ b/apps/web/src/app/(admin)/admin/mcp-servers/page.tsx @@ -0,0 +1,12 @@ +import { createServerHelpers } from "@/trpc/server"; +import { AdminMcpServersContent } from "./_content"; + +export default async function AdminMcpServersPage() { + const { trpc, HydrateClient } = await createServerHelpers(); + void trpc.mcpServer.list.prefetch({ includeDisabled: true }); + return ( + + + + ); +} diff --git a/apps/web/src/app/(admin)/admin/n8n/page.tsx b/apps/web/src/app/(admin)/admin/n8n/page.tsx new file mode 100644 index 00000000..776ecbf3 --- /dev/null +++ b/apps/web/src/app/(admin)/admin/n8n/page.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogBody, + DialogCloseButton, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { trpc } from "@/trpc/client"; +import { ConnectivityTest, useConnectivity } from "@/components/admin/connectivity"; + +function N8nIntegrationCard() { + const utils = trpc.useUtils(); + const connectivity = useConnectivity(); + const configQuery = trpc.settings.getN8nConfig.useQuery(); + const saveMutation = trpc.settings.setN8nConfig.useMutation({ + onSuccess: async () => { + toast.success("n8n settings saved"); + await utils.settings.getN8nConfig.invalidate(); + setOpen(false); + }, + onError: (error) => toast.error(error.message ?? "Failed to save n8n settings"), + }); + + const [open, setOpen] = useState(false); + const [baseUrl, setBaseUrl] = useState(""); + const [apiKey, setApiKey] = useState(""); + + const config = configQuery.data; + + useEffect(() => { + if (!open || !config) return; + setBaseUrl(config.baseUrl); + setApiKey(""); + }, [open, config]); + + const handleSave = () => { + if (baseUrl.trim() === "") { + toast.error("Base URL is required"); + return; + } + saveMutation.mutate({ baseUrl: baseUrl.trim(), apiKey: apiKey.length > 0 ? apiKey : null }); + }; + + return ( + + + n8n Integration + + + +

+ Connect an n8n instance so auto nodes can pick from your workflows instead of + hand-typing a webhook URL. +

+ {!config ? ( +

Loading…

+ ) : ( + <> +
+ Base URL + {config.baseUrl || "—"} +
+
+ API key + {config.apiKey === "set" ? "•••• set" : "unset"} +
+ + )} + {config && Boolean(config.baseUrl) && config.apiKey === "set" && ( + + )} +
+ + !o && setOpen(false)}> + + + Edit n8n settings + + + +

+ Saved values override .env. Leave the API key blank to keep the stored one. +

+
+ + setBaseUrl(e.target.value)} + placeholder="https://n8n.example.com" + /> +
+
+ + setApiKey(e.target.value)} + placeholder={config?.apiKey === "set" ? "•••••• (stored)" : "Not set"} + /> +
+
+ + + + +
+
+
+ ); +} + +export default function AdminN8nPage() { + const featureQuery = trpc.featureFlag.isEnabledForMe.useQuery({ key: "auto_node" }); + + return ( +
+
+
+
+

n8n

+

+ Connect an n8n instance so automated (n8n) flow steps can call your workflows. +

+
+ {featureQuery.data === false ? ( + + + n8n unavailable + + +

+ Automated (n8n) nodes are turned off for your account. An administrator can enable + the auto_node feature flag to manage the n8n + connection here. +

+
+
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/apps/web/src/app/(admin)/admin/settings/page.tsx b/apps/web/src/app/(admin)/admin/settings/page.tsx index f258847f..2e76968b 100644 --- a/apps/web/src/app/(admin)/admin/settings/page.tsx +++ b/apps/web/src/app/(admin)/admin/settings/page.tsx @@ -1,8 +1,8 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; -import type { ConnectivityResult, ConnectivityTarget } from "@rbrasier/domain"; +import type { ConnectivityTarget } from "@rbrasier/domain"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { @@ -18,6 +18,11 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { trpc } from "@/trpc/client"; +import { + ConnectivityTest, + useConnectivity, + type ConnectivityController, +} from "@/components/admin/connectivity"; type Provider = "anthropic" | "openai" | "mistral" | "bedrock"; @@ -28,139 +33,16 @@ const PROVIDER_LABEL: Record = { bedrock: "Amazon Bedrock", }; -// Targets exercised by the header "Test all" button, in card order. +// Targets exercised by the header "Test all" button, in card order. n8n moved to +// its own Flow Settings page, so it is no longer probed from here. const ALL_CONNECTIVITY_TARGETS: ConnectivityTarget[] = [ "ai", - "n8n", "embeddings", "storage", "email", "entra", ]; -type BadgeState = - | { status: "idle" } - | { status: "testing" } - | { status: "ok"; latencyMs?: number; message?: string } - | { status: "skipped"; message?: string } - | { status: "failed"; message?: string }; - -interface ConnectivityController { - states: Partial>; - runTest: (target: ConnectivityTarget) => Promise; - runAll: (targets: ConnectivityTarget[]) => Promise; - isBusy: boolean; -} - -const toBadge = (result: ConnectivityResult): BadgeState => { - if (result.skipped) return { status: "skipped", message: result.message }; - if (result.ok) return { status: "ok", latencyMs: result.latencyMs, message: result.message }; - return { status: "failed", message: result.message }; -}; - -function useConnectivity(): ConnectivityController { - const [states, setStates] = useState>>({}); - const mutation = trpc.settings.testConnectivity.useMutation(); - - const runTest = useCallback( - async (target: ConnectivityTarget) => { - setStates((prev) => ({ ...prev, [target]: { status: "testing" } })); - try { - const result = await mutation.mutateAsync({ target }); - setStates((prev) => ({ ...prev, [target]: toBadge(result) })); - } catch (error) { - const message = error instanceof Error ? error.message : "Probe failed"; - setStates((prev) => ({ ...prev, [target]: { status: "failed", message } })); - } - }, - [mutation], - ); - - // Fan out to per-card probes in parallel so each badge resolves independently. - const runAll = useCallback( - async (targets: ConnectivityTarget[]) => { - await Promise.all(targets.map((target) => runTest(target))); - }, - [runTest], - ); - - const isBusy = Object.values(states).some((state) => state?.status === "testing"); - - return { states, runTest, runAll, isBusy }; -} - -function ConnectivityBadge({ target, state }: { target: ConnectivityTarget; state?: BadgeState }) { - if (!state || state.status === "idle") return null; - - const testId = `connectivity-badge-${target}`; - if (state.status === "testing") { - return ( - - Testing… - - ); - } - if (state.status === "ok") { - return ( - - Reachable{typeof state.latencyMs === "number" ? ` · ${state.latencyMs} ms` : ""} - - ); - } - if (state.status === "skipped") { - return ( - - {state.message ?? "Not configured"} - - ); - } - return ( - - Failed{state.message ? `: ${state.message}` : ""} - - ); -} - -function ConnectivityTest({ - target, - controller, -}: { - target: ConnectivityTarget; - controller: ConnectivityController; -}) { - const state = controller.states[target]; - return ( -
- - -
- ); -} - function OrganisationNameCard() { const orgNameQuery = trpc.settings.get.useQuery({ key: "organisation_name" }); const setMutation = trpc.settings.set.useMutation({ @@ -756,114 +638,6 @@ function AiProviderCard({ connectivity }: { connectivity: ConnectivityController ); } -function N8nIntegrationCard({ connectivity }: { connectivity: ConnectivityController }) { - const utils = trpc.useUtils(); - const configQuery = trpc.settings.getN8nConfig.useQuery(); - const saveMutation = trpc.settings.setN8nConfig.useMutation({ - onSuccess: async () => { - toast.success("n8n settings saved"); - await utils.settings.getN8nConfig.invalidate(); - setOpen(false); - }, - onError: (error) => toast.error(error.message ?? "Failed to save n8n settings"), - }); - - const [open, setOpen] = useState(false); - const [baseUrl, setBaseUrl] = useState(""); - const [apiKey, setApiKey] = useState(""); - - const config = configQuery.data; - - useEffect(() => { - if (!open || !config) return; - setBaseUrl(config.baseUrl); - setApiKey(""); - }, [open, config]); - - const handleSave = () => { - if (baseUrl.trim() === "") { - toast.error("Base URL is required"); - return; - } - saveMutation.mutate({ baseUrl: baseUrl.trim(), apiKey: apiKey.length > 0 ? apiKey : null }); - }; - - return ( - - - n8n Integration - - - -

- Connect an n8n instance so auto nodes can pick from your workflows instead of - hand-typing a webhook URL. -

- {!config ? ( -

Loading…

- ) : ( - <> -
- Base URL - {config.baseUrl || "—"} -
-
- API key - {config.apiKey === "set" ? "•••• set" : "unset"} -
- - )} - {config && Boolean(config.baseUrl) && config.apiKey === "set" && ( - - )} -
- - !o && setOpen(false)}> - - - Edit n8n settings - - - -

- Saved values override .env. Leave the API key blank to keep the stored one. -

-
- - setBaseUrl(e.target.value)} - placeholder="https://n8n.example.com" - /> -
-
- - setApiKey(e.target.value)} - placeholder={config?.apiKey === "set" ? "•••••• (stored)" : "Not set"} - /> -
-
- - - - -
-
-
- ); -} - function StorageCard({ connectivity }: { connectivity: ConnectivityController }) { const utils = trpc.useUtils(); const storageQuery = trpc.settings.getStorageConfig.useQuery(); @@ -2167,7 +1941,6 @@ export default function AppSettingsPage() { - diff --git a/apps/web/src/app/(admin)/admin/skills/_content.tsx b/apps/web/src/app/(admin)/admin/skills/_content.tsx new file mode 100644 index 00000000..05d15256 --- /dev/null +++ b/apps/web/src/app/(admin)/admin/skills/_content.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { trpc } from "@/trpc/client"; + +const EXAMPLE_SKILL = `--- +name: Contract Reviewer +description: Reviews procurement contracts for risk +--- + +# Contract review + +Read the contract carefully and flag unusual indemnity, liability, or +termination clauses. Ask the user about anything ambiguous.`; + +export function AdminSkillsContent() { + const utils = trpc.useUtils(); + const featureQuery = trpc.featureFlag.isEnabledForMe.useQuery({ key: "skills" }); + const skillsQuery = trpc.skill.list.useQuery({ includeArchived: true }); + + const [raw, setRaw] = useState(""); + const [error, setError] = useState(null); + + const create = trpc.skill.create.useMutation({ + onSuccess: () => { + setRaw(""); + setError(null); + void utils.skill.list.invalidate(); + }, + onError: (cause) => setError(cause.message), + }); + const archive = trpc.skill.archive.useMutation({ + onSuccess: () => void utils.skill.list.invalidate(), + }); + const restore = trpc.skill.restore.useMutation({ + onSuccess: () => void utils.skill.list.invalidate(), + }); + + const submit = () => { + if (!raw.trim()) return; + create.mutate({ raw }); + }; + + if (featureQuery.data === false) { + return ( +
+
+ + + Skills unavailable + + +

+ The Skills feature is turned off for your account. An administrator can + enable the skills feature flag to + manage skills here. +

+
+
+
+
+ ); + } + + return ( +
+
+ + + Upload a skill + + +

+ Paste a SKILL.md (YAML frontmatter + + markdown body). Uploaded skills can be attached to any conversational + step to steer the AI's behaviour. +

+
+ +