diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index bc12193f0..e97156ec0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -33,6 +33,7 @@ body: options: # The below comment is used to insert a new version with on-release.yml #NEXT_VERSION# + - 1.76.1 - 1.76.0 - 1.75.0 - 1.74.0 diff --git a/apps/nextjs/src/app/api/mcp/[transport]/route.ts b/apps/nextjs/src/app/api/mcp/[transport]/route.ts index 364fe90c5..f6a303a86 100644 --- a/apps/nextjs/src/app/api/mcp/[transport]/route.ts +++ b/apps/nextjs/src/app/api/mcp/[transport]/route.ts @@ -14,6 +14,7 @@ import { db } from "@homarr/db"; import { getPackageVersion } from "~/versions/package-reader"; import { extractMcpTools } from "../_extract-tools"; +import { serializeMcpToolResult } from "../_serialize-result"; const logger = createLogger({ module: "mcpRoute" }); @@ -203,7 +204,7 @@ const mcpHandler = createMcpHandler( 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) }], + content: [{ type: "text" as const, text: serializeMcpToolResult(result) }], }; } catch (error) { const message = sanitizeErrorMessage(error); diff --git a/apps/nextjs/src/app/api/mcp/_serialize-result.spec.ts b/apps/nextjs/src/app/api/mcp/_serialize-result.spec.ts new file mode 100644 index 000000000..8ee8df795 --- /dev/null +++ b/apps/nextjs/src/app/api/mcp/_serialize-result.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { serializeMcpToolResult } from "./_serialize-result"; + +describe("serializeMcpToolResult", () => { + it("serializes objects as JSON strings", () => { + expect(serializeMcpToolResult({ boardId: "1" })).toBe('{"boardId":"1"}'); + }); + + it("serializes primitive values", () => { + expect(serializeMcpToolResult("hello")).toBe('"hello"'); + expect(serializeMcpToolResult(42)).toBe("42"); + expect(serializeMcpToolResult(true)).toBe("true"); + }); + + it("serializes null as the string null", () => { + expect(serializeMcpToolResult(null)).toBe("null"); + }); + + it("serializes void results as the string null so MCP content text is always a string", () => { + expect(serializeMcpToolResult(undefined)).toBe("null"); + }); +}); diff --git a/apps/nextjs/src/app/api/mcp/_serialize-result.ts b/apps/nextjs/src/app/api/mcp/_serialize-result.ts new file mode 100644 index 000000000..f49492062 --- /dev/null +++ b/apps/nextjs/src/app/api/mcp/_serialize-result.ts @@ -0,0 +1,3 @@ +export function serializeMcpToolResult(result: unknown): string { + return JSON.stringify(result) ?? "null"; +} diff --git a/nginx.conf b/nginx.conf index 7754b4087..a433abfd9 100644 --- a/nginx.conf +++ b/nginx.conf @@ -9,7 +9,7 @@ http { # Route websockets traffic to port 3001 location /websockets { - proxy_pass http://${HOSTNAME}:3001; + proxy_pass http://127.0.0.1:3001; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; @@ -18,7 +18,7 @@ http { # Route all other traffic to port 3000 location / { - proxy_pass http://${HOSTNAME}:3000; + proxy_pass http://127.0.0.1:3000; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/packages/api/src/shared.ts b/packages/api/src/shared.ts index e05700f22..9237a37a9 100644 --- a/packages/api/src/shared.ts +++ b/packages/api/src/shared.ts @@ -43,7 +43,7 @@ async function importCookiesAsync() { function getBaseUrl() { if (typeof window !== "undefined") return window.location.origin; - return `http://${process.env.HOSTNAME ?? "localhost"}:3000`; + return "http://127.0.0.1:3000"; } export const trpcPath = "/api/trpc"; diff --git a/packages/core/src/infrastructure/http/index.ts b/packages/core/src/infrastructure/http/index.ts index 116cf5971..572388f2a 100644 --- a/packages/core/src/infrastructure/http/index.ts +++ b/packages/core/src/infrastructure/http/index.ts @@ -5,5 +5,4 @@ export { createCustomCheckServerIdentity, createHttpsAgentAsync, fetchWithTrustedCertificatesAsync, - mergeHeadersWithUserAgent, } from "./request"; diff --git a/packages/core/src/infrastructure/http/request.ts b/packages/core/src/infrastructure/http/request.ts index 0b06b2725..44e0aa629 100644 --- a/packages/core/src/infrastructure/http/request.ts +++ b/packages/core/src/infrastructure/http/request.ts @@ -13,19 +13,6 @@ import { UndiciHttpAgent } from "@homarr/core/infrastructure/http"; import type { TrustedCertificateHostname } from "../certificates/hostnames"; import { withTimeoutAsync } from "./timeout"; - -import packageJson from "../../../../../package.json"; - -export const getDefaultUserAgent = () => `Homarr/${packageJson.version} (+https://homarr.dev)`; - -export const mergeHeadersWithUserAgent = (headers: unknown): Headers => { - const mergedHeaders = new Headers(headers as HeadersInit); - if (!mergedHeaders.has("user-agent")) { - mergedHeaders.set("user-agent", getDefaultUserAgent()); - } - return mergedHeaders; -}; - export const createCustomCheckServerIdentity = ( trustedHostnames: TrustedCertificateHostname[], ): typeof checkServerIdentity => { @@ -85,18 +72,22 @@ export const fetchWithTrustedCertificatesAsync = async ( undefined, options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined, )); - const buildFetchOptions = (fetchOptions: Omit) => ({ - ...fetchOptions, - headers: mergeHeadersWithUserAgent(fetchOptions.headers) as unknown as RequestInit["headers"], - }); if (options?.timeout) { const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options; return await withTimeoutAsync( - async (signal) => fetch(url, { ...buildFetchOptions(fetchOptions), signal, dispatcher: agent }), + async (signal) => + fetch(url, { + ...fetchOptions, + signal, + dispatcher: agent, + }), options.timeout, ); } const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options ?? {}; - return fetch(url, { ...buildFetchOptions(fetchOptions), dispatcher: agent }); + return fetch(url, { + ...fetchOptions, + dispatcher: agent, + }); }; diff --git a/packages/core/src/test/infrastructure/http/request.spec.ts b/packages/core/src/test/infrastructure/http/request.spec.ts deleted file mode 100644 index e1980862d..000000000 --- a/packages/core/src/test/infrastructure/http/request.spec.ts +++ /dev/null @@ -1,120 +0,0 @@ -// @vitest-environment node - -import { createServer } from "node:http"; -import type { Server } from "node:http"; -import type { AddressInfo } from "node:net"; -import type { RequestInit } from "undici"; - -import { describe, expect, test, vi } from "vitest"; - -import { fetchWithTrustedCertificatesAsync, mergeHeadersWithUserAgent } from "@homarr/core/infrastructure/http"; - -vi.mock("@homarr/core/infrastructure/certificates", () => ({ - getAllTrustedCertificatesAsync: vi.fn(async () => []), - getTrustedCertificateHostnamesAsync: vi.fn(async () => []), -})); - -describe("mergeHeadersWithUserAgent", () => { - test("should keep existing headers when given a Headers instance", () => { - // Arrange - const headers = new Headers({ "x-api-key": "some-api-key", Accept: "application/json" }); - - // Act - const merged = mergeHeadersWithUserAgent(headers); - - // Assert - expect(merged.get("x-api-key")).toBe("some-api-key"); - expect(merged.get("accept")).toBe("application/json"); - expect(merged.get("user-agent")).toContain("Homarr/"); - }); - - test("should keep existing headers when given a record", () => { - // Arrange - const headers = { "x-api-key": "some-api-key", Accept: "application/json" }; - - // Act - const merged = mergeHeadersWithUserAgent(headers); - - // Assert - expect(merged.get("x-api-key")).toBe("some-api-key"); - expect(merged.get("accept")).toBe("application/json"); - expect(merged.get("user-agent")).toContain("Homarr/"); - }); - - test("should keep existing headers when given a tuple array", () => { - // Arrange - const headers: [string, string][] = [ - ["x-api-key", "some-api-key"], - ["Accept", "application/json"], - ]; - - // Act - const merged = mergeHeadersWithUserAgent(headers); - - // Assert - expect(merged.get("x-api-key")).toBe("some-api-key"); - expect(merged.get("accept")).toBe("application/json"); - expect(merged.get("user-agent")).toContain("Homarr/"); - }); - - test("should not override an existing user-agent in any casing", () => { - // Arrange - const headers = new Headers({ "USER-AGENT": "custom-agent" }); - - // Act - const merged = mergeHeadersWithUserAgent(headers); - - // Assert - expect(merged.get("user-agent")).toBe("custom-agent"); - }); - - test("should fall back to the default user agent when no headers are provided", () => { - // Act - const merged = mergeHeadersWithUserAgent(undefined); - - // Assert - expect(merged.get("user-agent")).toContain("Homarr/"); - }); -}); - -const startCaptureServerAsync = async (): Promise<{ - server: Server; - getReceivedHeaders: () => Record; - port: number; -}> => { - let receivedHeaders: Record = {}; - const server = createServer((req, res) => { - receivedHeaders = req.headers; - res.writeHead(200, { "Content-Type": "application/json" }); - res.end("{}"); - }); - await new Promise((resolve) => server.listen(0, resolve)); - const port = (server.address() as AddressInfo).port; - return { - server, - port, - getReceivedHeaders: () => receivedHeaders, - }; -}; - -describe("fetchWithTrustedCertificatesAsync", () => { - test("should deliver a Headers instance with all headers to the remote server", async () => { - // Arrange - the @immich/sdk hands over a Headers instance after merging its defaults - const { server, port, getReceivedHeaders } = await startCaptureServerAsync(); - const headers = new Headers({ "x-api-key": "immich-api-key", Accept: "application/json" }); - - // Act - try { - const response = await fetchWithTrustedCertificatesAsync(`http://127.0.0.1:${port}/api/server/statistics`, { - headers: headers as unknown as RequestInit["headers"], - }); - - // Assert - expect(response.status).toBe(200); - expect(getReceivedHeaders()["x-api-key"]).toBe("immich-api-key"); - expect(getReceivedHeaders()["user-agent"]).toContain("Homarr/"); - } finally { - server.close(); - } - }); -}); diff --git a/scripts/run.sh b/scripts/run.sh index 6357c723c..efcc50f9f 100644 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -21,20 +21,18 @@ fi export AUTH_SECRET=$(openssl rand -base64 32) # Start nginx proxy -# 1. Replace the HOSTNAME in the nginx template file -# 2. Create the nginx configuration file from the template -# 3. Start the nginx server +# 1. Create the nginx configuration file from the template +# 2. Start the nginx server # Only listen on IPv6 when the host actually has IPv6 configured (#4596). # Otherwise nginx aborts with "socket() [::]:7575 failed (97: Address family # not supported by protocol)". Check HOMARR_DISABLE_IPV6 first so users can # force IPv4-only even when the host supports IPv6, then probe for IPv6 # addresses via grep (not -s: proc files always report size 0). -export HOSTNAME export NGINX_LISTEN_IPV6='' if [ "${HOMARR_DISABLE_IPV6:-false}" != "true" ] && grep -q . /proc/net/if_inet6 2>/dev/null; then NGINX_LISTEN_IPV6='listen [::]:7575;' fi -envsubst '${HOSTNAME} ${NGINX_LISTEN_IPV6}' < /etc/nginx/templates/nginx.conf > /etc/nginx/nginx.conf +envsubst '${NGINX_LISTEN_IPV6}' < /etc/nginx/templates/nginx.conf > /etc/nginx/nginx.conf # Start services in the background and store their PIDs nginx -g 'daemon off;' & NGINX_PID=$! @@ -67,6 +65,9 @@ terminate() { trap terminate TERM INT +# Next.js standalone uses HOSTNAME as its bind address. Docker's generated +# hostname can be unresolvable, so bind explicitly while nginx uses loopback. +export HOSTNAME=0.0.0.0 node apps/nextjs/server.js & NEXTJS_PID=$!