mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
Add MCP server with OAuth/API-key auth (#6570)
Adds an optional MCP server (proprietary module) that exposes Stirling's PDF operations and AI capabilities to MCP clients. Off by default, zero footprint when disabled. ### What - New `/mcp` endpoint: streamable-HTTP + JSON-RPC 2.0; 8 tools (describe_operation, pages/convert/misc/security category tools, AI, upload, download). - Runs real operations over an internal loopback; results returned inline as base64 (small) or by fileId (large). ### Auth (two modes) - OAuth2 resource server: RFC 9728 protected-resource metadata, RFC 8707 audience binding, JWKS, `mcp.tools.read/write` scopes; binds each token to a provisioned Stirling account. - API-key mode: reuses Stirling per-user `X-API-KEY` (no IdP needed). ### Security - Per-user file ownership in FileStorage: async/queued writes scoped to the submitting user; legacy/owner-less files stay readable. - Admin allow/block list controls which operations are exposed. - Python engine gated behind a shared secret (`X-Engine-Auth`). - MCP filter chain is isolated and cannot weaken the main app's security. - Hardened: no upstream error-body leakage, log injection sanitized, fileId path/sidecar enumeration blocked. ### Config / footprint - Disabled by default (`mcp.enabled=false`); all beans `@ConditionalOnProperty`. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details.
This commit is contained in:
@@ -17,6 +17,7 @@ import AdminLegalSection from "@app/components/shared/config/configSections/Admi
|
||||
import AdminPlanSection from "@app/components/shared/config/configSections/AdminPlanSection";
|
||||
import AdminFeaturesSection from "@app/components/shared/config/configSections/AdminFeaturesSection";
|
||||
import AdminEndpointsSection from "@app/components/shared/config/configSections/AdminEndpointsSection";
|
||||
import AdminMcpSection from "@app/components/shared/config/configSections/AdminMcpSection";
|
||||
import AdminAuditSection from "@app/components/shared/config/configSections/AdminAuditSection";
|
||||
import AdminUsageSection from "@app/components/shared/config/configSections/AdminUsageSection";
|
||||
import AdminStorageSharingSection from "@app/components/shared/config/configSections/AdminStorageSharingSection";
|
||||
@@ -136,6 +137,14 @@ export const useConfigNavSections = (
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
{
|
||||
key: "adminMcp",
|
||||
label: t("settings.configuration.mcp", "MCP Server"),
|
||||
icon: "smart-toy-rounded",
|
||||
component: <AdminMcpSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
{
|
||||
key: "adminDatabase",
|
||||
label: t("settings.configuration.database", "Database"),
|
||||
|
||||
+551
@@ -0,0 +1,551 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TextInput,
|
||||
Textarea,
|
||||
Switch,
|
||||
Select,
|
||||
Stack,
|
||||
Paper,
|
||||
Text,
|
||||
Loader,
|
||||
Group,
|
||||
Alert,
|
||||
Code,
|
||||
List,
|
||||
} from "@mantine/core";
|
||||
import { alert } from "@app/components/toast";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
|
||||
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
|
||||
import { useAdminSettings } from "@app/hooks/useAdminSettings";
|
||||
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
|
||||
import PendingBadge from "@app/components/shared/config/PendingBadge";
|
||||
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||
|
||||
interface McpAuthData {
|
||||
mode?: string;
|
||||
issuerUri?: string;
|
||||
jwksUri?: string;
|
||||
resourceId?: string;
|
||||
usernameClaim?: string;
|
||||
requireExistingAccount?: boolean;
|
||||
}
|
||||
|
||||
interface McpSettingsData {
|
||||
enabled?: boolean;
|
||||
scopesEnabled?: boolean;
|
||||
allowedOperations?: string[];
|
||||
blockedOperations?: string[];
|
||||
auth?: McpAuthData;
|
||||
}
|
||||
|
||||
/** Parse a comma/space/newline separated list into a string[]. */
|
||||
function parseOpList(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\s,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
interface ApiResponseWithPending<T> {
|
||||
_pending?: Partial<T>;
|
||||
}
|
||||
|
||||
type McpApiResponse = McpSettingsData & ApiResponseWithPending<McpSettingsData>;
|
||||
|
||||
export default function AdminMcpSection() {
|
||||
const { t } = useTranslation();
|
||||
const { loginEnabled } = useLoginRequired();
|
||||
const {
|
||||
restartModalOpened,
|
||||
showRestartModal,
|
||||
closeRestartModal,
|
||||
restartServer,
|
||||
} = useRestartServer();
|
||||
|
||||
const {
|
||||
settings,
|
||||
setSettings,
|
||||
loading,
|
||||
saving,
|
||||
fetchSettings,
|
||||
saveSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<McpSettingsData>({
|
||||
sectionName: "mcp",
|
||||
fetchTransformer: async (): Promise<
|
||||
McpSettingsData & { _pending?: Partial<McpSettingsData> }
|
||||
> => {
|
||||
const response = await apiClient.get<McpApiResponse>(
|
||||
"/api/v1/admin/settings/section/mcp",
|
||||
);
|
||||
return response.data || {};
|
||||
},
|
||||
// Save nested auth.* keys as dot-notation through the root endpoint so siblings are preserved.
|
||||
saveTransformer: (s: McpSettingsData) => ({
|
||||
sectionData: {},
|
||||
deltaSettings: {
|
||||
"mcp.enabled": s.enabled ?? false,
|
||||
"mcp.scopesEnabled": s.scopesEnabled ?? true,
|
||||
"mcp.allowedOperations": s.allowedOperations ?? [],
|
||||
"mcp.blockedOperations": s.blockedOperations ?? [],
|
||||
"mcp.auth.mode": s.auth?.mode ?? "oauth",
|
||||
"mcp.auth.issuerUri": s.auth?.issuerUri ?? "",
|
||||
"mcp.auth.jwksUri": s.auth?.jwksUri ?? "",
|
||||
"mcp.auth.resourceId": s.auth?.resourceId ?? "",
|
||||
"mcp.auth.usernameClaim": s.auth?.usernameClaim ?? "sub",
|
||||
"mcp.auth.requireExistingAccount":
|
||||
s.auth?.requireExistingAccount ?? true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||
settings,
|
||||
loading,
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveSettings();
|
||||
markSaved();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("admin.error", "Error"),
|
||||
body: t("admin.settings.saveError", "Failed to save settings"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
setSettings(resetToSnapshot());
|
||||
}, [resetToSnapshot, setSettings]);
|
||||
|
||||
const setAuth = (patch: Partial<McpAuthData>) =>
|
||||
setSettings({ ...settings, auth: { ...(settings.auth || {}), ...patch } });
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "https://your-host";
|
||||
const mcpUrl = `${baseUrl}/mcp`;
|
||||
const metadataUrl = `${baseUrl}/.well-known/oauth-protected-resource`;
|
||||
const authMode = settings.auth?.mode || "oauth";
|
||||
|
||||
return (
|
||||
<div className="settings-section-container">
|
||||
<Stack gap="lg" className="settings-section-content">
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t("admin.settings.mcp.title", "MCP Server")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"admin.settings.mcp.description",
|
||||
"Expose Stirling's PDF tools and AI agents to MCP clients over an OAuth-protected endpoint.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t("admin.settings.mcp.enabled.label", "Enable MCP Server")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"admin.settings.mcp.enabled.description",
|
||||
"When off (default), no /mcp endpoint, metadata, or MCP beans are loaded.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.enabled || false}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, enabled: e.target.checked })
|
||||
}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending("enabled")} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Select
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t("admin.settings.mcp.mode.label", "Authentication mode")}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("auth.mode")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mcp.mode.description",
|
||||
"OAuth needs an external IdP. API key uses a Stirling per-user API key (X-API-KEY) - simplest for self-host.",
|
||||
)}
|
||||
data={[
|
||||
{ value: "oauth", label: "OAuth 2.1 (external IdP)" },
|
||||
{ value: "apikey", label: "API key (Stirling per-user key)" },
|
||||
]}
|
||||
value={authMode}
|
||||
onChange={(v) => setAuth({ mode: v || "oauth" })}
|
||||
allowDeselect={false}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
|
||||
{authMode === "apikey" && (
|
||||
<Alert variant="light" color="gray">
|
||||
<Text size="xs">
|
||||
{t(
|
||||
"admin.settings.mcp.apikeyNote",
|
||||
"Clients send a Stirling API key in the X-API-KEY header (or Authorization: Bearer <key>). The key maps to its owning Stirling user - only provisioned accounts get in, and actions are audited as that user. Manage keys under Account → API Keys.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{authMode === "oauth" && (
|
||||
<>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t(
|
||||
"admin.settings.mcp.issuerUri.label",
|
||||
"OAuth Issuer URL",
|
||||
)}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("auth.issuerUri")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mcp.issuerUri.description",
|
||||
"Your OAuth2 authorization server (must publish /.well-known/openid-configuration). Required when enabled.",
|
||||
)}
|
||||
value={settings.auth?.issuerUri || ""}
|
||||
onChange={(e) => setAuth({ issuerUri: e.target.value })}
|
||||
placeholder="https://auth.example.com"
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t(
|
||||
"admin.settings.mcp.resourceId.label",
|
||||
"Resource ID",
|
||||
)}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("auth.resourceId")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mcp.resourceId.description",
|
||||
"This server's public /mcp URL. Tokens must list it in their audience (RFC 8707) or they are rejected.",
|
||||
)}
|
||||
value={settings.auth?.resourceId || ""}
|
||||
onChange={(e) => setAuth({ resourceId: e.target.value })}
|
||||
placeholder={mcpUrl}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t(
|
||||
"admin.settings.mcp.jwksUri.label",
|
||||
"JWKS URL (optional)",
|
||||
)}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("auth.jwksUri")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mcp.jwksUri.description",
|
||||
"Leave blank to discover it from the issuer. Set only if your IdP serves keys at a non-standard URL.",
|
||||
)}
|
||||
value={settings.auth?.jwksUri || ""}
|
||||
onChange={(e) => setAuth({ jwksUri: e.target.value })}
|
||||
placeholder={t(
|
||||
"admin.settings.mcp.jwksUri.placeholder",
|
||||
"Auto-discovered from issuer",
|
||||
)}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.mcp.scopes.label",
|
||||
"Enforce OAuth scopes",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"admin.settings.mcp.scopes.description",
|
||||
"Require mcp.tools.read for read ops and mcp.tools.write for write/AI ops.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.scopesEnabled ?? true}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
scopesEnabled: e.target.checked,
|
||||
})
|
||||
}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
<PendingBadge show={isFieldPending("scopesEnabled")} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t(
|
||||
"admin.settings.mcp.requireAccount.label",
|
||||
"Require an existing Stirling account",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
"admin.settings.mcp.requireAccount.description",
|
||||
"Only let tokens through if their subject maps to a provisioned, enabled Stirling user.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Switch
|
||||
checked={settings.auth?.requireExistingAccount ?? true}
|
||||
onChange={(e) =>
|
||||
setAuth({ requireExistingAccount: e.target.checked })
|
||||
}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
<PendingBadge
|
||||
show={isFieldPending("auth.requireExistingAccount")}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t(
|
||||
"admin.settings.mcp.usernameClaim.label",
|
||||
"Username claim",
|
||||
)}
|
||||
</span>
|
||||
<PendingBadge
|
||||
show={isFieldPending("auth.usernameClaim")}
|
||||
/>
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mcp.usernameClaim.description",
|
||||
"JWT claim matched against a Stirling username (e.g. sub, email, preferred_username).",
|
||||
)}
|
||||
value={settings.auth?.usernameClaim || ""}
|
||||
onChange={(e) => setAuth({ usernameClaim: e.target.value })}
|
||||
placeholder="sub"
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t("admin.settings.mcp.allowedOps.label", "Allowed tools")}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("allowedOperations")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mcp.allowedOps.description",
|
||||
"If set, ONLY these operation ids are exposed (an allow-list; comma or space separated). Leave blank to expose all enabled tools except any blocked below.",
|
||||
)}
|
||||
value={(settings.allowedOperations || []).join(" ")}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
allowedOperations: parseOpList(e.target.value),
|
||||
})
|
||||
}
|
||||
placeholder="merge-pdfs split-pdf rotate-pdf"
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={3}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>
|
||||
{t("admin.settings.mcp.blockedOps.label", "Blocked tools")}
|
||||
</span>
|
||||
<PendingBadge show={isFieldPending("blockedOperations")} />
|
||||
</Group>
|
||||
}
|
||||
description={t(
|
||||
"admin.settings.mcp.blockedOps.description",
|
||||
"Operation ids to hide from MCP (comma or space separated), e.g. add-password remove-password. Leave blank to expose all enabled tools.",
|
||||
)}
|
||||
value={(settings.blockedOperations || []).join(" ")}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
blockedOperations: parseOpList(e.target.value),
|
||||
})
|
||||
}
|
||||
placeholder="add-password sanitize-pdf"
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={3}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Compact in-page setup guide */}
|
||||
<Alert
|
||||
variant="light"
|
||||
color="blue"
|
||||
title={t("admin.settings.mcp.guide.title", "Connect an MCP client")}
|
||||
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
|
||||
>
|
||||
<Stack gap={6}>
|
||||
<List size="xs" type="ordered" spacing={4}>
|
||||
{authMode === "oauth" ? (
|
||||
<>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step1",
|
||||
"Enter your OAuth issuer + resource ID above, Save, and restart.",
|
||||
)}
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step2",
|
||||
"In your MCP client add a",
|
||||
)}{" "}
|
||||
<b>streamable-HTTP</b>{" "}
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step2b",
|
||||
"server pointing at:",
|
||||
)}{" "}
|
||||
<Code>{mcpUrl}</Code>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step3",
|
||||
"The client auto-discovers OAuth from:",
|
||||
)}{" "}
|
||||
<Code>{metadataUrl}</Code>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step4",
|
||||
"Approve the sign-in; the client retries with a token. Tools appear grouped: convert, pages, misc, security, ai.",
|
||||
)}
|
||||
</List.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step1ApiKey",
|
||||
"Create an API key under Account → API Keys (each user uses their own).",
|
||||
)}
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step2",
|
||||
"In your MCP client add a",
|
||||
)}{" "}
|
||||
<b>streamable-HTTP</b>{" "}
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step2b",
|
||||
"server pointing at:",
|
||||
)}{" "}
|
||||
<Code>{mcpUrl}</Code>
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step3ApiKey",
|
||||
"Send the key in an",
|
||||
)}{" "}
|
||||
<Code>X-API-KEY</Code>{" "}
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step3ApiKeyb",
|
||||
"header (or Authorization: Bearer <key>). No OAuth or metadata discovery is used.",
|
||||
)}
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
{t(
|
||||
"admin.settings.mcp.guide.step4ApiKey",
|
||||
"Tools appear grouped: convert, pages, misc, security, ai - and every call is audited as the key's owner.",
|
||||
)}
|
||||
</List.Item>
|
||||
</>
|
||||
)}
|
||||
</List>
|
||||
<Text size="xs" c="dimmed">
|
||||
{authMode === "oauth"
|
||||
? t(
|
||||
"admin.settings.mcp.guide.tip",
|
||||
"Tip: register the resource ID as an allowed audience in your IdP. Tested with MCP Inspector and Claude Desktop.",
|
||||
)
|
||||
: t(
|
||||
"admin.settings.mcp.guide.tipApiKey",
|
||||
"Tip: API-key mode needs no external IdP - simplest for self-host. The key maps to its owning Stirling user.",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
</Stack>
|
||||
|
||||
<SettingsStickyFooter
|
||||
isDirty={isDirty}
|
||||
saving={saving}
|
||||
loginEnabled={loginEnabled}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
/>
|
||||
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user