Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f59c8a993a |
No files matched your search
@@ -63,6 +63,8 @@ UNSAFE_ENABLE_MOCK_INTEGRATION=true
|
|||||||
|
|
||||||
# Public URL contract. Values must be absolute HTTP(S) URLs without credentials,
|
# Public URL contract. Values must be absolute HTTP(S) URLs without credentials,
|
||||||
# query strings, or fragments. Trailing slashes are normalized at startup.
|
# query strings, or fragments. Trailing slashes are normalized at startup.
|
||||||
|
# Public Homarr origin used by MCP OAuth metadata and redirects behind proxies.
|
||||||
|
# BASE_URL=https://homarr.example.com
|
||||||
HOMARR_WEBSITE_URL=https://homarr.dev
|
HOMARR_WEBSITE_URL=https://homarr.dev
|
||||||
# PocketBase API used by Homarr and by the standalone documentation site.
|
# PocketBase API used by Homarr and by the standalone documentation site.
|
||||||
# Defaults to HOMARR_WEBSITE_URL.
|
# Defaults to HOMARR_WEBSITE_URL.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ Using the `PUID` and `PGID` will require you to set the correct permissions on t
|
|||||||
| `PUID` | User ID to run the container as | Any valid user ID | `0` |
|
| `PUID` | User ID to run the container as | Any valid user ID | `0` |
|
||||||
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
|
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
|
||||||
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
|
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
|
||||||
|
| `BASE_URL` | Public Homarr origin used by MCP OAuth metadata and redirects. Set this when a reverse proxy does not preserve the public host and protocol. | HTTP(S) origin without a path | - |
|
||||||
| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` |
|
| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` |
|
||||||
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
|
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
|
||||||
| `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` |
|
| `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` |
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./homarr/appdata:/appdata
|
- ./homarr/appdata:/appdata
|
||||||
environment:
|
environment:
|
||||||
- BASE_URL=your.internal.dns.address.here.com
|
- BASE_URL=https://your.internal.dns.address.here.com
|
||||||
- SECRET_ENCRYPTION_KEY=your_64_character_hex_string # <--- can be generated with `openssl rand -hex 32`
|
- SECRET_ENCRYPTION_KEY=your_64_character_hex_string # <--- can be generated with `openssl rand -hex 32`
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ The **AI / MCP** tab on Homarr's API page shows the current endpoint, configurat
|
|||||||
MCP supports API keys and OAuth 2.1 with PKCE. API keys are simpler for direct client configuration; OAuth is available
|
MCP supports API keys and OAuth 2.1 with PKCE. API keys are simpler for direct client configuration; OAuth is available
|
||||||
for clients that support dynamic authorization.
|
for clients that support dynamic authorization.
|
||||||
|
|
||||||
|
When Homarr is behind a reverse proxy that does not preserve the public host and protocol, set `BASE_URL` to the exact
|
||||||
|
public origin, for example `https://homarr.example.com`. Do not include a path. OAuth clients discover the authorization
|
||||||
|
server and protected resource through Homarr's `/.well-known/` routes.
|
||||||
|
|
||||||
Tools use the permissions of the API key owner or authorized user. Creating a key does not grant additional access.
|
Tools use the permissions of the API key owner or authorized user. Creating a key does not grant additional access.
|
||||||
Custom Widget and Workshop authoring tools, prompts, and resources are visible only to administrators.
|
Custom Widget and Workshop authoring tools, prompts, and resources are visible only to administrators.
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,15 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{ source: "/.well-known/:path*", destination: "/well-known/:path*" },
|
||||||
|
{
|
||||||
|
source: "/api/mcp/:transport/.well-known/:path*",
|
||||||
|
destination: "/well-known/:path*/api/mcp/:transport",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
// skipcq: JS-0116
|
// skipcq: JS-0116
|
||||||
// eslint-disable-next-line @typescript-eslint/require-await,no-restricted-syntax
|
// eslint-disable-next-line @typescript-eslint/require-await,no-restricted-syntax
|
||||||
async headers() {
|
async headers() {
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
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"],
|
|
||||||
resource_indicators_supported: true,
|
|
||||||
},
|
|
||||||
{ headers: corsHeaders },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function OPTIONS() {
|
|
||||||
return new Response(null, { status: 204, headers: corsHeaders });
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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}/api/mcp`,
|
|
||||||
authorization_servers: [baseUrl],
|
|
||||||
},
|
|
||||||
{ headers: corsHeaders },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function OPTIONS() {
|
|
||||||
return new Response(null, { status: 204, headers: corsHeaders });
|
|
||||||
}
|
|
||||||
@@ -22,7 +22,10 @@ vi.mock("@homarr/auth/api-key", () => ({
|
|||||||
API_KEY_HEADER_NAME: "ApiKey",
|
API_KEY_HEADER_NAME: "ApiKey",
|
||||||
getSessionFromApiKeyAsync: mocks.authenticate,
|
getSessionFromApiKeyAsync: mocks.authenticate,
|
||||||
}));
|
}));
|
||||||
vi.mock("@homarr/common", () => ({ extractBaseUrlFromHeaders: () => "http://localhost" }));
|
vi.mock("@homarr/common", () => ({
|
||||||
|
extractBaseUrlFromHeaders: () => "http://localhost",
|
||||||
|
removeTrailingSlash: (value: string) => value.replace(/\/+$/u, ""),
|
||||||
|
}));
|
||||||
vi.mock("@homarr/common/server", () => ({ ipAddressFromHeaders: mocks.ipAddress }));
|
vi.mock("@homarr/common/server", () => ({ ipAddressFromHeaders: mocks.ipAddress }));
|
||||||
vi.mock("@homarr/core/infrastructure/logs", () => ({
|
vi.mock("@homarr/core/infrastructure/logs", () => ({
|
||||||
createLogger: () => ({ info: vi.fn(), warn: mocks.loggerWarn, error: vi.fn() }),
|
createLogger: () => ({ info: vi.fn(), warn: mocks.loggerWarn, error: vi.fn() }),
|
||||||
@@ -42,6 +45,7 @@ vi.mock("@homarr/custom-widgets/core", () => ({
|
|||||||
getCustomWidgetJsonSchema: () => ({ type: "object", title: "Custom Widget" }),
|
getCustomWidgetJsonSchema: () => ({ type: "object", title: "Custom Widget" }),
|
||||||
}));
|
}));
|
||||||
vi.mock("@homarr/db", () => ({ db: {} }));
|
vi.mock("@homarr/db", () => ({ db: {} }));
|
||||||
|
vi.mock("~/env", () => ({ env: { BASE_URL: undefined } }));
|
||||||
vi.mock("~/versions/package-reader", () => ({ getPackageVersion: () => "test-version" }));
|
vi.mock("~/versions/package-reader", () => ({ getPackageVersion: () => "test-version" }));
|
||||||
vi.mock("../_extract-tools", () => ({
|
vi.mock("../_extract-tools", () => ({
|
||||||
extractMcpTools: () => [
|
extractMcpTools: () => [
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { extractBaseUrlFromHeaders, removeTrailingSlash } from "@homarr/common";
|
||||||
|
|
||||||
|
import { env } from "~/env";
|
||||||
|
|
||||||
|
type RequestHeaders = Parameters<typeof extractBaseUrlFromHeaders>[0];
|
||||||
|
|
||||||
|
export const getMcpBaseUrl = (headers: RequestHeaders) =>
|
||||||
|
removeTrailingSlash(env.BASE_URL ?? extractBaseUrlFromHeaders(headers));
|
||||||
@@ -7,7 +7,7 @@ import { z } from "zod/v4";
|
|||||||
import type { McpTool } from "@homarr/api/mcp";
|
import type { McpTool } from "@homarr/api/mcp";
|
||||||
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
|
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
|
||||||
import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key";
|
import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key";
|
||||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
import { removeTrailingSlash } from "@homarr/common";
|
||||||
import { ipAddressFromHeaders } from "@homarr/common/server";
|
import { ipAddressFromHeaders } from "@homarr/common/server";
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { buildCustomWidgetMcpPrompt } from "@homarr/custom-widgets/authoring-prompt";
|
import { buildCustomWidgetMcpPrompt } from "@homarr/custom-widgets/authoring-prompt";
|
||||||
@@ -24,6 +24,7 @@ import { db } from "@homarr/db";
|
|||||||
|
|
||||||
import { getPackageVersion } from "~/versions/package-reader";
|
import { getPackageVersion } from "~/versions/package-reader";
|
||||||
import { getSafeAssistantToolError } from "../assistant/chat/assistant-tool-error";
|
import { getSafeAssistantToolError } from "../assistant/chat/assistant-tool-error";
|
||||||
|
import { getMcpBaseUrl } from "./_base-url";
|
||||||
import { extractMcpTools } from "./_extract-tools";
|
import { extractMcpTools } from "./_extract-tools";
|
||||||
import { createMcpProtocolHandler } from "./_protocol";
|
import { createMcpProtocolHandler } from "./_protocol";
|
||||||
|
|
||||||
@@ -246,6 +247,12 @@ function jsonErrorResponse(status: number, body: Record<string, string>, extraHe
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getOAuthChallenge = (req: NextRequest) => {
|
||||||
|
const baseUrl = getMcpBaseUrl(req.headers);
|
||||||
|
const resourcePath = removeTrailingSlash(new URL(req.url).pathname);
|
||||||
|
return `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource${resourcePath}"`;
|
||||||
|
};
|
||||||
|
|
||||||
export const handleMcpRequest = async (req: NextRequest) => {
|
export const handleMcpRequest = async (req: NextRequest) => {
|
||||||
const pathname = new URL(req.url).pathname;
|
const pathname = new URL(req.url).pathname;
|
||||||
if (pathname.endsWith("/sse") || pathname.endsWith("/message")) {
|
if (pathname.endsWith("/sse") || pathname.endsWith("/message")) {
|
||||||
@@ -269,30 +276,37 @@ export const handleMcpRequest = async (req: NextRequest) => {
|
|||||||
|
|
||||||
if (!apiKeyValue) {
|
if (!apiKeyValue) {
|
||||||
recordAuthFailure(ipAddress);
|
recordAuthFailure(ipAddress);
|
||||||
const baseUrl = extractBaseUrlFromHeaders(req.headers);
|
|
||||||
return jsonErrorResponse(
|
return jsonErrorResponse(
|
||||||
401,
|
401,
|
||||||
{
|
{
|
||||||
error: "unauthorized",
|
error: "unauthorized",
|
||||||
hint: "Authenticate with an ApiKey header or via OAuth at /.well-known/oauth-authorization-server",
|
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"` },
|
{ "WWW-Authenticate": getOAuthChallenge(req) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!apiKeyValue.includes(".")) {
|
if (!apiKeyValue.includes(".")) {
|
||||||
recordAuthFailure(ipAddress);
|
recordAuthFailure(ipAddress);
|
||||||
return jsonErrorResponse(401, { error: "invalid_token", hint: "The token must be in the format '<id>.<token>'." });
|
return jsonErrorResponse(
|
||||||
|
401,
|
||||||
|
{ error: "invalid_token", hint: "The token must be in the format '<id>.<token>'." },
|
||||||
|
{ "WWW-Authenticate": getOAuthChallenge(req) },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await getSessionFromApiKeyAsync(db, apiKeyValue, ipAddress, ua);
|
const session = await getSessionFromApiKeyAsync(db, apiKeyValue, ipAddress, ua);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
recordAuthFailure(ipAddress);
|
recordAuthFailure(ipAddress);
|
||||||
return jsonErrorResponse(401, {
|
return jsonErrorResponse(
|
||||||
error: "invalid_token",
|
401,
|
||||||
hint: "The API key was not found or the token is incorrect.",
|
{
|
||||||
});
|
error: "invalid_token",
|
||||||
|
hint: "The API key was not found or the token is incorrect.",
|
||||||
|
},
|
||||||
|
{ "WWW-Authenticate": getOAuthChallenge(req) },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearAuthFailures(ipAddress);
|
clearAuthFailures(ipAddress);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
|
||||||
|
|
||||||
|
import { getMcpBaseUrl } from "../../_base-url";
|
||||||
import { consumePendingAuth, createAuthCode, getClient, storePendingAuth } from "../_store";
|
import { consumePendingAuth, createAuthCode, getClient, storePendingAuth } from "../_store";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
@@ -83,8 +83,9 @@ export async function GET(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedResource = `${extractBaseUrlFromHeaders(req.headers)}/api/mcp`;
|
const baseUrl = getMcpBaseUrl(req.headers);
|
||||||
if (resource && resource !== expectedResource) {
|
const allowedResources = new Set([`${baseUrl}/api/mcp`, `${baseUrl}/api/mcp/mcp`]);
|
||||||
|
if (resource && !allowedResources.has(resource)) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
error: "invalid_target",
|
error: "invalid_target",
|
||||||
@@ -152,7 +153,7 @@ export async function GET(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginUrl = new URL("/auth/login", url.origin);
|
const loginUrl = new URL("/auth/login", baseUrl);
|
||||||
loginUrl.searchParams.set("callbackUrl", `/api/mcp/oauth/authorize?pending=${id}`);
|
loginUrl.searchParams.set("callbackUrl", `/api/mcp/oauth/authorize?pending=${id}`);
|
||||||
return Response.redirect(loginUrl.toString());
|
return Response.redirect(loginUrl.toString());
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { getMcpBaseUrl } from "~/app/api/mcp/_base-url";
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "*",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, props: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const [kind, ...rest] = (await props.params).path;
|
||||||
|
const baseUrl = getMcpBaseUrl(req.headers);
|
||||||
|
const identifierPath = rest.join("/");
|
||||||
|
|
||||||
|
if (kind === "oauth-authorization-server") {
|
||||||
|
const issuer = identifierPath ? `${baseUrl}/${identifierPath}` : baseUrl;
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
issuer,
|
||||||
|
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"],
|
||||||
|
resource_indicators_supported: true,
|
||||||
|
},
|
||||||
|
{ headers: corsHeaders },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "oauth-protected-resource") {
|
||||||
|
const resource = identifierPath ? `${baseUrl}/${identifierPath}` : `${baseUrl}/api/mcp`;
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
resource,
|
||||||
|
authorization_servers: [baseUrl],
|
||||||
|
},
|
||||||
|
{ headers: corsHeaders },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ error: "not_found" }, { status: 404, headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function OPTIONS() {
|
||||||
|
return new Response(null, { status: 204, headers: corsHeaders });
|
||||||
|
}
|
||||||
@@ -16,8 +16,15 @@ const publicHttpUrl = (variableName: string) =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const publicHttpOrigin = (variableName: string) =>
|
||||||
|
publicHttpUrl(variableName).refine(
|
||||||
|
(value) => value === new URL(value).origin,
|
||||||
|
`${variableName} must be an HTTP(S) origin without a path`,
|
||||||
|
);
|
||||||
|
|
||||||
export const env = createEnv({
|
export const env = createEnv({
|
||||||
server: {
|
server: {
|
||||||
|
BASE_URL: publicHttpOrigin("BASE_URL").optional(),
|
||||||
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
|
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
|
||||||
DEMO_MODE: createBooleanSchema(false),
|
DEMO_MODE: createBooleanSchema(false),
|
||||||
DEMO_READ_ONLY: createBooleanSchema(true),
|
DEMO_READ_ONLY: createBooleanSchema(true),
|
||||||
|
|||||||
Reference in new issue
Block a user