Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/tools/servers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
20 changes: 11 additions & 9 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
7 changes: 1 addition & 6 deletions tests/tools/metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 1 addition & 6 deletions tests/tools/server-ssh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
45 changes: 38 additions & 7 deletions tests/tools/servers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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"
Expand Down Expand Up @@ -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();
Expand Down
Loading