From 611468b97232d1332b528bfda0af7f6034101446 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:58:33 +0100 Subject: [PATCH] Add SaaS MCP usage tab (#6590) --- .../public/locales/en-GB/translation.toml | 54 ++-- .../config/configSections/McpSection.tsx | 262 ++++++++++++++++++ .../shared/config/saasConfigNavSections.tsx | 40 +++ .../saas/components/shared/config/types.ts | 9 +- .../src/saas/services/apiClient.test.ts | 9 + 5 files changed, 348 insertions(+), 26 deletions(-) create mode 100644 frontend/editor/src/saas/components/shared/config/configSections/McpSection.tsx diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index e907cba19..b3f5109ce 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -3049,6 +3049,37 @@ impact = "Any applications or services currently using these keys will stop work title = "Refresh API Keys" warning = "⚠️ Warning: This action will generate new API keys and make your previous keys invalid." +[config.mcp] +description = "Model Context Protocol (MCP) lets AI assistants like Claude use your Stirling PDF tools directly. Connect a client once and your assistant can convert, edit, secure and process documents on your behalf." +navLabel = "MCP Server" +tip = "Every action your assistant runs is performed as your account and counts towards your usage, just like using Stirling PDF directly." +title = "MCP Server" +viewApiKeys = "View API keys" + +[config.mcp.copy] +configLabel = "Config" +copied = "Copied" +copy = "Copy" +endpointLabel = "Endpoint URL" +tooltip = "Copy {{label}}" +tooltipCopied = "{{label}} copied" + +[config.mcp.endpoint] +label = "Your MCP endpoint" + +[config.mcp.setup] +addTo = "Add to" +hint = "Pick your client, paste the snippet into the file shown, then restart it. You'll sign in with your Stirling account on first use - no keys to copy." +title = "Connect your AI assistant" + +[config.mcp.tools] +ai = "AI" +convert = "Convert" +misc = "Misc" +pages = "Pages" +security = "Security" +title = "What your assistant can do" + [config.overview] description = "Current application settings and configuration details." error = "Error" @@ -3210,7 +3241,6 @@ creditsRemaining = "{{current}} of {{total}} remaining" creditsThisMonth = "Monthly credits" current = "Current Plan" customPricing = "Custom" -customWatchedFolders = "Custom Watched Folders" dedicatedSupportSlas = "Dedicated support & SLAs" enterpriseSubscription = "Enterprise" everythingInCredits = "Everything in Credits, plus:" @@ -3518,7 +3548,6 @@ addFiles = "Add Files" [fileManager] active = "Active" -addToWatchedFolder = "Add to Watched Folder" addToUpload = "Add to Upload" changesNotUploaded = "Changes not uploaded" clearAll = "Clear All" @@ -4563,10 +4592,8 @@ sendResetLink = "Send reset link" sessionExpired = "Your session has expired. Please sign in again." createAccount = "Create an account" signin = "Sign in" -signInAnonymously = "Sign Up as a Guest" signingIn = "Signing in..." signInWith = "Sign in with" -subtitle = "Sign back in to Stirling PDF" title = "Sign in" unexpectedError = "Unexpected error: {{message}}" updatePassword = "Update password" @@ -5782,7 +5809,6 @@ label = "SMTP Username" [quickAccess] access = "Access" -watchedFolders = "Watched Folders" accessAddPerson = "Add another person" accessBack = "Back" accessCopyLink = "Copy link" @@ -6873,6 +6899,7 @@ createFailed = "Failed to create signing request" [signup] accountCreatedSuccessfully = "Account created successfully! You can now sign in." +alreadyHaveAccount = "I already have an account" checkEmailConfirmation = "Check your email for a confirmation link to complete your registration." confirmPassword = "Confirm password" confirmPasswordPlaceholder = "Confirm password" @@ -6898,7 +6925,6 @@ skip = "Skip" subtitle = "Join Stirling PDF to get started" title = "Create an account" unexpectedError = "Unexpected error: {{message}}" -useEmailInstead = "Use Email Instead" [sizes] large = "Large" @@ -8172,20 +8198,14 @@ newFolder = "New folder" noFolders = "No watched folders yet" sidebarTitle = "Watched Folders" title = "Watched Folders" -sidebarFiles = "My Files" [watchedFolders.modal] automation = "Automation" -automationSaved = "Automation saved" createFolder = "Create Folder" -stepsSaved = "Steps saved — click Create Folder to finish" automationRequired = "Add at least one configured step before saving." color = "Accent color" createTitle = "New Watched Folder" -description = "Description" -descriptionPlaceholder = "What does this folder do?" editTitle = "Edit Watched Folder" -icon = "Icon" name = "Folder name" namePlaceholder = "My Watched Folder" nameRequired = "Folder name is required" @@ -8193,17 +8213,13 @@ nameTooLong = "Folder name must be 50 characters or less" saveChanges = "Save Changes" saveFailed = "Failed to save folder. Please try again." automationNameFallback = "Watched Folder automation" -retryLabel = "Auto-retry" maxRetries = "Max retries" -maxRetriesDesc = "0 to disable" retryDelay = "Delay (minutes)" -outputLabel = "Output" outputModeVersion = "Replace original?" outputModeVersionDesc = "Output becomes a new version of the input file rather than a separate file" outputModeNewDesc = "Output is saved as a new separate file" outputName = "Output filename prefix" outputNameSuffix = "Suffix" -outputNamePlaceholderVersion = "Same as original" sectionFolder = "Folder" sectionSourceOutput = "Source & Output" sectionSteps = "Steps" @@ -8230,14 +8246,10 @@ positionSuffix = "Suffix" [watchedFolders.home] create = "Create your first Watched Folder" -dropHere = "Drop to process" editFolder = "Edit folder" empty = "No watched folders yet" file = "file" files = "files" -openFolder = "Open folder" -title = "Watched Folders" -subtitle = "Folders that automatically process PDFs with your configured pipeline" emptyTitle = "Automate your PDF workflows" emptyDesc = "Set up a Watched Folder once. Drop PDFs in and they're automatically compressed, OCR'd, split, merged — whatever your pipeline does." addAnother = "Add another Watched Folder" @@ -8245,7 +8257,6 @@ addAnotherDesc = "Automatically process files with a new pipeline" resume = "Resume" pause = "Pause" deleteFolder = "Delete folder" -noSteps = "No automation steps configured" [watchedFolders.card] edit = "Edit folder" @@ -8328,7 +8339,6 @@ previewLoadFailed = "Could not load file preview." back = "Back" dismiss = "Dismiss" retry = "Retry" -view = "View" download = "Download" downloadInput = "Download input" downloadOutput = "Download output" diff --git a/frontend/editor/src/saas/components/shared/config/configSections/McpSection.tsx b/frontend/editor/src/saas/components/shared/config/configSections/McpSection.tsx new file mode 100644 index 000000000..8313fdd12 --- /dev/null +++ b/frontend/editor/src/saas/components/shared/config/configSections/McpSection.tsx @@ -0,0 +1,262 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { + Stack, + Paper, + Text, + Group, + Alert, + Code, + Badge, + Button, + CopyButton, + Tabs, + Tooltip, + ThemeIcon, +} from "@mantine/core"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { openAppSettings } from "@app/utils/appSettings"; + +/** Strip a single trailing slash so we can safely append paths. */ +function trimTrailingSlash(url: string): string { + return url.endsWith("/") ? url.slice(0, -1) : url; +} + +/** A small copy-to-clipboard button that sits inline next to a URL/snippet. */ +function CopyInline({ value, label }: { value: string; label: string }) { + const { t } = useTranslation(); + return ( + + {({ copied, copy }) => ( + + + + )} + + ); +} + +// SaaS MCP guide: always shown, explains how to point an AI assistant at the +// OAuth-protected /mcp endpoint with per-client config. +export default function McpSection() { + const { t } = useTranslation(); + const { config } = useAppConfig(); + + const baseUrl = useMemo(() => { + const raw = + config?.baseUrl || + (typeof window !== "undefined" ? window.location.origin : ""); + return trimTrailingSlash(raw); + }, [config?.baseUrl]); + + const mcpUrl = `${baseUrl}/mcp`; + + // Per-client connection snippets pointing at this deployment's /mcp endpoint. + const clients = useMemo( + () => [ + { + value: "claude", + label: "Claude Desktop", + file: "claude_desktop_config.json", + config: JSON.stringify( + { mcpServers: { "stirling-pdf": { type: "http", url: mcpUrl } } }, + null, + 2, + ), + }, + { + value: "codex", + label: "Codex CLI", + file: "~/.codex/config.toml", + config: `[mcp_servers.stirling-pdf]\nurl = "${mcpUrl}"`, + }, + { + value: "vscode", + label: "VS Code", + file: ".vscode/mcp.json", + config: JSON.stringify( + { servers: { "stirling-pdf": { type: "http", url: mcpUrl } } }, + null, + 2, + ), + }, + ], + [mcpUrl], + ); + + const tools: { icon: string; key: string; fallback: string }[] = [ + { + icon: "sync-alt-rounded", + key: "config.mcp.tools.convert", + fallback: "Convert", + }, + { + icon: "description-rounded", + key: "config.mcp.tools.pages", + fallback: "Pages", + }, + { icon: "lock", key: "config.mcp.tools.security", fallback: "Security" }, + { + icon: "construction-rounded", + key: "config.mcp.tools.misc", + fallback: "Misc", + }, + { icon: "smart-toy-rounded", key: "config.mcp.tools.ai", fallback: "AI" }, + ]; + + return ( +
+ +
+ + + + + + {t("config.mcp.title", "MCP Server")} + + + + {t( + "config.mcp.description", + "Model Context Protocol (MCP) lets AI assistants like Claude use your Stirling PDF tools directly. Connect a client once and your assistant can convert, edit, secure and process documents on your behalf.", + )} + +
+ + {/* Endpoint */} + + + + + {t("config.mcp.endpoint.label", "Your MCP endpoint")} + + {mcpUrl} + + + + + + {/* Per-client setup */} + + + + {t("config.mcp.setup.title", "Connect your AI assistant")} + + + {t( + "config.mcp.setup.hint", + "Pick your client, paste the snippet into the file shown, then restart it. You'll sign in with your Stirling account on first use - no keys to copy.", + )} + + + + {clients.map((c) => ( + + {c.label} + + ))} + + {clients.map((c) => ( + + + + + {t("config.mcp.setup.addTo", "Add to")}{" "} + {c.file} + + + + {c.config} + + + ))} + + + + + {/* Available tools */} +
+ + {t("config.mcp.tools.title", "What your assistant can do")} + + + {tools.map((tool) => ( + + } + > + {t(tool.key, tool.fallback)} + + ))} + +
+ + {/* Tip / cross-link */} + } + > + + + {t( + "config.mcp.tip", + "Every action your assistant runs is performed as your account and counts towards your usage, just like using Stirling PDF directly.", + )} + + + + +
+
+ ); +} diff --git a/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx index 5dd293451..27979ff5a 100644 --- a/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx +++ b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx @@ -9,6 +9,7 @@ import GeneralSection from "@app/components/shared/config/configSections/General import PasswordSecurity from "@app/components/shared/config/configSections/PasswordSecurity"; import ApiKeys from "@app/components/shared/config/configSections/ApiKeys"; import Plan from "@app/components/shared/config/configSections/Plan"; +import McpSection from "@app/components/shared/config/configSections/McpSection"; type OverviewComponent = React.ComponentType<{ onLogoutClick: () => void }>; @@ -108,6 +109,44 @@ function appendBillingSection( ]; } +// Add an "MCP Server" tab in the Developer section. Always shown in SaaS; +// purely informational, so it appears for anonymous users too. +function appendMcpSection( + sections: ConfigNavSection[], + t: TFunction<"translation", undefined>, +): ConfigNavSection[] { + const hasMcp = sections.some((section) => + section.items.some((item) => item.key === "mcp"), + ); + + if (hasMcp) { + return sections; + } + + const mcpItem = { + key: "mcp" as const, + label: t("config.mcp.navLabel", "MCP Server"), + icon: "smart-toy-rounded", + component: , + }; + + const developerIndex = sections.findIndex((section) => + section.items.some( + (item) => item.key === "developer" || item.key === "api-keys", + ), + ); + + if (developerIndex === -1) { + return [...sections, { title: "Developer", items: [mcpItem] }]; + } + + return sections.map((section, index) => + index === developerIndex + ? { ...section, items: [...section.items, mcpItem] } + : section, + ); +} + export function createSaasConfigNavSections( Overview: OverviewComponent, onLogoutClick: () => void, @@ -151,6 +190,7 @@ export function createSaasConfigNavSections( sections = ensurePreferencesSection(sections); sections = appendDeveloperSection(sections); + sections = appendMcpSection(sections, t); if (!isAnonymous) { sections = appendBillingSection(sections, t); diff --git a/frontend/editor/src/saas/components/shared/config/types.ts b/frontend/editor/src/saas/components/shared/config/types.ts index e39944212..f80bd1ac1 100644 --- a/frontend/editor/src/saas/components/shared/config/types.ts +++ b/frontend/editor/src/saas/components/shared/config/types.ts @@ -1,8 +1,9 @@ import { VALID_NAV_KEYS as CORE_NAV_KEYS } from "@core/components/shared/config/types"; -// SaaS adds an "overview" account section. All other keys (including ones -// SaaS doesn't render today) come from core - subtracting them here would -// just break the type union without affecting runtime nav. -export const VALID_NAV_KEYS = [...CORE_NAV_KEYS, "overview"] as const; +// SaaS adds an "overview" account section and an "mcp" integrations tab. All +// other keys (including ones SaaS doesn't render today) come from core - +// subtracting them here would just break the type union without affecting +// runtime nav. +export const VALID_NAV_KEYS = [...CORE_NAV_KEYS, "overview", "mcp"] as const; export type NavKey = (typeof VALID_NAV_KEYS)[number]; diff --git a/frontend/editor/src/saas/services/apiClient.test.ts b/frontend/editor/src/saas/services/apiClient.test.ts index 56480bb7c..45646bf8d 100644 --- a/frontend/editor/src/saas/services/apiClient.test.ts +++ b/frontend/editor/src/saas/services/apiClient.test.ts @@ -13,6 +13,15 @@ vi.mock("@app/auth/supabase", () => ({ }, })); +// Stub apiClient's heavy UI/util deps so importing it stays cheap. None are +// exercised here (toast, plan settings and handleHttpError paths aren't hit), +// and re-importing the real toast graph per test made these tests time out. +vi.mock("@app/components/toast", () => ({ alert: vi.fn() })); +vi.mock("@app/utils/appSettings", () => ({ openPlanSettings: vi.fn() })); +vi.mock("@app/services/httpErrorHandler", () => ({ + handleHttpError: vi.fn().mockResolvedValue(undefined), +})); + describe("apiClient", () => { beforeEach(() => { vi.clearAllMocks();