diff --git a/src/tools/servers.ts b/src/tools/servers.ts index f361fb6..9f444c7 100644 --- a/src/tools/servers.ts +++ b/src/tools/servers.ts @@ -34,7 +34,7 @@ function formatServer(server: HetznerServer): string { `- **IPv4**: ${ipv4}`, `- **IPv6**: ${ipv6}`, `- **Type**: ${server.server_type.name} (${server.server_type.cores} cores, ${server.server_type.memory}GB RAM, ${server.server_type.disk}GB disk)`, - `- **Location**: ${escapeHtml(server.datacenter.location.city)}, ${escapeHtml(server.datacenter.location.country)} (${escapeHtml(server.datacenter.name)})` + `- **Location**: ${escapeHtml(server.location.city)}, ${escapeHtml(server.location.country)} (${escapeHtml(server.location.name)})` ]; if (server.image) { diff --git a/src/types.ts b/src/types.ts index 7d64074..23be40a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,16 +68,18 @@ export const HetznerServerSchema = z.object({ memory: z.number(), disk: z.number() }), - datacenter: z.object({ - id: z.number(), + // Hetzner removed the `datacenter` property from the Servers API on 2026-06-30 + // (announced 2025-12-16, "Phasing out Datacenters in favor of Locations"). + // https://docs.hetzner.cloud/changelog#2025-12-16-phasing-out-datacenters + // + // Only the fields formatServer() actually renders are declared. z.object already + // strips unknown keys, so listing fewer fields is strictly more tolerant of the + // next upstream change — a required field we never read is a crash waiting to + // happen (that is exactly how the datacenter removal broke every server tool). + location: z.object({ name: z.string(), - description: z.string(), - location: z.object({ - id: z.number(), - name: z.string(), - city: z.string(), - country: z.string() - }) + country: z.string(), + city: z.string() }), image: z.object({ id: z.number(), diff --git a/tests/tools/metrics.test.ts b/tests/tools/metrics.test.ts index d087126..9b295ba 100644 --- a/tests/tools/metrics.test.ts +++ b/tests/tools/metrics.test.ts @@ -60,12 +60,7 @@ const serverResponse = { status: "running", public_net: { ipv4: { ip: "91.99.173.93" }, ipv6: { ip: "2a01:4f8::1" } }, server_type: { id: 22, name: "cx53", description: "CX53", cores: 16, memory: 32, disk: 320 }, - datacenter: { - id: 2, - name: "nbg1-dc3", - description: "Nuremberg DC Park 1", - location: { id: 2, name: "nbg1", city: "Nuremberg", country: "DE" } - }, + location: { id: 2, name: "nbg1", description: "Nuremberg DC Park 1", country: "DE", city: "Nuremberg" }, image: { id: 1, name: "ubuntu-22.04", description: "Ubuntu 22.04", os_flavor: "ubuntu", os_version: "22.04" }, labels: {}, created: "2024-01-01T00:00:00+00:00" diff --git a/tests/tools/server-ssh.test.ts b/tests/tools/server-ssh.test.ts index 802647c..4d7616a 100644 --- a/tests/tools/server-ssh.test.ts +++ b/tests/tools/server-ssh.test.ts @@ -55,12 +55,7 @@ const serverResponse = { ipv6: { ip: "2a01:4f8::1" } }, server_type: { id: 22, name: "cx53", description: "CX53", cores: 16, memory: 32, disk: 320 }, - datacenter: { - id: 2, - name: "nbg1-dc3", - description: "Nuremberg DC Park 1", - location: { id: 2, name: "nbg1", city: "Nuremberg", country: "DE" } - }, + location: { id: 2, name: "nbg1", description: "Nuremberg DC Park 1", country: "DE", city: "Nuremberg" }, image: { id: 1, name: "ubuntu-22.04", description: "Ubuntu 22.04", os_flavor: "ubuntu", os_version: "22.04" }, labels: {}, created: "2024-01-01T00:00:00+00:00" diff --git a/tests/tools/servers.test.ts b/tests/tools/servers.test.ts index 4bd2227..6fe9500 100644 --- a/tests/tools/servers.test.ts +++ b/tests/tools/servers.test.ts @@ -12,7 +12,7 @@ vi.mock("../../src/api.js", async (importOriginal) => { import { registerServerTools } from "../../src/tools/servers.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { makeApiRequest } from "../../src/api.js"; -import { HetznerServer, ListServersResponse, ListServersResponseSchema } from "../../src/types.js"; +import { HetznerServer, HetznerServerSchema, ListServersResponse, ListServersResponseSchema } from "../../src/types.js"; const mockedRequest = vi.mocked(makeApiRequest); @@ -29,12 +29,9 @@ const baseServer: HetznerServer = { ipv6: { ip: "2001:db8::1" } }, server_type: { id: 1, name: "cx22", description: "CX22", cores: 2, memory: 4, disk: 40 }, - datacenter: { - id: 1, - name: "fsn1-dc14", - description: "Falkenstein DC Park 1", - location: { id: 1, name: "fsn1", city: "Falkenstein", country: "DE" } - }, + // 只保留 schema 宣告的欄位,與 parse 後的實際形狀一致。 + // 真實 API 多回傳的欄位由下方 rawApiServer 測試涵蓋。 + location: { name: "fsn1", country: "DE", city: "Falkenstein" }, image: { id: 1, name: "ubuntu-24.04", description: "Ubuntu 24.04", os_flavor: "ubuntu", os_version: "24.04" }, labels: {}, created: "2026-01-01T00:00:00+00:00" @@ -73,6 +70,40 @@ function captureRegisteredTools(): CapturedTool[] { return captured; } +describe("hetzner_list_servers — location rendering", () => { + // Regression: Hetzner removed `datacenter` from the Servers API on 2026-06-30. + // The formatter must read the top-level `location` object instead. + it("renders Location from the top-level location object", async () => { + const tools = captureRegisteredTools(); + const handler = tools.find((t) => t.name === "hetzner_list_servers")!.handler; + mockedRequest.mockResolvedValueOnce(pageResponse([makeServer(1)], null)); + + const result = await handler({ response_format: "markdown" }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain("**Location**: Falkenstein, DE (fsn1)"); + }); + + // The live payload carries no `datacenter` and extra location keys we never render. + // Declaring only the consumed fields must tolerate both. + it("parses a raw API payload with no datacenter and unknown extra keys", () => { + const rawApiServer = { + ...baseServer, + location: { + id: 1, + name: "fsn1", + description: "Falkenstein DC Park 1", + country: "DE", + city: "Falkenstein", + latitude: 50.47612, + longitude: 12.370071, + network_zone: "eu-central" + } + }; + expect(() => HetznerServerSchema.parse(rawApiServer)).not.toThrow(); + }); +}); + describe("hetzner_list_servers — auto-pagination", () => { it("fetches all pages and combines results", async () => { const tools = captureRegisteredTools();