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,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