From a049c8651f0215af130d44ce95e4659a4a68df41 Mon Sep 17 00:00:00 2001 From: Terry Chen Date: Wed, 8 Jul 2026 13:30:09 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(servers):=20=E6=94=B9=E7=94=A8=20locati?= =?UTF-8?q?on=20=E5=8F=96=E4=BB=A3=E5=B7=B2=E8=A2=AB=20Hetzner=20=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E7=9A=84=20datacenter=20=E6=AC=84=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hetzner Cloud API 於 2026-06-30 正式從 Servers 與 Primary IPs 資源移除 `datacenter` 屬性(2025-12-16 公告 "Phasing out Datacenters in favor of Locations",https://docs.hetzner.cloud/changelog#2025-12-16-phasing-out-datacenters)。 原本 HetznerServerSchema 將 `datacenter` 列為 required,且 formatServer 讀取 `server.datacenter.location.*`,導致移除後所有 hetzner_list_servers / hetzner_get_server / metrics / ssh 相關工具一律拋出: Invalid input: expected object, received undefined (servers.0.datacenter) 改用 API 早已提供的頂層 `location` 物件,且**只宣告 formatServer 實際會渲染的 三個欄位**(name / city / country)。zod 的 z.object 預設就會剝除未宣告的多餘 欄位,因此宣告得越少對上游變更越寬容;反之,一個「宣告為必要卻從未讀取」的欄位 就是一顆定時炸彈——這次的 datacenter 正是如此。 why 不用 .passthrough():實測 zod 4.3.6,預設 z.object 對「多出來的欄位」本來 就 PASS(自動剝除),passthrough 只是改為保留;而兩者對「缺少必要欄位」一律 THROW。也就是說 passthrough 對這次的失效模式毫無防護作用,加了只會誤導。 驗證: - 346 unit tests / typecheck / lint 全綠 - 新增測試以真實 API 形狀(無 datacenter、含 latitude/longitude/network_zone 等未宣告欄位)驗證 schema 可正常解析 - runtime 實打 Hetzner API:hetzner_list_servers 與 hetzner_get_server 均 回傳 "**Location**: Nuremberg, DE (nbg1)" --- src/tools/servers.ts | 2 +- src/types.ts | 20 +++++++++------- tests/tools/metrics.test.ts | 7 +----- tests/tools/server-ssh.test.ts | 7 +----- tests/tools/servers.test.ts | 43 ++++++++++++++++++++++++++++++---- 5 files changed, 53 insertions(+), 26 deletions(-) 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..f7f3ba8 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,11 +29,12 @@ const baseServer: HetznerServer = { ipv6: { ip: "2001:db8::1" } }, server_type: { id: 1, name: "cx22", description: "CX22", cores: 2, memory: 4, disk: 40 }, - datacenter: { + location: { id: 1, - name: "fsn1-dc14", + name: "fsn1", description: "Falkenstein DC Park 1", - location: { id: 1, name: "fsn1", city: "Falkenstein", country: "DE" } + country: "DE", + city: "Falkenstein" }, image: { id: 1, name: "ubuntu-24.04", description: "Ubuntu 24.04", os_flavor: "ubuntu", os_version: "24.04" }, labels: {}, @@ -73,6 +74,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(); From 7dfcb0e79370b03c56f823ad54712a3e2d473b26 Mon Sep 17 00:00:00 2001 From: Terry Chen Date: Wed, 8 Jul 2026 17:49:27 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(servers):=20fixture=20=E5=8F=AA?= =?UTF-8?q?=E4=BF=9D=E7=95=99=20schema=20=E5=AE=A3=E5=91=8A=E7=9A=84?= =?UTF-8?q?=E6=AC=84=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review 指出 baseServer 標註為 HetznerServer,但 location 仍帶著 id / description——schema 已不再宣告這兩個欄位,測試資料因此與 parse 後的實際 形狀不一致。 追查發現這個不一致之所以沒被 typecheck 抓到,是因為 tsconfig.json 的 include 只有 `src/**/*`,測試檔從來就不在 typecheck 範圍內。 fixture 收斂為 { name, country, city }。真實 API 多回傳的欄位 (id / description / latitude / longitude / network_zone)仍由既有的 rawApiServer 測試涵蓋,該測試刻意使用未標註型別的字面值。 註:把 tests/ 納入 typecheck 需另建 tsconfig(現有 rootDir 指向 src,且 tests/api.test.ts、tests/tools/volumes.test.ts 等有 4 個既有型別錯誤), 不在本 hotfix 範圍。 驗證:351 tests / typecheck / lint 全綠。 --- tests/tools/servers.test.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/tools/servers.test.ts b/tests/tools/servers.test.ts index f7f3ba8..6fe9500 100644 --- a/tests/tools/servers.test.ts +++ b/tests/tools/servers.test.ts @@ -29,13 +29,9 @@ const baseServer: HetznerServer = { ipv6: { ip: "2001:db8::1" } }, server_type: { id: 1, name: "cx22", description: "CX22", cores: 2, memory: 4, disk: 40 }, - location: { - id: 1, - name: "fsn1", - description: "Falkenstein DC Park 1", - country: "DE", - city: "Falkenstein" - }, + // 只保留 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"