feat: add MCP server for AI assistant integration (#5882)
This commit is contained in:
1 parent
d77c6f8d4e
commit
6baff7be3d
47 files changed
+5722
-12624
No files matched your search
@@ -0,0 +1,32 @@
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
const baseUrl = extractBaseUrlFromHeaders(await headers());
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
issuer: baseUrl,
|
||||
authorization_endpoint: `${baseUrl}/api/mcp/oauth/authorize`,
|
||||
token_endpoint: `${baseUrl}/api/mcp/oauth/token`,
|
||||
registration_endpoint: `${baseUrl}/api/mcp/oauth/register`,
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: ["none"],
|
||||
scopes_supported: ["mcp:tools"],
|
||||
},
|
||||
{ headers: corsHeaders },
|
||||
);
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
const baseUrl = extractBaseUrlFromHeaders(await headers());
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
resource: baseUrl,
|
||||
authorization_servers: [baseUrl],
|
||||
},
|
||||
{ headers: corsHeaders },
|
||||
);
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
|
||||
import { IconBrain, IconCode, IconKey } from "@tabler/icons-react";
|
||||
|
||||
import { McpInstructions } from "./mcp-instructions";
|
||||
|
||||
export interface McpToolGroup {
|
||||
namespace: string;
|
||||
tools: { name: string; description: string; type: "query" | "mutation" }[];
|
||||
}
|
||||
|
||||
interface ApiPageTabsProps {
|
||||
documentationLabel: string;
|
||||
apiKeyLabel: string;
|
||||
mcpLabel: string;
|
||||
documentationPanel: ReactNode;
|
||||
authenticationPanel: ReactNode;
|
||||
baseUrl: string;
|
||||
hasApiKeys: boolean;
|
||||
toolGroups: McpToolGroup[];
|
||||
}
|
||||
|
||||
export function ApiPageTabs({
|
||||
documentationLabel,
|
||||
apiKeyLabel,
|
||||
mcpLabel,
|
||||
documentationPanel,
|
||||
authenticationPanel,
|
||||
baseUrl,
|
||||
hasApiKeys,
|
||||
toolGroups,
|
||||
}: ApiPageTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<string | null>("documentation");
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTab value="documentation" leftSection={<IconCode size={16} />}>
|
||||
{documentationLabel}
|
||||
</TabsTab>
|
||||
<TabsTab value="authentication" leftSection={<IconKey size={16} />}>
|
||||
{apiKeyLabel}
|
||||
</TabsTab>
|
||||
<TabsTab value="mcp" leftSection={<IconBrain size={16} />}>
|
||||
{mcpLabel}
|
||||
</TabsTab>
|
||||
</TabsList>
|
||||
<TabsPanel value="authentication">{authenticationPanel}</TabsPanel>
|
||||
<TabsPanel value="documentation">{documentationPanel}</TabsPanel>
|
||||
<TabsPanel value="mcp">
|
||||
<McpInstructions baseUrl={baseUrl} hasApiKeys={hasApiKeys} toolGroups={toolGroups} />
|
||||
</TabsPanel>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionControl,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Code,
|
||||
CopyButton,
|
||||
Group,
|
||||
List,
|
||||
ListItem,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBrain,
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconKey,
|
||||
IconLock,
|
||||
IconPlus,
|
||||
IconPlugConnected,
|
||||
IconSparkles,
|
||||
IconTerminal,
|
||||
IconTools,
|
||||
IconWorld,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CopyApiKeyModal } from "./copy-api-key-modal";
|
||||
import type { McpToolGroup } from "./api-page-tabs";
|
||||
import classes from "./mcp-tools-accordion.module.css";
|
||||
|
||||
const toolTypeDisplay: Record<"query" | "mutation", { color: string; method: string }> = {
|
||||
query: { color: "blue", method: "GET" },
|
||||
mutation: { color: "orange", method: "POST" },
|
||||
};
|
||||
|
||||
interface McpInstructionsProps {
|
||||
baseUrl: string;
|
||||
hasApiKeys: boolean;
|
||||
toolGroups: McpToolGroup[];
|
||||
}
|
||||
|
||||
export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstructionsProps) {
|
||||
const t = useScopedI18n("management.page.tool.api.tab.mcp");
|
||||
const { openModal } = useModalAction(CopyApiKeyModal);
|
||||
const { mutate: createApiKey, isPending } = clientApi.apiKeys.create.useMutation({
|
||||
async onSuccess(data) {
|
||||
openModal({ apiKey: data.apiKey });
|
||||
await revalidatePathActionAsync("/manage/tools/api");
|
||||
},
|
||||
});
|
||||
const mcpUrl = `${baseUrl}/api/mcp/mcp`;
|
||||
|
||||
const streamableHttpConfig = JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
homarr: {
|
||||
url: mcpUrl,
|
||||
headers: {
|
||||
ApiKey: "<your-api-key>",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
const stdioConfig = JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
homarr: {
|
||||
command: "npx",
|
||||
args: ["-y", "mcp-remote", mcpUrl, "--header", "ApiKey:<your-api-key>"],
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
const testCommand = `curl -s -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -H "ApiKey: <your-api-key>" -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}' ${mcpUrl}`;
|
||||
|
||||
return (
|
||||
<Stack gap="xl" p="md">
|
||||
<Group gap="sm" align="center">
|
||||
<ThemeIcon variant="light" size="xl" radius="md">
|
||||
<IconBrain size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>{t("title")}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("subtitle")}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Alert variant="light" icon={<IconSparkles size={18} />}>
|
||||
<Text size="sm">{t("description")}</Text>
|
||||
</Alert>
|
||||
|
||||
{!hasApiKeys && (
|
||||
<Alert variant="light" color="yellow" icon={<IconKey size={18} />}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm">{t("noApiKey")}</Text>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="filled"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() => createApiKey()}
|
||||
loading={isPending}
|
||||
>
|
||||
{t("createApiKeyButton")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Title order={5} mb="xs">
|
||||
<Group gap="xs">
|
||||
<IconPlugConnected size={16} />
|
||||
{t("endpoint.title")}
|
||||
</Group>
|
||||
</Title>
|
||||
<CopyableCode value={mcpUrl} copyLabel={t("copy")} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Title order={5} mb="xs">
|
||||
<Group gap="xs">
|
||||
<IconLock size={16} />
|
||||
{t("authentication.title")}
|
||||
</Group>
|
||||
</Title>
|
||||
<Text size="sm" mb="sm">
|
||||
{t("authentication.intro")}
|
||||
</Text>
|
||||
|
||||
<Accordion variant="separated" defaultValue="apikey">
|
||||
<AccordionItem value="apikey">
|
||||
<AccordionControl>
|
||||
<Group gap="xs">
|
||||
<IconKey size={16} />
|
||||
{t("authentication.apiKey.title")}
|
||||
</Group>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">{t("authentication.apiKey.description")}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("authentication.permissions")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="oauth">
|
||||
<AccordionControl>
|
||||
<Group gap="xs">
|
||||
<IconWorld size={16} />
|
||||
{t("authentication.oauth.title")}
|
||||
</Group>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">{t("authentication.oauth.description")}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("authentication.oauth.hint")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Title order={5} mb="sm">
|
||||
{t("clientConfig.title")}
|
||||
</Title>
|
||||
<Accordion variant="separated" defaultValue="streamable-http">
|
||||
<AccordionItem value="streamable-http">
|
||||
<AccordionControl>
|
||||
<Group gap="xs">
|
||||
<Badge size="xs" variant="filled">
|
||||
{t("clientConfig.streamableHttp.recommended")}
|
||||
</Badge>
|
||||
{t("clientConfig.streamableHttp.title")}
|
||||
</Group>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("clientConfig.streamableHttp.worksWith")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("clientConfig.streamableHttp.addToConfig")}
|
||||
</Text>
|
||||
<List size="xs" spacing={4}>
|
||||
<ListItem>
|
||||
{t("clientConfig.streamableHttp.cursor", {
|
||||
name: "Cursor",
|
||||
file: ".cursor/mcp.json",
|
||||
})}
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
{t("clientConfig.streamableHttp.claudeDesktop", {
|
||||
name: "Claude Desktop",
|
||||
file: "claude_desktop_config.json",
|
||||
})}
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
{t("clientConfig.streamableHttp.claudeCode", {
|
||||
name: "Claude Code",
|
||||
file: ".claude/mcp.json",
|
||||
command: "claude mcp add homarr",
|
||||
})}
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
{t("clientConfig.streamableHttp.vsCodeCopilot", {
|
||||
name: "VS Code Copilot",
|
||||
file: ".vscode/mcp.json",
|
||||
})}
|
||||
</ListItem>
|
||||
</List>
|
||||
<CopyableCode value={streamableHttpConfig} copyLabel={t("copy")} />
|
||||
<Alert variant="light" color="yellow" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs">{t("clientConfig.streamableHttp.replaceHint")}</Text>
|
||||
</Alert>
|
||||
</Stack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="stdio">
|
||||
<AccordionControl>{t("clientConfig.stdio.title")}</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("clientConfig.stdio.description", { dependency: "npx" })}
|
||||
</Text>
|
||||
<CopyableCode value={stdioConfig} copyLabel={t("copy")} />
|
||||
</Stack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Title order={5} mb="xs">
|
||||
<Group gap="xs">
|
||||
<IconTerminal size={16} />
|
||||
{t("testConnection.title")}
|
||||
</Group>
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="xs">
|
||||
{t("testConnection.description")}
|
||||
</Text>
|
||||
<CopyableCode value={testCommand} copyLabel={t("copy")} />
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
{t("testConnection.hint")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Title order={5} mb="xs">
|
||||
<Group gap="xs">
|
||||
<IconTools size={16} />
|
||||
{t("availableTools.title")}
|
||||
</Group>
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="sm">
|
||||
{t("availableTools.description", {
|
||||
count: String(toolGroups.reduce((sum, g) => sum + g.tools.length, 0)),
|
||||
})}
|
||||
</Text>
|
||||
<Accordion variant="separated" multiple>
|
||||
{toolGroups.map((group) => (
|
||||
<AccordionItem key={group.namespace} value={group.namespace}>
|
||||
<AccordionControl>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{group.namespace}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" circle>
|
||||
{group.tools.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<Stack gap={6}>
|
||||
{group.tools.map((tool) => (
|
||||
<div key={tool.name} className={classes.toolRow}>
|
||||
<Badge size="sm" w={50} radius="xs" variant="light" color={toolTypeDisplay[tool.type].color}>
|
||||
{toolTypeDisplay[tool.type].method}
|
||||
</Badge>
|
||||
<div>
|
||||
<Code fz="xs" fw={600}>
|
||||
{tool.name}
|
||||
</Code>
|
||||
<Text size="xs" c="dimmed" lh={1.4}>
|
||||
{tool.description}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyableCode({ value, copyLabel }: { value: string; copyLabel: string }) {
|
||||
return (
|
||||
<Code block pos="relative" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{value}
|
||||
<CopyButton value={value}>
|
||||
{({ copied, copy }) => (
|
||||
<ActionIcon variant="subtle" size="sm" onClick={copy} pos="absolute" top={8} right={8} title={copyLabel}>
|
||||
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
||||
</ActionIcon>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Code>
|
||||
);
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
.toolRow {
|
||||
display: grid;
|
||||
grid-template-columns: 42px 1fr;
|
||||
align-items: first baseline;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useComputedColorScheme } from "@mantine/core";
|
||||
import { ApiReferenceReact } from "@scalar/api-reference-react";
|
||||
|
||||
import "@scalar/api-reference-react/style.css";
|
||||
import "./scalar-theme.css";
|
||||
|
||||
interface ScalarApiReferenceProps {
|
||||
document: object;
|
||||
}
|
||||
|
||||
export function ScalarApiReference({ document }: ScalarApiReferenceProps) {
|
||||
const colorScheme = useComputedColorScheme("light");
|
||||
|
||||
return (
|
||||
<ApiReferenceReact
|
||||
configuration={{
|
||||
content: document,
|
||||
layout: "classic",
|
||||
theme: "alternate",
|
||||
showSidebar: false,
|
||||
hideDarkModeToggle: true,
|
||||
hideSearch: true,
|
||||
hiddenClients: true,
|
||||
showDeveloperTools: "never",
|
||||
defaultOpenAllTags: true,
|
||||
forceDarkModeState: colorScheme,
|
||||
authentication: {
|
||||
preferredSecurityScheme: "apikey",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
.light-mode {
|
||||
--scalar-background-1: var(--mantine-color-body);
|
||||
--scalar-background-2: var(--mantine-color-gray-0);
|
||||
--scalar-background-3: var(--mantine-color-gray-1);
|
||||
--scalar-background-card: var(--mantine-color-white);
|
||||
--scalar-background-accent: rgba(0, 0, 0, 0.04);
|
||||
|
||||
--scalar-color-1: var(--mantine-color-text);
|
||||
--scalar-color-2: var(--mantine-color-dimmed);
|
||||
--scalar-color-3: var(--mantine-color-dimmed);
|
||||
--scalar-color-accent: var(--mantine-color-red-6);
|
||||
|
||||
--scalar-border-color: var(--mantine-color-gray-3);
|
||||
|
||||
--scalar-button-1: var(--mantine-color-dark-7);
|
||||
--scalar-button-1-hover: var(--mantine-color-dark-5);
|
||||
--scalar-button-1-color: var(--mantine-color-white);
|
||||
|
||||
--scalar-scrollbar-color: rgba(0, 0, 0, 0.18);
|
||||
--scalar-scrollbar-color-active: rgba(0, 0, 0, 0.36);
|
||||
}
|
||||
|
||||
.dark-mode {
|
||||
--scalar-background-1: var(--mantine-color-body);
|
||||
--scalar-background-2: var(--mantine-color-dark-6);
|
||||
--scalar-background-3: var(--mantine-color-dark-5);
|
||||
--scalar-background-card: var(--mantine-color-dark-6);
|
||||
--scalar-background-accent: rgba(255, 255, 255, 0.06);
|
||||
|
||||
--scalar-color-1: var(--mantine-color-text);
|
||||
--scalar-color-2: var(--mantine-color-dimmed);
|
||||
--scalar-color-3: var(--mantine-color-dimmed);
|
||||
--scalar-color-accent: var(--mantine-color-red-6);
|
||||
|
||||
--scalar-border-color: var(--mantine-color-dark-4);
|
||||
|
||||
--scalar-button-1: rgba(255, 255, 255, 0.9);
|
||||
--scalar-button-1-hover: rgba(255, 255, 255, 0.8);
|
||||
--scalar-button-1-color: var(--mantine-color-dark-7);
|
||||
|
||||
--scalar-scrollbar-color: rgba(255, 255, 255, 0.18);
|
||||
--scalar-scrollbar-color-active: rgba(255, 255, 255, 0.36);
|
||||
}
|
||||
|
||||
.scalar-card,
|
||||
.scalar-api-client__item,
|
||||
.dark-mode .dark-mode.scalar-card {
|
||||
--scalar-background-1: var(--scalar-background-card);
|
||||
--scalar-background-2: var(--scalar-background-card);
|
||||
--scalar-background-3: var(--scalar-background-card);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { OpenAPIObject } from "openapi3-ts/oas31";
|
||||
import SwaggerUI from "swagger-ui-react";
|
||||
|
||||
// workaround for CSS that cannot be processed by next.js, https://github.com/swagger-api/swagger-ui/issues/10045
|
||||
import "../swagger-ui-dark.css";
|
||||
import "../swagger-ui-overrides.css";
|
||||
import "../swagger-ui.css";
|
||||
|
||||
interface SwaggerUIClientProps {
|
||||
document: OpenAPIObject;
|
||||
}
|
||||
|
||||
export const SwaggerUIClient = ({ document }: SwaggerUIClientProps) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const requestInterceptor = (req: Record<string, any>) => {
|
||||
req.credentials = "omit";
|
||||
return req;
|
||||
};
|
||||
|
||||
return <SwaggerUI requestInterceptor={requestInterceptor} spec={document} />;
|
||||
};
|
||||
@@ -1,16 +1,57 @@
|
||||
import { headers } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Stack, Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core";
|
||||
import { Stack } from "@mantine/core";
|
||||
|
||||
import { mcpRouter } from "@homarr/api/mcp";
|
||||
import { openApiDocument } from "@homarr/api/open-api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { SwaggerUIClient } from "~/app/[locale]/manage/tools/api/components/swagger-ui";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
|
||||
import { ApiKeysManagement } from "./components/api-keys";
|
||||
import { ApiPageTabs } from "./components/api-page-tabs";
|
||||
import { ScalarApiReference } from "./components/scalar-api-reference";
|
||||
|
||||
import type { McpToolGroup } from "./components/api-page-tabs";
|
||||
|
||||
let cachedToolGroups: McpToolGroup[] | null = null;
|
||||
|
||||
function getMcpToolGroups(): McpToolGroup[] {
|
||||
if (cachedToolGroups) return cachedToolGroups;
|
||||
|
||||
const tools = extractMcpTools();
|
||||
|
||||
const procedures = (mcpRouter as any)._def.procedures as Record<string, { _def?: { type?: string } }>;
|
||||
const procedureTypeMap = new Map<string, "query" | "mutation">();
|
||||
for (const [key, proc] of Object.entries(procedures)) {
|
||||
const type = proc?._def?.type;
|
||||
if (type === "query" || type === "mutation") {
|
||||
procedureTypeMap.set(key, type);
|
||||
}
|
||||
}
|
||||
|
||||
const groups = new Map<string, McpToolGroup["tools"]>();
|
||||
for (const tool of tools) {
|
||||
const namespace = tool.pathInRouter[0] ?? "other";
|
||||
if (!groups.has(namespace)) {
|
||||
groups.set(namespace, []);
|
||||
}
|
||||
const procedureKey = tool.pathInRouter.join(".");
|
||||
groups.get(namespace)?.push({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
type: procedureTypeMap.get(procedureKey) ?? "query",
|
||||
});
|
||||
}
|
||||
cachedToolGroups = Array.from(groups.entries()).map(([namespace, items]) => ({
|
||||
namespace,
|
||||
tools: items,
|
||||
}));
|
||||
return cachedToolGroups;
|
||||
}
|
||||
|
||||
export async function generateMetadata() {
|
||||
const session = await auth();
|
||||
@@ -30,24 +71,25 @@ export default async function ApiPage() {
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
notFound();
|
||||
}
|
||||
const document = openApiDocument(extractBaseUrlFromHeaders(await headers()));
|
||||
const requestHeaders = await headers();
|
||||
const baseUrl = extractBaseUrlFromHeaders(requestHeaders);
|
||||
const document = openApiDocument(baseUrl);
|
||||
const apiKeys = await api.apiKeys.getAll();
|
||||
const t = await getScopedI18n("management.page.tool.api.tab");
|
||||
const toolGroups = getMcpToolGroups();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Tabs defaultValue={"documentation"}>
|
||||
<TabsList>
|
||||
<TabsTab value={"documentation"}>{t("documentation.label")}</TabsTab>
|
||||
<TabsTab value={"authentication"}>{t("apiKey.label")}</TabsTab>
|
||||
</TabsList>
|
||||
<TabsPanel value={"authentication"}>
|
||||
<ApiKeysManagement apiKeys={apiKeys} />
|
||||
</TabsPanel>
|
||||
<TabsPanel value={"documentation"}>
|
||||
<SwaggerUIClient document={document} />
|
||||
</TabsPanel>
|
||||
</Tabs>
|
||||
<ApiPageTabs
|
||||
documentationLabel={t("documentation.label")}
|
||||
apiKeyLabel={t("apiKey.label")}
|
||||
mcpLabel={t("mcp.label")}
|
||||
documentationPanel={<ScalarApiReference document={document} />}
|
||||
authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />}
|
||||
baseUrl={baseUrl}
|
||||
hasApiKeys={apiKeys.length > 0}
|
||||
toolGroups={toolGroups}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +0,0 @@
|
||||
.swagger-ui .info {
|
||||
margin: 0 !important;
|
||||
margin-bottom: 20px !important;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,300 @@
|
||||
import { AsyncLocalStorage } from "async_hooks";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { userAgent } from "next/server";
|
||||
import { createMcpHandler } from "mcp-handler";
|
||||
import type { McpTool } from "trpc-to-mcp";
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
|
||||
import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key";
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
import { ipAddressFromHeaders } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
import { getPackageVersion } from "~/versions/package-reader";
|
||||
import { extractMcpTools } from "../_extract-tools";
|
||||
|
||||
const logger = createLogger({ module: "mcpRoute" });
|
||||
|
||||
const AUTH_RATE_WINDOW_MS = 60_000;
|
||||
const AUTH_MAX_FAILURES = 10;
|
||||
|
||||
interface RateLimitStore {
|
||||
authFailures: Map<string, { count: number; resetAt: number }>;
|
||||
cleanupTimer: ReturnType<typeof setInterval> | null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var mcpRateLimit: RateLimitStore | undefined;
|
||||
}
|
||||
|
||||
const rateLimitStore =
|
||||
globalThis.mcpRateLimit ??
|
||||
(globalThis.mcpRateLimit = {
|
||||
authFailures: new Map(),
|
||||
cleanupTimer: null,
|
||||
});
|
||||
const authFailures = rateLimitStore.authFailures;
|
||||
|
||||
if (!rateLimitStore.cleanupTimer) {
|
||||
rateLimitStore.cleanupTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of authFailures) {
|
||||
if (entry.resetAt < now) authFailures.delete(key);
|
||||
}
|
||||
}, AUTH_RATE_WINDOW_MS);
|
||||
rateLimitStore.cleanupTimer.unref();
|
||||
}
|
||||
|
||||
function checkAuthRateLimit(ip: string | null): boolean {
|
||||
const key = ip ?? "unknown";
|
||||
const now = Date.now();
|
||||
const entry = authFailures.get(key);
|
||||
if (!entry || entry.resetAt < now) return true;
|
||||
return entry.count < AUTH_MAX_FAILURES;
|
||||
}
|
||||
|
||||
function recordAuthFailure(ip: string | null) {
|
||||
const key = ip ?? "unknown";
|
||||
const now = Date.now();
|
||||
const entry = authFailures.get(key);
|
||||
if (!entry || entry.resetAt < now) {
|
||||
authFailures.set(key, { count: 1, resetAt: now + AUTH_RATE_WINDOW_MS });
|
||||
return;
|
||||
}
|
||||
entry.count++;
|
||||
}
|
||||
|
||||
function clearAuthFailures(ip: string | null) {
|
||||
authFailures.delete(ip ?? "unknown");
|
||||
}
|
||||
|
||||
const callerStorage = new AsyncLocalStorage<ReturnType<typeof mcpRouter.createCaller>>();
|
||||
|
||||
const emptyObjectSchema = {
|
||||
type: "object" as const,
|
||||
properties: {},
|
||||
required: [] as string[],
|
||||
};
|
||||
|
||||
let toolsCache: McpTool[] | null = null;
|
||||
|
||||
function normalizeSchema(schema: McpTool["inputSchema"]) {
|
||||
if (!schema) return emptyObjectSchema;
|
||||
const properties = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const required = ((schema.required as string[]) ?? []).filter((key) => !("default" in (properties[key] ?? {})));
|
||||
return { type: schema.type ?? ("object" as const), properties, required };
|
||||
}
|
||||
|
||||
function getTools() {
|
||||
if (!toolsCache) {
|
||||
toolsCache = extractMcpTools().map((tool) => ({
|
||||
...tool,
|
||||
inputSchema: normalizeSchema(tool.inputSchema),
|
||||
}));
|
||||
logger.info(`Extracted ${toolsCache.length} MCP tools`);
|
||||
}
|
||||
return toolsCache;
|
||||
}
|
||||
|
||||
const SAFE_ERROR_MESSAGES: Record<string, string> = {
|
||||
UNAUTHORIZED: "You do not have permission to perform this action",
|
||||
FORBIDDEN: "Access denied",
|
||||
NOT_FOUND: "The requested resource was not found",
|
||||
BAD_REQUEST: "Invalid request parameters",
|
||||
INTERNAL_SERVER_ERROR: "An internal error occurred",
|
||||
TIMEOUT: "The operation timed out",
|
||||
TOO_MANY_REQUESTS: "Rate limit exceeded, try again later",
|
||||
CONFLICT: "A conflict occurred with the current state",
|
||||
PRECONDITION_FAILED: "A precondition for this operation was not met",
|
||||
};
|
||||
|
||||
function sanitizeErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && "code" in error) {
|
||||
const trpcCode = (error as Error & { code: string }).code;
|
||||
const safeMessage = SAFE_ERROR_MESSAGES[trpcCode];
|
||||
if (safeMessage) return safeMessage;
|
||||
}
|
||||
if (error instanceof Error && error.message.length < 200 && !error.message.includes("/")) {
|
||||
return error.message;
|
||||
}
|
||||
return "Tool execution failed";
|
||||
}
|
||||
|
||||
const SERVER_INSTRUCTIONS = `You are connected to Homarr, a self-hosted homelab dashboard.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
**Integrations** are connections to external self-hosted services (Sonarr, Radarr, Plex, Overseerr, Pi-hole, Home Assistant, etc.). Each integration has an \`id\` (integrationId) and a \`kind\` (e.g. "sonarr", "radarr", "overseerr"). Use \`integration_all\` to list them. Many tools require an \`integrationId\` — always fetch it from \`integration_all\` or \`integration_search\` first.
|
||||
|
||||
**Boards** are customizable dashboards containing widgets that display data from integrations. Users can have multiple boards.
|
||||
|
||||
**Apps** are links/bookmarks to self-hosted services displayed on boards. They are separate from integrations — an app is a visual shortcut, an integration is a data connection.
|
||||
|
||||
**Permissions**: Each integration result includes a \`permissions\` object:
|
||||
- \`hasUseAccess: true\` means you can read data from this integration
|
||||
- \`hasInteractAccess: true\` means you can perform actions (start/stop, request media, etc.)
|
||||
- \`hasFullAccess: true\` means you can modify/delete the integration config
|
||||
If \`hasUseAccess\` is false, the API key owner lacks permission for that integration — this is normal, not an error. Simply skip integrations where you lack the required permission level.
|
||||
|
||||
## Common Workflows
|
||||
|
||||
**"What's coming this week?"** → Use \`calendar_findAllEvents\` to get upcoming media releases from Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books).
|
||||
|
||||
**"Search and request a movie/show"** → Use \`integration_searchMediaRequests\` to search across Overseerr/Jellyseerr, then \`integration_requestMedia\` to submit a request. Use \`integration_getMediaRequestOptions\` first for TV shows to pick seasons.
|
||||
|
||||
**"What's streaming right now?"** → Use \`mediaServer_getCurrentStreams\` to see active Plex/Jellyfin/Emby streams.
|
||||
|
||||
**"Check server health"** → Use \`healthMonitoring_getSystemHealthStatus\` for NAS/server metrics or \`healthMonitoring_getClusterHealthStatus\` for Proxmox clusters.
|
||||
|
||||
**"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with container IDs.
|
||||
|
||||
**"Control smart home"** → Use \`smartHome_entityState\` to check a Home Assistant entity, \`smartHome_switchEntity\` to toggle it, or \`smartHome_executeAutomation\` to trigger an automation.
|
||||
|
||||
**"Block/unblock ads"** → Use \`dnsHole_summary\` for Pi-hole/AdGuard stats, \`dnsHole_disable\` to temporarily disable blocking, \`dnsHole_enable\` to re-enable.
|
||||
|
||||
**"Check downloads"** → Use \`downloads_getJobsAndStatuses\` for queue status, \`downloads_pause\`/\`downloads_resume\` to control queues.
|
||||
|
||||
**"Pending media requests"** → Use \`mediaRequests_getLatestRequests\` to see pending/approved/declined requests, \`mediaRequests_answerRequest\` to approve or decline.
|
||||
|
||||
## Tips
|
||||
- Always call \`integration_all\` first to discover available integrations and their IDs before using integration-dependent tools.
|
||||
- The \`integrationId\` parameter in widget tools accepts the ID from \`integration_all\`, not the integration name or kind.
|
||||
- If a tool returns empty results, the user may not have the required integration configured.
|
||||
- Docker and Kubernetes tools work only if the Homarr instance has access to the Docker socket or Kubernetes cluster.`;
|
||||
|
||||
const mcpHandler = createMcpHandler(
|
||||
(server) => {
|
||||
server.server.setRequestHandler(ListToolsRequestSchema, () => ({
|
||||
tools: getTools(),
|
||||
}));
|
||||
|
||||
server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
const tools = getTools();
|
||||
const tool = tools.find((t) => t.name === name);
|
||||
|
||||
if (!tool) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Tool "${name}" not found` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const caller = callerStorage.getStore();
|
||||
if (!caller) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "Authentication context not available" }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const procedure = tool.pathInRouter.reduce<any>((acc, part) => acc?.[part], caller);
|
||||
if (typeof procedure !== "function") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Tool "${name}" is not callable` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const input = args && Object.keys(args).length > 0 ? args : undefined;
|
||||
const result = await procedure(input);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||
};
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(error);
|
||||
logger.warn("MCP tool execution failed", {
|
||||
tool: name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify({ error: message }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
capabilities: { tools: {} },
|
||||
instructions: SERVER_INSTRUCTIONS,
|
||||
serverInfo: {
|
||||
name: "homarr",
|
||||
version: getPackageVersion(),
|
||||
},
|
||||
},
|
||||
{
|
||||
basePath: "/api/mcp",
|
||||
maxDuration: 60,
|
||||
},
|
||||
);
|
||||
|
||||
function extractApiKeyValue(req: NextRequest): string | null {
|
||||
const apiKeyHeader = req.headers.get(API_KEY_HEADER_NAME);
|
||||
if (apiKeyHeader) return apiKeyHeader;
|
||||
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function jsonErrorResponse(status: number, body: Record<string, string>, extraHeaders: Record<string, string> = {}) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...extraHeaders },
|
||||
});
|
||||
}
|
||||
|
||||
const handler = async (req: NextRequest) => {
|
||||
const apiKeyValue = extractApiKeyValue(req);
|
||||
const ipAddress = ipAddressFromHeaders(req.headers);
|
||||
const { ua } = userAgent(req);
|
||||
|
||||
if (!checkAuthRateLimit(ipAddress)) {
|
||||
return jsonErrorResponse(
|
||||
429,
|
||||
{ error: "rate_limited", hint: "Too many failed authentication attempts. Try again later." },
|
||||
{ "Retry-After": "60" },
|
||||
);
|
||||
}
|
||||
|
||||
if (!apiKeyValue) {
|
||||
recordAuthFailure(ipAddress);
|
||||
const baseUrl = extractBaseUrlFromHeaders(req.headers);
|
||||
return jsonErrorResponse(
|
||||
401,
|
||||
{
|
||||
error: "unauthorized",
|
||||
hint: "Authenticate with an ApiKey header or via OAuth at /.well-known/oauth-authorization-server",
|
||||
},
|
||||
{ "WWW-Authenticate": `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"` },
|
||||
);
|
||||
}
|
||||
|
||||
if (!apiKeyValue.includes(".")) {
|
||||
recordAuthFailure(ipAddress);
|
||||
return jsonErrorResponse(401, { error: "invalid_token", hint: "The token must be in the format '<id>.<token>'." });
|
||||
}
|
||||
|
||||
const session = await getSessionFromApiKeyAsync(db, apiKeyValue, ipAddress, ua);
|
||||
|
||||
if (!session) {
|
||||
recordAuthFailure(ipAddress);
|
||||
return jsonErrorResponse(401, {
|
||||
error: "invalid_token",
|
||||
hint: "The API key was not found or the token is incorrect.",
|
||||
});
|
||||
}
|
||||
|
||||
clearAuthFailures(ipAddress);
|
||||
logger.info("MCP request authenticated", { userId: session.user.id });
|
||||
|
||||
const ctx = createTRPCContext({ session, headers: req.headers });
|
||||
const caller = mcpRouter.createCaller(ctx);
|
||||
|
||||
return callerStorage.run(caller, () => mcpHandler(req as unknown as Request));
|
||||
};
|
||||
|
||||
export { handler as GET, handler as POST, handler as DELETE };
|
||||
@@ -0,0 +1,23 @@
|
||||
import { extractToolsFromProcedures } from "trpc-to-mcp";
|
||||
|
||||
import { mcpRouter } from "@homarr/api/mcp";
|
||||
|
||||
let cache: ReturnType<typeof extractToolsFromProcedures> | null = null;
|
||||
|
||||
export function extractMcpTools() {
|
||||
if (cache) return cache;
|
||||
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
if (typeof args[0] === "string" && args[0].includes("[TRPC-TO-MCP]")) return;
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
|
||||
try {
|
||||
cache = extractToolsFromProcedures(mcpRouter);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
|
||||
return cache;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
||||
|
||||
interface OAuthClient {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUris: string[];
|
||||
clientName: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface AuthCode {
|
||||
code: string;
|
||||
clientId: string;
|
||||
userId: string;
|
||||
codeChallenge: string;
|
||||
codeChallengeMethod: string;
|
||||
redirectUri: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface PendingAuth {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
codeChallenge: string;
|
||||
codeChallengeMethod: string;
|
||||
state: string | null;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface OAuthStore {
|
||||
clients: Map<string, OAuthClient>;
|
||||
authCodes: Map<string, AuthCode>;
|
||||
pendingAuths: Map<string, PendingAuth>;
|
||||
cleanupTimer: ReturnType<typeof setInterval> | null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var mcpOAuthStore: OAuthStore | undefined;
|
||||
}
|
||||
|
||||
const store =
|
||||
globalThis.mcpOAuthStore ??
|
||||
(globalThis.mcpOAuthStore = {
|
||||
clients: new Map(),
|
||||
authCodes: new Map(),
|
||||
pendingAuths: new Map(),
|
||||
cleanupTimer: null,
|
||||
});
|
||||
const clients = store.clients;
|
||||
const authCodes = store.authCodes;
|
||||
const pendingAuths = store.pendingAuths;
|
||||
|
||||
const CLEANUP_INTERVAL_MS = 60_000;
|
||||
const AUTH_CODE_TTL_MS = 300_000;
|
||||
const PENDING_AUTH_TTL_MS = 600_000;
|
||||
const CLIENT_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const MAX_CLIENTS = 1000;
|
||||
const MAX_AUTH_CODES = 5000;
|
||||
const MAX_PENDING_AUTHS = 5000;
|
||||
|
||||
if (!store.cleanupTimer) {
|
||||
store.cleanupTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, code] of authCodes) {
|
||||
if (code.expiresAt < now) authCodes.delete(key);
|
||||
}
|
||||
for (const [key, pending] of pendingAuths) {
|
||||
if (pending.expiresAt < now) pendingAuths.delete(key);
|
||||
}
|
||||
for (const [key, client] of clients) {
|
||||
if (client.createdAt + CLIENT_TTL_MS < now) clients.delete(key);
|
||||
}
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
export function registerClient(redirectUris: string[], clientName: string) {
|
||||
if (clients.size >= MAX_CLIENTS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clientId = randomBytes(16).toString("hex");
|
||||
const clientSecret = randomBytes(32).toString("hex");
|
||||
const client: OAuthClient = {
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUris,
|
||||
clientName,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
clients.set(clientId, client);
|
||||
return { clientId, clientSecret };
|
||||
}
|
||||
|
||||
export function getClient(clientId: string) {
|
||||
return clients.get(clientId) ?? null;
|
||||
}
|
||||
|
||||
export function createAuthCode(
|
||||
clientId: string,
|
||||
userId: string,
|
||||
codeChallenge: string,
|
||||
codeChallengeMethod: string,
|
||||
redirectUri: string,
|
||||
) {
|
||||
if (authCodes.size >= MAX_AUTH_CODES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const code = randomBytes(32).toString("hex");
|
||||
authCodes.set(code, {
|
||||
code,
|
||||
clientId,
|
||||
userId,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
redirectUri,
|
||||
expiresAt: Date.now() + AUTH_CODE_TTL_MS,
|
||||
});
|
||||
return code;
|
||||
}
|
||||
|
||||
export function consumeAuthCode(code: string) {
|
||||
const authCode = authCodes.get(code);
|
||||
if (!authCode) return null;
|
||||
authCodes.delete(code);
|
||||
if (authCode.expiresAt < Date.now()) return null;
|
||||
return authCode;
|
||||
}
|
||||
|
||||
export function verifyPkce(codeVerifier: string, codeChallenge: string, method: string) {
|
||||
if (method !== "S256") return false;
|
||||
const computed = createHash("sha256").update(codeVerifier).digest("base64url");
|
||||
if (computed.length !== codeChallenge.length) return false;
|
||||
return timingSafeEqual(Buffer.from(computed), Buffer.from(codeChallenge));
|
||||
}
|
||||
|
||||
export function storePendingAuth(params: Omit<PendingAuth, "expiresAt">) {
|
||||
if (pendingAuths.size >= MAX_PENDING_AUTHS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = randomBytes(16).toString("hex");
|
||||
pendingAuths.set(id, {
|
||||
...params,
|
||||
expiresAt: Date.now() + PENDING_AUTH_TTL_MS,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
export function consumePendingAuth(id: string) {
|
||||
const pending = pendingAuths.get(id);
|
||||
if (!pending) return null;
|
||||
pendingAuths.delete(id);
|
||||
if (pending.expiresAt < Date.now()) return null;
|
||||
return pending;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
|
||||
import { consumePendingAuth, createAuthCode, getClient, storePendingAuth } from "../_store";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const url = new URL(req.url);
|
||||
const session = await auth();
|
||||
|
||||
const pendingId = url.searchParams.get("pending");
|
||||
if (pendingId) {
|
||||
if (!session?.user) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "access_denied",
|
||||
error_description: "Login was not completed",
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const pending = consumePendingAuth(pendingId);
|
||||
if (!pending) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "invalid_request",
|
||||
error_description: "Authorization request expired, please try again",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const code = createAuthCode(
|
||||
pending.clientId,
|
||||
session.user.id,
|
||||
pending.codeChallenge,
|
||||
pending.codeChallengeMethod,
|
||||
pending.redirectUri,
|
||||
);
|
||||
if (!code) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "server_error",
|
||||
error_description: "Server is busy, please try again later",
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const redirect = new URL(pending.redirectUri);
|
||||
redirect.searchParams.set("code", code);
|
||||
if (pending.state) redirect.searchParams.set("state", pending.state);
|
||||
return Response.redirect(redirect.toString());
|
||||
}
|
||||
|
||||
const clientId = url.searchParams.get("client_id");
|
||||
const redirectUri = url.searchParams.get("redirect_uri");
|
||||
const codeChallenge = url.searchParams.get("code_challenge");
|
||||
const codeChallengeMethod = url.searchParams.get("code_challenge_method") ?? "S256";
|
||||
const state = url.searchParams.get("state");
|
||||
|
||||
if (!clientId || !redirectUri || !codeChallenge) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "invalid_request",
|
||||
error_description: "Missing required parameters",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (codeChallengeMethod !== "S256") {
|
||||
return Response.json(
|
||||
{
|
||||
error: "invalid_request",
|
||||
error_description: "Only S256 code_challenge_method is supported",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const client = getClient(clientId);
|
||||
if (!client) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "invalid_client",
|
||||
error_description:
|
||||
"Unknown client_id — the server was restarted and lost the registration. Please re-run the auth command to re-register.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!client.redirectUris.includes(redirectUri)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "invalid_request",
|
||||
error_description: "redirect_uri not registered",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (session?.user) {
|
||||
const code = createAuthCode(clientId, session.user.id, codeChallenge, codeChallengeMethod, redirectUri);
|
||||
if (!code) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "server_error",
|
||||
error_description: "Server is busy, please try again later",
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const redirect = new URL(redirectUri);
|
||||
redirect.searchParams.set("code", code);
|
||||
if (state) redirect.searchParams.set("state", state);
|
||||
return Response.redirect(redirect.toString());
|
||||
}
|
||||
|
||||
const id = storePendingAuth({
|
||||
clientId,
|
||||
redirectUri,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
state,
|
||||
});
|
||||
if (!id) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "server_error",
|
||||
error_description: "Server is busy, please try again later",
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const loginUrl = new URL("/auth/login", url.origin);
|
||||
loginUrl.searchParams.set("callbackUrl", `/api/mcp/oauth/authorize?pending=${id}`);
|
||||
return Response.redirect(loginUrl.toString());
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { registerClient } from "../_store";
|
||||
|
||||
interface RateLimitStore {
|
||||
attempts: Map<string, { count: number; resetAt: number }>;
|
||||
timer: ReturnType<typeof setInterval> | null;
|
||||
}
|
||||
|
||||
const REGISTER_RATE_WINDOW_MS = 60_000;
|
||||
const REGISTER_MAX_ATTEMPTS = 5;
|
||||
|
||||
declare global {
|
||||
var mcpRegisterRateLimit: RateLimitStore | undefined;
|
||||
}
|
||||
|
||||
const store =
|
||||
globalThis.mcpRegisterRateLimit ??
|
||||
(globalThis.mcpRegisterRateLimit = {
|
||||
attempts: new Map(),
|
||||
timer: null,
|
||||
});
|
||||
const registerAttempts = store.attempts;
|
||||
|
||||
if (!store.timer) {
|
||||
store.timer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of registerAttempts) {
|
||||
if (entry.resetAt < now) registerAttempts.delete(key);
|
||||
}
|
||||
}, REGISTER_RATE_WINDOW_MS);
|
||||
store.timer.unref();
|
||||
}
|
||||
|
||||
function getClientIp(req: Request): string {
|
||||
const forwarded = req.headers.get("x-forwarded-for");
|
||||
return forwarded?.split(",")[0]?.trim() ?? "unknown";
|
||||
}
|
||||
|
||||
function isValidRedirectUri(uri: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(uri);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const ip = getClientIp(req);
|
||||
const now = Date.now();
|
||||
const entry = registerAttempts.get(ip);
|
||||
|
||||
if (entry && entry.resetAt >= now && entry.count >= REGISTER_MAX_ATTEMPTS) {
|
||||
return Response.json(
|
||||
{ error: "rate_limited", error_description: "Too many registration attempts, try again later" },
|
||||
{ status: 429, headers: { "Retry-After": "60" } },
|
||||
);
|
||||
}
|
||||
|
||||
const existing = registerAttempts.get(ip);
|
||||
if (!existing || existing.resetAt < now) {
|
||||
registerAttempts.set(ip, { count: 1, resetAt: now + REGISTER_RATE_WINDOW_MS });
|
||||
} else {
|
||||
existing.count++;
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
|
||||
if (!body) {
|
||||
return Response.json({ error: "invalid_request", error_description: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const redirectUris = body.redirect_uris;
|
||||
const clientName = typeof body.client_name === "string" ? body.client_name.slice(0, 256) : "MCP Client";
|
||||
|
||||
if (!Array.isArray(redirectUris) || redirectUris.length === 0 || redirectUris.length > 10) {
|
||||
return Response.json(
|
||||
{ error: "invalid_client_metadata", error_description: "redirect_uris must be an array of 1-10 URIs" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const validUris = redirectUris.filter((uri): uri is string => typeof uri === "string" && isValidRedirectUri(uri));
|
||||
if (validUris.length !== redirectUris.length) {
|
||||
return Response.json(
|
||||
{ error: "invalid_client_metadata", error_description: "All redirect_uris must be valid http or https URLs" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = registerClient(validUris, clientName);
|
||||
if (!result) {
|
||||
return Response.json(
|
||||
{ error: "server_error", error_description: "Too many registered clients, please try again later" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
client_id: result.clientId,
|
||||
client_secret: result.clientSecret,
|
||||
redirect_uris: validUris,
|
||||
client_name: clientName,
|
||||
token_endpoint_auth_method: "none",
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
import { hashPasswordAsync } from "@homarr/auth";
|
||||
import { generateSecureRandomToken } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { db } from "@homarr/db";
|
||||
import { apiKeys } from "@homarr/db/schema";
|
||||
|
||||
import { consumeAuthCode, verifyPkce } from "../_store";
|
||||
|
||||
const logger = createLogger({ module: "mcpOAuthToken" });
|
||||
|
||||
interface RateLimitStore {
|
||||
failures: Map<string, { count: number; resetAt: number }>;
|
||||
timer: ReturnType<typeof setInterval> | null;
|
||||
}
|
||||
|
||||
const TOKEN_RATE_WINDOW_MS = 60_000;
|
||||
const TOKEN_MAX_FAILURES = 10;
|
||||
|
||||
declare global {
|
||||
var mcpTokenRateLimit: RateLimitStore | undefined;
|
||||
}
|
||||
|
||||
const store =
|
||||
globalThis.mcpTokenRateLimit ??
|
||||
(globalThis.mcpTokenRateLimit = {
|
||||
failures: new Map(),
|
||||
timer: null,
|
||||
});
|
||||
const tokenFailures = store.failures;
|
||||
|
||||
if (!store.timer) {
|
||||
store.timer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of tokenFailures) {
|
||||
if (entry.resetAt < now) tokenFailures.delete(key);
|
||||
}
|
||||
}, TOKEN_RATE_WINDOW_MS);
|
||||
store.timer.unref();
|
||||
}
|
||||
|
||||
function getClientIp(req: Request): string {
|
||||
const forwarded = req.headers.get("x-forwarded-for");
|
||||
return forwarded?.split(",")[0]?.trim() ?? "unknown";
|
||||
}
|
||||
|
||||
function recordTokenFailure(ip: string) {
|
||||
const now = Date.now();
|
||||
const existing = tokenFailures.get(ip);
|
||||
if (!existing || existing.resetAt < now) {
|
||||
tokenFailures.set(ip, { count: 1, resetAt: now + TOKEN_RATE_WINDOW_MS });
|
||||
return;
|
||||
}
|
||||
existing.count++;
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const ip = getClientIp(req);
|
||||
const now = Date.now();
|
||||
const entry = tokenFailures.get(ip);
|
||||
|
||||
if (entry && entry.resetAt >= now && entry.count >= TOKEN_MAX_FAILURES) {
|
||||
return Response.json(
|
||||
{ error: "rate_limited", error_description: "Too many requests, try again later" },
|
||||
{ status: 429, headers: { "Retry-After": "60" } },
|
||||
);
|
||||
}
|
||||
|
||||
let params: Record<string, string>;
|
||||
try {
|
||||
const body = await req.formData().catch(() => null);
|
||||
params = body
|
||||
? (Object.fromEntries(body.entries()) as Record<string, string>)
|
||||
: ((await req.json()) as Record<string, string>);
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: "invalid_request", error_description: "Could not parse request body" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { grant_type, code, code_verifier, redirect_uri } = params;
|
||||
|
||||
if (grant_type !== "authorization_code") {
|
||||
return Response.json({ error: "unsupported_grant_type" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!code || !code_verifier) {
|
||||
return Response.json(
|
||||
{ error: "invalid_request", error_description: "Missing code or code_verifier" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (code.length > 256 || code_verifier.length > 256) {
|
||||
return Response.json({ error: "invalid_request", error_description: "Parameter too long" }, { status: 400 });
|
||||
}
|
||||
|
||||
const authCode = consumeAuthCode(code);
|
||||
if (!authCode) {
|
||||
recordTokenFailure(ip);
|
||||
return Response.json(
|
||||
{ error: "invalid_grant", error_description: "Invalid or expired authorization code" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (redirect_uri && redirect_uri !== authCode.redirectUri) {
|
||||
recordTokenFailure(ip);
|
||||
return Response.json({ error: "invalid_grant", error_description: "redirect_uri mismatch" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!verifyPkce(code_verifier, authCode.codeChallenge, authCode.codeChallengeMethod)) {
|
||||
recordTokenFailure(ip);
|
||||
return Response.json({ error: "invalid_grant", error_description: "PKCE verification failed" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const id = randomBytes(4).toString("hex");
|
||||
const randomToken = generateSecureRandomToken(24);
|
||||
const hashedToken = await hashPasswordAsync(randomToken);
|
||||
|
||||
await db.insert(apiKeys).values({
|
||||
id,
|
||||
apiKey: hashedToken,
|
||||
userId: authCode.userId,
|
||||
});
|
||||
|
||||
const accessToken = `${id}.${randomToken}`;
|
||||
|
||||
return Response.json({
|
||||
access_token: accessToken,
|
||||
token_type: "Bearer",
|
||||
scope: "mcp:tools",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to create API key during OAuth token exchange", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return Response.json({ error: "server_error", error_description: "Failed to issue access token" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,8 @@ const handler = auth(async (req) => {
|
||||
const response = await fetchRequestHandler({
|
||||
endpoint: trpcPath,
|
||||
router: appRouter,
|
||||
req,
|
||||
// mcp-handler globally augments Request with `auth?: AuthInfo`, conflicting with NextAuth's `auth: Session | null`
|
||||
req: req as unknown as Request,
|
||||
createContext: () => createTRPCContext({ session, headers: req.headers }),
|
||||
onError({ error, path, type }) {
|
||||
logger.error(new ErrorWithMetadata("tRPC Error occured", { path, type }, { cause: error }));
|
||||
|
||||
Reference in new issue
Block a user