Compare commits

..
Author SHA1 Message Date
ajnart acb58c60a3 style: auto-fix lint and format 2026-08-25 05:44:48 +00:00
ajnart 5f84ce590b fix(mcp): return valid string for void tool results
JSON.stringify(undefined) returns the value undefined, not a string,
so void-returning MCP tools produced content blocks with text: undefined
causing clients to reject the call with -32602 despite the action running.

Serialize to 'null' as a fallback so the text field is always a string.
2026-08-25 07:43:22 +02:00
Thomas a0149d5894 Merge pull request #6673 from homarr-labs/investigate-6672-immich-401 2026-08-22 19:44:03 +02:00
ajnart 8579fcbfc2 test(http,immich): address CodeRabbit review
- guarantee server teardown via try/finally in both wire-level tests
- add tuple-array HeadersInit coverage to the helper suite
2026-08-22 17:21:38 +02:00
ajnart b6f3aba23e test(http,immich): prove User-Agent fix at the wire level
Add two regression tests that fail on v1.76.0 and pass with the fix:
- wire-level test: a Headers instance (the shape @immich/sdk hands over)
  must reach the remote server with all headers intact
- real-SDK e2e: getServerStatistics through fetchWithTrustedCertificatesAsync
  against a live local server must deliver x-api-key
2026-08-22 17:16:31 +02:00
ajnart 31e218295d fix(http): preserve SDK headers when injecting User-Agent
The v1.76.0 User-Agent injection object-spread `fetchOptions.headers`, which
silently drops all headers when the value is a Headers instance. The Immich
SDK passes a Headers object with the merged x-api-key header, so requests
lost their API key and returned 401 (issue #6672). Build a real Headers
instance instead, keeping case-insensitive user-agent handling.
2026-08-22 17:12:43 +02:00
14 changed files with 221 additions and 224 deletions

No files matched your search

@@ -30,7 +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` |
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, location search, 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` |
| `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` |
+14 -16
View File
@@ -1,22 +1,26 @@
---
title: "Weather"
description: "Displays the current weather information of a set location."
title: 'Weather'
description: 'Displays the current weather information of a set location.'
hide_title: true
---
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { weatherWidget } from ".";
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header';
import { weatherWidget } from '.';
<WidgetHeader widget={weatherWidget} categories={["Weather"]} />
<WidgetHeader
widget={weatherWidget}
categories={["Weather"]}
/>
The Weather Widget uses your location to display the current, highest and lowest temperature.
### Screenshots
import forecastWeather from "./img/forecast.png";
import currentWeather from "./img/current.png";
import forecastWeather from './img/forecast.png';
import currentWeather from './img/current.png';
<div className="flex flex-col gap-1">
<img src={forecastWeather} alt="weather forecast-5-days" width={128 * 4.5} />
@@ -28,15 +32,9 @@ import currentWeather from "./img/current.png";
<AddingWidget />
### Configuration
<WidgetConfig configuration={weatherWidget.configuration} />
### Data source
We use the Open Meteo API to fetch the weather and locations.
If you need to whitelist the api, please use the following URL: `https://open-meteo.com`
:::note
When the `NO_EXTERNAL_CONNECTION` environment variable is enabled, the widget (and the city
search inside its configuration) cannot load any data from Open Meteo.
:::
If you need to whitelist the api, please use the following URL: `https://open-meteo.com`
@@ -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);
@@ -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");
});
});
@@ -0,0 +1,3 @@
export function serializeMcpToolResult(result: unknown): string {
return JSON.stringify(result) ?? "null";
}
-3
View File
@@ -1,6 +1,5 @@
import { z } from "zod/v4";
import { env } from "@homarr/common/env";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
@@ -38,8 +37,6 @@ export const locationRouter = createTRPCRouter({
.input(locationSearchCityInput)
.output(locationSearchCityOutput)
.query(async ({ input }) => {
if (env.NO_EXTERNAL_CONNECTION) return { results: [] };
const res = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(
`https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`,
@@ -1,80 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
const mocks = vi.hoisted(() => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
env: { NO_EXTERNAL_CONNECTION: false },
}));
vi.mock("@homarr/common/env", () => ({
env: mocks.env,
}));
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: mocks.fetchWithTrustedCertificatesAsync,
}));
vi.mock("@homarr/core/infrastructure/http/timeout", () => ({
withTimeoutAsync: async (callback: (signal: AbortSignal) => Promise<Response>) => {
return await callback(new AbortController().signal);
},
}));
import { locationRouter } from "../location";
const createJsonResponse = (body: unknown) =>
new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
const createCaller = () => locationRouter.createCaller({ db: createDb(), deviceType: undefined, session: null });
afterEach(() => {
mocks.env.NO_EXTERNAL_CONNECTION = false;
mocks.fetchWithTrustedCertificatesAsync.mockReset();
});
describe("locationRouter.searchCity", () => {
test("should return empty results when external connections are disabled", async () => {
// Arrange
mocks.env.NO_EXTERNAL_CONNECTION = true;
const caller = createCaller();
// Act
const result = await caller.searchCity({ query: "Paris" });
// Assert
expect(result.results).toEqual([]);
expect(mocks.fetchWithTrustedCertificatesAsync).not.toHaveBeenCalled();
});
test("should fetch the geocoding API when external connections are enabled", async () => {
// Arrange
mocks.env.NO_EXTERNAL_CONNECTION = false;
mocks.fetchWithTrustedCertificatesAsync.mockResolvedValue(
createJsonResponse({
results: [
{
id: 1,
name: "Paris",
country: "France",
country_code: "FR",
latitude: 48.85341,
longitude: 2.3488,
population: 2138551,
},
],
}),
);
const caller = createCaller();
// Act
const result = await caller.searchCity({ query: "Paris" });
// Assert
expect(result.results[0]?.name).toBe("Paris");
expect(mocks.fetchWithTrustedCertificatesAsync).toHaveBeenCalledWith(
expect.stringContaining("geocoding-api.open-meteo.com"),
expect.anything(),
);
});
});
@@ -1,64 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
const mocks = vi.hoisted(() => ({
getDataAsync: vi.fn(),
handler: vi.fn(),
env: { NO_EXTERNAL_CONNECTION: false },
}));
vi.mock("@homarr/common/env", () => ({
env: mocks.env,
}));
vi.mock("@homarr/request-handler/weather", () => ({
weatherRequestHandler: {
handler: mocks.handler,
},
}));
import { weatherRouter } from "../../widgets/weather";
const weatherData = {
current: { weathercode: 0, temperature: 20, windspeed: 10 },
daily: [],
};
const createCaller = () => weatherRouter.createCaller({ db: createDb(), deviceType: undefined, session: null });
afterEach(() => {
mocks.env.NO_EXTERNAL_CONNECTION = false;
mocks.handler.mockReset();
mocks.getDataAsync.mockReset();
});
describe("weatherRouter.atLocation", () => {
test("should return null when external connections are disabled", async () => {
// Arrange
mocks.env.NO_EXTERNAL_CONNECTION = true;
const caller = createCaller();
// Act
const result = await caller.atLocation({ latitude: 48.85341, longitude: 2.3488 });
// Assert
expect(result).toBeNull();
expect(mocks.handler).not.toHaveBeenCalled();
});
test("should return the weather data when external connections are enabled", async () => {
// Arrange
mocks.env.NO_EXTERNAL_CONNECTION = false;
mocks.handler.mockReturnValue({ getDataAsync: mocks.getDataAsync });
mocks.getDataAsync.mockResolvedValue({ data: weatherData, timestamp: new Date() });
const caller = createCaller();
// Act
const result = await caller.atLocation({ latitude: 48.85341, longitude: 2.3488 });
// Assert
expect(result).toEqual(weatherData);
expect(mocks.handler).toHaveBeenCalledWith({ latitude: 48.85341, longitude: 2.3488 });
});
});
@@ -5,4 +5,5 @@ export {
createCustomCheckServerIdentity,
createHttpsAgentAsync,
fetchWithTrustedCertificatesAsync,
mergeHeadersWithUserAgent,
} from "./request";
@@ -18,6 +18,14 @@ 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 => {
@@ -77,30 +85,18 @@ export const fetchWithTrustedCertificatesAsync = async (
undefined,
options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined,
));
const buildFetchOptions = (fetchOptions: Omit<RequestInit, "dispatcher" | "bodyTimeout" | "timeout">) => ({
...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, {
...fetchOptions,
headers: {
"User-Agent": getDefaultUserAgent(),
...fetchOptions.headers,
},
signal,
dispatcher: agent,
}),
async (signal) => fetch(url, { ...buildFetchOptions(fetchOptions), signal, dispatcher: agent }),
options.timeout,
);
}
const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options ?? {};
return fetch(url, {
...fetchOptions,
headers: {
"User-Agent": getDefaultUserAgent(),
...fetchOptions.headers,
},
dispatcher: agent,
});
return fetch(url, { ...buildFetchOptions(fetchOptions), dispatcher: agent });
};
@@ -0,0 +1,120 @@
// @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<string, string | string[] | undefined>;
port: number;
}> => {
let receivedHeaders: Record<string, string | string[] | undefined> = {};
const server = createServer((req, res) => {
receivedHeaders = req.headers;
res.writeHead(200, { "Content-Type": "application/json" });
res.end("{}");
});
await new Promise<void>((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();
}
});
});
@@ -0,0 +1,39 @@
// @vitest-environment node
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { getServerStatistics, init } from "@immich/sdk";
import { describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
vi.mock("@homarr/core/infrastructure/certificates", () => ({
getAllTrustedCertificatesAsync: vi.fn(async () => []),
getTrustedCertificateHostnamesAsync: vi.fn(async () => []),
}));
describe("real @immich/sdk end-to-end through fetchWithTrustedCertificatesAsync", () => {
test("x-api-key set via init() reaches the server", async () => {
let receivedXApiKey: string | string[] | undefined;
const server = createServer((req, res) => {
receivedXApiKey = req.headers["x-api-key"];
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ usage: 1000, photos: 5, videos: 2 }));
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const port = (server.address() as AddressInfo).port;
init({ baseUrl: `http://127.0.0.1:${port}/api`, apiKey: "sdk-configured-key" });
try {
const stats = await getServerStatistics({
fetch: fetchWithTrustedCertificatesAsync as unknown as typeof fetch,
});
expect(receivedXApiKey).toBe("sdk-configured-key");
expect(stats).toMatchObject({ usage: 1000, photos: 5, videos: 2 });
} finally {
server.close();
}
});
});
-5
View File
@@ -2309,11 +2309,6 @@
}
},
"currentWindSpeed": "{currentWindSpeed} {unit}",
"error": {
"disabled": "Weather data is unavailable because outbound connections are disabled",
"failed": "Failed to load weather data",
"retry": "Retry"
},
"dailyForecast": {
"sunrise": "Sunrise",
"sunset": "Sunset",
+4 -36
View File
@@ -1,14 +1,7 @@
"use client";
import { Box, Button, Group, HoverCard, Stack, Text } from "@mantine/core";
import {
IconArrowDownRight,
IconArrowUpRight,
IconCloudOff,
IconDroplets,
IconMapPin,
IconWind,
} from "@tabler/icons-react";
import { Box, Group, HoverCard, Stack, Text } from "@mantine/core";
import { IconArrowDownRight, IconArrowUpRight, IconDroplets, IconMapPin, IconWind } from "@tabler/icons-react";
import combineClasses from "clsx";
import dayjs from "dayjs";
@@ -23,38 +16,13 @@ import { AnimatedWeatherIcon } from "./animated-icon";
import { WeatherDescription } from "./icon";
export default function WeatherWidget({ isEditMode, options }: WidgetComponentProps<"weather">) {
const tError = useScopedI18n("widget.weather.error");
const input = {
latitude: options.location.latitude,
longitude: options.location.longitude,
};
const { data: weather, isPending, isError, refetch } = clientApi.widget.weather.atLocation.useQuery(input);
const { data: weather } = clientApi.widget.weather.atLocation.useQuery(input);
if (isPending) return <WidgetEmptyState />;
if (isError) {
return (
<Stack align="center" justify="center" gap="xs" w="100%" h="100%" p="sm">
<IconCloudOff size={24} />
<Text c="red" size="sm" ta="center">
{tError("failed")}
</Text>
<Button variant="subtle" size="compact-xs" onClick={() => void refetch()}>
{tError("retry")}
</Button>
</Stack>
);
}
if (!weather) {
return (
<Stack align="center" justify="center" gap="xs" w="100%" h="100%" p="sm">
<Text c="dimmed" size="sm" ta="center">
{tError("disabled")}
</Text>
</Stack>
);
}
if (!weather) return <WidgetEmptyState />;
return (
<Stack