diff --git a/TELEMETRY.md b/TELEMETRY.md index bcd9137..ffcdbb0 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -52,7 +52,7 @@ turning expected caller failures into exception issues: | Attribute | Meaning | Example values | | --- | --- | --- | | `mcp.tool.name` | Fixed registered tool name | `get_timeseries`, `compare_periods` | -| `error.kind` | Bounded failure category | `user_input`, `plausible_api`, `unexpected` | +| `error.kind` | Bounded failure category | `user_input`, `plausible_api`, `plausible_unreachable`, `unexpected` | | `http.response.status_code` | Plausible status, when applicable | `400`, `401`, `429`, `503` | Unexpected failures and Plausible 5xx responses are also captured as exceptions. User input diff --git a/__tests__/errors.test.ts b/__tests__/errors.test.ts index 2d2c8b5..f293956 100644 --- a/__tests__/errors.test.ts +++ b/__tests__/errors.test.ts @@ -12,8 +12,8 @@ vi.mock("@sentry/cloudflare", () => ({ const { captureException, count } = sentry; -import { PlausibleApiError } from "../src/plausible.js"; -import { reportToolError, UserFacingError } from "../src/errors.js"; +import { PlausibleApiError, PlausibleUnreachableError } from "../src/plausible.js"; +import { reportToolError, toolErrorResult, UserFacingError } from "../src/errors.js"; const toolName = "get_timeseries"; @@ -26,7 +26,11 @@ describe("reportToolError", () => { it("records user-facing failures without creating an exception issue", () => { const error = new UserFacingError("site_id is required"); - expect(reportToolError(error, toolName)).toBe("site_id is required"); + expect(reportToolError(error, toolName)).toEqual({ + code: "invalid_input", + message: "site_id is required", + retryable: false, + }); expect(count).toHaveBeenCalledWith("mcp.tool.error", 1, { attributes: { "mcp.tool.name": toolName, @@ -39,9 +43,11 @@ describe("reportToolError", () => { it("records expected Plausible 4xx responses without creating an exception issue", () => { const error = new PlausibleApiError(401, "Invalid API key or site ID"); - expect(reportToolError(error, toolName)).toBe( - "Plausible rejected the API key or site_id (401)", - ); + expect(reportToolError(error, toolName)).toEqual({ + code: "unauthorized", + message: "Plausible rejected the API key or site_id (401)", + retryable: false, + }); expect(count).toHaveBeenCalledWith("mcp.tool.error", 1, { attributes: { "mcp.tool.name": toolName, @@ -55,7 +61,11 @@ describe("reportToolError", () => { it("captures Plausible 5xx responses while recording the tool failure", () => { const error = new PlausibleApiError(503, "Unavailable"); - expect(reportToolError(error, toolName)).toBe("Plausible API returned 503"); + expect(reportToolError(error, toolName)).toEqual({ + code: "upstream_unavailable", + message: "Plausible API returned 503", + retryable: true, + }); expect(count).toHaveBeenCalledOnce(); expect(captureException).toHaveBeenCalledWith(error); }); @@ -66,16 +76,18 @@ describe("reportToolError", () => { JSON.stringify({ error: "Invalid metric \"pagevies\"" }) ); - expect(reportToolError(error, toolName)).toBe( - 'Plausible API returned 400: Invalid metric "pagevies"', - ); + expect(reportToolError(error, toolName)).toEqual({ + code: "invalid_query", + message: 'Plausible API returned 400: Invalid metric "pagevies"', + retryable: false, + }); expect(captureException).not.toHaveBeenCalled(); }); it("appends the Plausible error message to the 401 guidance", () => { const error = new PlausibleApiError(401, JSON.stringify({ error: "site does not exist" })); - expect(reportToolError(error, toolName)).toBe( + expect(reportToolError(error, toolName).message).toBe( "Plausible rejected the API key or site_id (401): site does not exist", ); }); @@ -83,7 +95,7 @@ describe("reportToolError", () => { it("falls back to the status-only message for a non-JSON body", () => { const error = new PlausibleApiError(502, "Bad Gateway"); - expect(reportToolError(error, toolName)).toBe("Plausible API returned 502"); + expect(reportToolError(error, toolName).message).toBe("Plausible API returned 502"); }); it("gives the caller a 5xx detail while the captured exception stays status-only", () => { @@ -92,7 +104,7 @@ describe("reportToolError", () => { JSON.stringify({ error: "failed filter customer@example.com" }) ); - expect(reportToolError(error, toolName)).toBe( + expect(reportToolError(error, toolName).message).toBe( "Plausible API returned 503: failed filter customer@example.com", ); expect(captureException).toHaveBeenCalledWith(error); @@ -101,10 +113,31 @@ describe("reportToolError", () => { expect(JSON.stringify(captured)).not.toContain("customer@example.com"); }); + it("classifies an unreachable Plausible API as retryable upstream_unavailable", () => { + const error = new PlausibleUnreachableError(new TypeError("fetch failed")); + + expect(reportToolError(error, toolName)).toEqual({ + code: "upstream_unavailable", + message: "Could not reach the Plausible API", + retryable: true, + }); + expect(count).toHaveBeenCalledWith("mcp.tool.error", 1, { + attributes: { + "mcp.tool.name": toolName, + "error.kind": "plausible_unreachable", + }, + }); + expect(captureException).toHaveBeenCalledWith(error); + }); + it("captures unexpected failures and returns a safe message", () => { const error = new Error("secret implementation detail"); - expect(reportToolError(error, toolName)).toBe("An unexpected error occurred"); + expect(reportToolError(error, toolName)).toEqual({ + code: "internal", + message: "An unexpected error occurred", + retryable: false, + }); expect(count).toHaveBeenCalledWith("mcp.tool.error", 1, { attributes: { "mcp.tool.name": toolName, @@ -113,4 +146,102 @@ describe("reportToolError", () => { }); expect(captureException).toHaveBeenCalledWith(error); }); + + it("classifies 403 as unauthorized", () => { + const error = new PlausibleApiError(403, JSON.stringify({ error: "forbidden" })); + + expect(reportToolError(error, toolName)).toEqual({ + code: "unauthorized", + message: "Plausible API returned 403: forbidden", + retryable: false, + }); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("classifies 429 as retryable and carries the wait time when known", () => { + const error = new PlausibleApiError(429, "", 30); + + expect(reportToolError(error, toolName)).toEqual({ + code: "rate_limited", + message: "Plausible rate limit exceeded (429)", + retryable: true, + retry_after_seconds: 30, + }); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("carries the wait time on a 5xx upstream_unavailable", () => { + expect(reportToolError(new PlausibleApiError(503, "", 7), toolName)).toMatchObject({ + code: "upstream_unavailable", + retryable: true, + retry_after_seconds: 7, + }); + }); + + it("omits retry_after_seconds on 429 without a Retry-After header", () => { + const info = reportToolError(new PlausibleApiError(429, ""), toolName); + + expect(info.code).toBe("rate_limited"); + expect(info.retryable).toBe(true); + expect(info).not.toHaveProperty("retry_after_seconds"); + }); +}); + +describe("toolErrorResult", () => { + beforeEach(() => { + captureException.mockReset(); + count.mockReset(); + }); + + it("returns an isError result with the error as JSON in a second text block", () => { + const result = toolErrorResult(new UserFacingError("site_id is required"), toolName); + + expect(result.isError).toBe(true); + expect(result.content).toHaveLength(2); + expect(result.content[0]).toEqual({ type: "text", text: "Error: site_id is required" }); + expect(JSON.parse(result.content[1].text)).toEqual({ + error: { code: "invalid_input", message: "site_id is required", retryable: false }, + }); + }); + + it("carries no structuredContent", () => { + const result = toolErrorResult(new UserFacingError("site_id is required"), toolName); + + expect(result).not.toHaveProperty("structuredContent"); + }); + + it("appends the wait time for a rate limit", () => { + const result = toolErrorResult(new PlausibleApiError(429, "", 12), toolName); + + expect(result.content[0].text).toBe( + "Error: Plausible rate limit exceeded (429) Retry after 12 seconds.", + ); + expect(JSON.parse(result.content[1].text).error.retry_after_seconds).toBe(12); + }); + + it("puts the wait time of a 503 in the JSON error block", () => { + const result = toolErrorResult(new PlausibleApiError(503, "", 9), toolName); + + expect(result.content[0].text).toBe( + "Error: Plausible API returned 503 Retry after 9 seconds.", + ); + expect(JSON.parse(result.content[1].text).error).toMatchObject({ + code: "upstream_unavailable", + retry_after_seconds: 9, + }); + }); + + it("flags a retryable error without a wait time as temporary", () => { + const result = toolErrorResult(new PlausibleApiError(503, ""), toolName); + + expect(result.content[0].text).toBe( + "Error: Plausible API returned 503 This error is temporary; retry later.", + ); + }); + + it("adds no retry hint to a non-retryable error", () => { + const result = toolErrorResult(new PlausibleApiError(400, ""), toolName); + + expect(result.content[0].text).toBe("Error: Plausible API returned 400"); + }); }); diff --git a/__tests__/plausible.test.ts b/__tests__/plausible.test.ts index 30b0d90..270ec06 100644 --- a/__tests__/plausible.test.ts +++ b/__tests__/plausible.test.ts @@ -1,8 +1,9 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { PlausibleClient, PlausibleApiError, parsePlausibleErrorDetail, + PlausibleUnreachableError, } from "../src/plausible.js"; const mockFetch = vi.fn(); @@ -15,10 +16,11 @@ function mockOk(data: unknown) { }); } -function mockError(status: number, body: string) { +function mockError(status: number, body: string, headers: Record = {}) { mockFetch.mockResolvedValueOnce({ ok: false, status, + headers: new Headers(headers), text: () => Promise.resolve(body), }); } @@ -75,6 +77,18 @@ describe("PlausibleClient", () => { expect(body.date_range).toEqual(["2026-07-01", "2026-07-07"]); }); + it("wraps a rejected fetch as PlausibleUnreachableError", async () => { + const cause = new TypeError("fetch failed"); + mockFetch.mockRejectedValueOnce(cause); + + const error = await client + .query({ site_id: "example.com", metrics: ["visitors"], date_range: "7d" }) + .catch((e) => e); + + expect(error).toBeInstanceOf(PlausibleUnreachableError); + expect((error as Error).cause).toBe(cause); + }); + it("includes dimensions when provided", async () => { mockOk({ results: [], meta: {}, query: {} }); @@ -137,6 +151,32 @@ describe("PlausibleClient", () => { expect(body.pagination).toEqual({ limit: 10, offset: 5 }); }); + it("includes order_by when provided and omits it when empty", async () => { + mockOk({ results: [], meta: {}, query: {} }); + await client.query({ + site_id: "example.com", + metrics: ["visitors"], + date_range: "7d", + order_by: [ + ["visitors", "desc"], + ["event:page", "asc"], + ], + }); + expect(JSON.parse(mockFetch.mock.calls[0][1].body).order_by).toEqual([ + ["visitors", "desc"], + ["event:page", "asc"], + ]); + + mockOk({ results: [], meta: {}, query: {} }); + await client.query({ + site_id: "example.com", + metrics: ["visitors"], + date_range: "7d", + order_by: [], + }); + expect(JSON.parse(mockFetch.mock.calls[1][1].body)).not.toHaveProperty("order_by"); + }); + it("uses self-hosted base URL", async () => { const selfHosted = new PlausibleClient({ apiKey: "key", @@ -230,6 +270,63 @@ describe("PlausibleClient", () => { ).rejects.toThrow(PlausibleApiError); }); + it("exposes an integer Retry-After header as retryAfterSeconds", async () => { + mockError(429, "Rate limit exceeded", { "Retry-After": "30" }); + + const error = await client + .query({ site_id: "example.com", metrics: ["visitors"], date_range: "7d" }) + .catch((e) => e); + + expect(error).toBeInstanceOf(PlausibleApiError); + expect((error as PlausibleApiError).retryAfterSeconds).toBe(30); + }); + + describe("HTTP-date Retry-After", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-10-21T07:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + async function retryAfterFor(header: string) { + mockError(429, "Rate limit exceeded", { "Retry-After": header }); + const error = await client + .query({ site_id: "example.com", metrics: ["visitors"], date_range: "7d" }) + .catch((e) => e); + return (error as PlausibleApiError).retryAfterSeconds; + } + + it("converts a future date to seconds from now", async () => { + expect(await retryAfterFor("Wed, 21 Oct 2026 07:00:30 GMT")).toBe(30); + }); + + it("rounds a fractional wait up", async () => { + vi.setSystemTime(new Date("2026-10-21T07:00:00.500Z")); + expect(await retryAfterFor("Wed, 21 Oct 2026 07:00:30 GMT")).toBe(30); + }); + + it("clamps a past date to 0", async () => { + expect(await retryAfterFor("Wed, 21 Oct 2026 06:00:00 GMT")).toBe(0); + }); + + it("ignores an unparseable value", async () => { + expect(await retryAfterFor("soon")).toBeUndefined(); + }); + }); + + it("leaves retryAfterSeconds undefined without a Retry-After header", async () => { + mockError(429, "Rate limit exceeded"); + + const error = await client + .query({ site_id: "example.com", metrics: ["visitors"], date_range: "7d" }) + .catch((e) => e); + + expect((error as PlausibleApiError).retryAfterSeconds).toBeUndefined(); + }); + it("throws PlausibleApiError on 500", async () => { mockError(500, "Internal Server Error"); diff --git a/__tests__/server.test.ts b/__tests__/server.test.ts index 989c8db..48a6c1d 100644 --- a/__tests__/server.test.ts +++ b/__tests__/server.test.ts @@ -214,6 +214,7 @@ describe("MCP Server Integration", () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 401, + headers: new Headers(), text: () => Promise.resolve("Invalid API key"), }); diff --git a/__tests__/tools/get-breakdown.test.ts b/__tests__/tools/get-breakdown.test.ts index 319d231..6510e7e 100644 --- a/__tests__/tools/get-breakdown.test.ts +++ b/__tests__/tools/get-breakdown.test.ts @@ -26,7 +26,7 @@ describe("get_breakdown tool", () => { site_id: "example.com", dimensions: ["event:page"], metrics: ["visitors", "pageviews", "bounce_rate"], - pagination: { limit: 20 }, + pagination: { limit: 21, offset: 0 }, }) ); }); @@ -41,7 +41,7 @@ describe("get_breakdown tool", () => { }); expect(client.query).toHaveBeenCalledWith( - expect.objectContaining({ pagination: { limit: 50 } }) + expect.objectContaining({ pagination: { limit: 51, offset: 0 } }) ); }); @@ -220,4 +220,133 @@ describe("get_breakdown tool", () => { expect.objectContaining({ metrics: ["visitors", "visit_duration"] }) ); }); + + it("passes offset through to the query", async () => { + const handler = getToolHandler(server, "get_breakdown"); + await handler({ + site_id: "example.com", + date_range: "7d", + dimension: "event:page", + limit: 10, + offset: 30, + }); + + expect(client.query).toHaveBeenCalledWith( + expect.objectContaining({ pagination: { limit: 11, offset: 30 } }) + ); + }); + + it("orders by the first metric with the dimension as a tiebreaker", async () => { + const handler = getToolHandler(server, "get_breakdown"); + await handler({ + site_id: "example.com", + date_range: "7d", + dimension: "event:page", + metrics: ["pageviews", "visitors"], + }); + + expect(client.query).toHaveBeenCalledWith( + expect.objectContaining({ + order_by: [ + ["pageviews", "desc"], + ["event:page", "asc"], + ], + }) + ); + }); + + it("omits the dimension tiebreaker for event:goal, which Plausible rejects in order_by", async () => { + const handler = getToolHandler(server, "get_breakdown"); + await handler({ site_id: "example.com", date_range: "7d", dimension: "event:goal" }); + + expect(client.query).toHaveBeenCalledWith( + expect.objectContaining({ order_by: [["visitors", "desc"]] }) + ); + }); + + describe("paging", () => { + function rows(n: number) { + return Array.from({ length: n }, (_, i) => ({ + dimensions: [`/page-${i}`], + metrics: [i, i, 0], + })); + } + + it("trims the extra row and reports has_more when another page exists", async () => { + client.query.mockResolvedValue({ results: rows(3), meta: {}, query: {} }); + const handler = getToolHandler(server, "get_breakdown"); + const result = await handler({ + site_id: "example.com", + date_range: "7d", + dimension: "event:page", + limit: 2, + offset: 4, + }); + + expect(result.structuredContent.results).toHaveLength(2); + expect(result.structuredContent.pagination).toEqual({ + limit: 2, + offset: 4, + has_more: true, + }); + const text = JSON.parse(result.content[0].text); + expect(text.results).toHaveLength(2); + expect(text.pagination).toEqual(result.structuredContent.pagination); + }); + + it("pages goals over a stable order however Plausible orders tied rows", async () => { + const goal = (name: string, visitors: number) => ({ + dimensions: [name], + metrics: [visitors, 0, 0], + }); + const handler = getToolHandler(server, "get_breakdown"); + const pageOf = async (results: unknown[], offset: number) => { + client.query.mockResolvedValueOnce({ results, meta: {}, query: {} }); + const result = await handler({ + site_id: "example.com", + date_range: "2026-10-01,2026-10-07", + dimension: "event:goal", + limit: 2, + offset, + }); + return result.structuredContent; + }; + + const first = await pageOf([goal("Top", 9), goal("B", 5), goal("A", 5), goal("C", 5)], 0); + const second = await pageOf([goal("Top", 9), goal("C", 5), goal("A", 5), goal("B", 5)], 2); + + expect(client.query).toHaveBeenCalledWith( + expect.objectContaining({ pagination: { limit: 10_000 } }) + ); + expect([...first.results, ...second.results].map((r: any) => r.dimensions[0])).toEqual([ + "Top", + "A", + "B", + "C", + ]); + expect(first.pagination.has_more).toBe(true); + expect(second.pagination.has_more).toBe(false); + }); + + it("reports has_more false when the page is not full beyond limit", async () => { + client.query.mockResolvedValue({ results: rows(2), meta: {}, query: {} }); + const handler = getToolHandler(server, "get_breakdown"); + const result = await handler({ + site_id: "example.com", + date_range: "7d", + dimension: "event:page", + limit: 2, + }); + + expect(result.structuredContent.results).toHaveLength(2); + expect(result.structuredContent.pagination).toEqual({ + limit: 2, + offset: 0, + has_more: false, + }); + expect(JSON.parse(result.content[0].text).pagination).toEqual( + result.structuredContent.pagination + ); + }); + }); }); diff --git a/__tests__/tools/output-shape.test.ts b/__tests__/tools/output-shape.test.ts new file mode 100644 index 0000000..bae5860 --- /dev/null +++ b/__tests__/tools/output-shape.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { McpServer } from "@modelcontextprotocol/server"; +import { register as registerTimeseries } from "../../src/tools/get-timeseries.js"; +import { register as registerBreakdown } from "../../src/tools/get-breakdown.js"; +import { register as registerConversions } from "../../src/tools/get-conversions.js"; +import { register as registerComparePeriods } from "../../src/tools/compare-periods.js"; +import { createMockClient, getToolHandler } from "./_helpers.js"; + +const cases = [ + { + tool: "get_timeseries", + register: registerTimeseries, + args: { date_range: "7d" }, + topLevelKeys: ["dimensions", "metrics", "results"], + }, + { + tool: "get_breakdown", + register: registerBreakdown, + args: { date_range: "7d", dimension: "event:page" }, + topLevelKeys: ["dimensions", "metrics", "pagination", "results"], + }, + { + tool: "get_conversions", + register: registerConversions, + args: { date_range: "7d" }, + topLevelKeys: ["dimensions", "metrics", "results"], + }, + { + tool: "compare_periods", + register: registerComparePeriods, + args: { period_a: "7d", period_b: "30d" }, + topLevelKeys: ["deltas", "period_a", "period_b"], + }, +]; + +function rows(n: number) { + return Array.from({ length: n }, (_, i) => ({ + dimensions: [`row-${i}`], + metrics: [i, i * 2, i / 2, i * 3], + })); +} + +describe.each(cases)("$tool structuredContent shape", ({ tool, register, args, topLevelKeys }) => { + it.each([0, 1, 5])("matches the output schema with %i result rows", async (rowCount) => { + const server = new McpServer({ name: "test", version: "0.0.1" }); + register(server, createMockClient({ results: rows(rowCount), meta: {}, query: {} }), "default.com"); + const registered = (server as any)._registeredTools[tool]; + + const result = await getToolHandler(server, tool)(args); + + expect(result.isError).toBeFalsy(); + expect(registered.outputSchema.safeParse(result.structuredContent).success).toBe(true); + expect(Object.keys(result.structuredContent).sort()).toEqual(topLevelKeys); + }); +}); diff --git a/src/errors.ts b/src/errors.ts index 2d09b35..35936ae 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,5 +1,5 @@ import * as Sentry from "@sentry/cloudflare"; -import { PlausibleApiError } from "./plausible.js"; +import { PlausibleApiError, PlausibleUnreachableError } from "./plausible.js"; export type ToolName = | "get_timeseries" @@ -14,12 +14,28 @@ export class UserFacingError extends Error { } } +export type ToolErrorCode = + | "invalid_input" + | "unauthorized" + | "rate_limited" + | "upstream_unavailable" + | "invalid_query" + | "internal"; + +/** Machine-readable failure description, returned as JSON in the second text block. */ +export interface ToolErrorInfo { + code: ToolErrorCode; + message: string; + retryable: boolean; + retry_after_seconds?: number; +} + /** * Preserve visibility into failed tool calls without turning expected caller errors into * exception issues. The MCP response remains an `isError` result; this function only decides - * how the failure is represented in telemetry and which safe message the client receives. + * how the failure is represented in telemetry and which safe message and code the client receives. */ -export function reportToolError(error: unknown, toolName: ToolName): string { +export function reportToolError(error: unknown, toolName: ToolName): ToolErrorInfo { if (error instanceof UserFacingError) { Sentry.metrics.count("mcp.tool.error", 1, { attributes: { @@ -27,7 +43,7 @@ export function reportToolError(error: unknown, toolName: ToolName): string { "error.kind": "user_input", }, }); - return error.message; + return { code: "invalid_input", message: error.message, retryable: false }; } if (error instanceof PlausibleApiError) { @@ -48,7 +64,33 @@ export function reportToolError(error: unknown, toolName: ToolName): string { } else { summary = `Plausible API returned ${error.status}`; } - return error.detail ? `${summary}: ${error.detail}` : summary; + const message = error.detail ? `${summary}: ${error.detail}` : summary; + + if (error.status === 401 || error.status === 403) { + return { code: "unauthorized", message, retryable: false }; + } + if (error.status === 429 || error.status >= 500) { + return { + code: error.status === 429 ? "rate_limited" : "upstream_unavailable", + message, + retryable: true, + ...(error.retryAfterSeconds !== undefined && { + retry_after_seconds: error.retryAfterSeconds, + }), + }; + } + return { code: "invalid_query", message, retryable: false }; + } + + if (error instanceof PlausibleUnreachableError) { + Sentry.metrics.count("mcp.tool.error", 1, { + attributes: { + "mcp.tool.name": toolName, + "error.kind": "plausible_unreachable", + }, + }); + Sentry.captureException(error); + return { code: "upstream_unavailable", message: error.message, retryable: true }; } Sentry.metrics.count("mcp.tool.error", 1, { @@ -58,5 +100,28 @@ export function reportToolError(error: unknown, toolName: ToolName): string { }, }); Sentry.captureException(error); - return "An unexpected error occurred"; + return { code: "internal", message: "An unexpected error occurred", retryable: false }; +} + +/** + * Failed tool result: a human-readable text block followed by the machine-readable error as + * JSON. `structuredContent` is omitted because MCP SDK v1 clients validate it against the + * success `outputSchema` even when `isError` is true, which would turn the error into a + * protocol failure. + */ +export function toolErrorResult(error: unknown, toolName: ToolName) { + const info = reportToolError(error, toolName); + let text = `Error: ${info.message}`; + if (info.retry_after_seconds !== undefined) { + text += ` Retry after ${info.retry_after_seconds} seconds.`; + } else if (info.retryable) { + text += " This error is temporary; retry later."; + } + return { + content: [ + { type: "text" as const, text }, + { type: "text" as const, text: JSON.stringify({ error: info }) }, + ], + isError: true, + }; } diff --git a/src/plausible.ts b/src/plausible.ts index fa0d904..f7ebbc7 100644 --- a/src/plausible.ts +++ b/src/plausible.ts @@ -5,6 +5,7 @@ export interface PlausibleQueryParams { dimensions?: string[]; filters?: unknown[]; pagination?: { limit: number; offset?: number }; + order_by?: [string, "asc" | "desc"][]; } export interface PlausibleResult { @@ -47,12 +48,21 @@ export function parsePlausibleErrorDetail(body: string): string | undefined { * exception surface entirely and handed to the caller separately. `detail` is non-enumerable * so a serializer walking the error cannot pick it up either. */ +/** fetch rejected before Plausible sent a response: DNS, TLS, or a dropped connection. */ +export class PlausibleUnreachableError extends Error { + constructor(cause: unknown) { + super("Could not reach the Plausible API", { cause }); + this.name = "PlausibleUnreachableError"; + } +} + export class PlausibleApiError extends Error { readonly detail: string | undefined; constructor( public readonly status: number, - body: string + body: string, + public readonly retryAfterSeconds?: number ) { super(`Plausible API error ${status}`); Object.defineProperty(this, "detail", { @@ -63,6 +73,15 @@ export class PlausibleApiError extends Error { } } +/** Retry-After is either delta-seconds or an HTTP-date; both are normalized to seconds from now. */ +function parseRetryAfterSeconds(header: string | null): number | undefined { + const value = header?.trim(); + if (!value) return undefined; + if (/^\d+$/.test(value)) return Number(value); + const date = Date.parse(value); + return Number.isFinite(date) ? Math.max(0, Math.ceil((date - Date.now()) / 1000)) : undefined; +} + export interface PlausibleClientConfig { apiKey: string; baseUrl?: string; @@ -111,18 +130,31 @@ export class PlausibleClient { body.pagination = params.pagination; } - const response = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${this.apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); + if (params.order_by?.length) { + body.order_by = params.order_by; + } + + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + } catch (cause) { + throw new PlausibleUnreachableError(cause); + } if (!response.ok) { const text = await response.text(); - throw new PlausibleApiError(response.status, text); + throw new PlausibleApiError( + response.status, + text, + parseRetryAfterSeconds(response.headers.get("Retry-After")) + ); } return (await response.json()) as PlausibleResponse; diff --git a/src/schemas.ts b/src/schemas.ts index 20c9d50..f9ced26 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -83,26 +83,30 @@ export const dateRangeSchema = z 'Must be "Nh", "Nd", "Nmo", "day", "month", "year", "all", or "YYYY-MM-DD,YYYY-MM-DD"' ) .describe( - 'Date range: "7d", "30d", "12mo", "month", "year", "all", or "YYYY-MM-DD,YYYY-MM-DD"' + 'Date range: "7d", "30d", "12mo", "month", "year", "all", or "YYYY-MM-DD,YYYY-MM-DD" (start,end, both inclusive). Days after today count as zero.' ); export const pageSchema = z .string() .max(1024) .describe( - "Filter by page path. Exact match by default, use * as trailing wildcard (e.g. /blog*)" + "Filter by page path. Exact match by default, use * as trailing wildcard (e.g. /blog*). Do not also target event:page in property_filters; the call is rejected." ) .optional(); export const goalSchema = z .string() .max(1024) - .describe("Filter by goal name (e.g. Signup, Purchase)") + .describe( + "Filter by goal name (e.g. Signup, Purchase). Do not also target event:goal in property_filters; the call is rejected." + ) .optional(); export const metricsSchema = z .array(z.enum(VALID_METRICS)) - .describe("Metrics to return. Defaults vary by tool.") + .describe( + "Metrics to return. Defaults vary by tool. Plausible rejects these combinations: bounce_rate or visit_duration with event:goal or event:props: dimensions; pageviews, events, scroll_depth or time_on_page with visit:entry_page or visit:exit_page dimensions; views_per_visit with any non-time dimension or a page filter. For goal conversions use get_conversions." + ) .optional(); /** @@ -155,7 +159,7 @@ const customPropertyDimensionSchema = z export const dimensionSchema = z .union([z.enum(VALID_DIMENSIONS), customPropertyDimensionSchema]) .describe( - 'Dimension to group results by: a standard dimension (e.g. event:page, visit:source), or a custom event property as "event:props:" (e.g. event:props:plan).' + 'Dimension to group results by: a standard dimension (e.g. event:page, visit:source), or a custom event property as "event:props:" (e.g. event:props:plan). Prefer visit:country_name/region_name/city_name over the code variants when presenting geography to people. An empty visit:entry_page or visit:exit_page value means a session with no recorded pageview.' ); /** Matches the cap on the `page`/`goal` shortcut params, which compile to the same filters. */ @@ -297,6 +301,14 @@ export const queryResultOutputSchema = z.object({ }) ) .describe("One row per dimension-value combination returned by Plausible"), + pagination: z + .object({ + limit: z.number().describe("Max rows requested"), + offset: z.number().describe("Rows skipped before the first returned row"), + has_more: z.boolean().describe("True when rows exist beyond this page"), + }) + .describe("Present only on paged tools (get_breakdown)") + .optional(), }); /** @@ -306,7 +318,8 @@ export const queryResultOutputSchema = z.object({ export function buildQueryStructuredContent( response: PlausibleResponse, metrics: string[], - dimensions: string[] + dimensions: string[], + pagination?: z.infer["pagination"] ) { return { metrics, @@ -315,5 +328,6 @@ export function buildQueryStructuredContent( dimensions: row.dimensions, metrics: row.metrics, })), + ...(pagination && { pagination }), }; } diff --git a/src/server.ts b/src/server.ts index 0ad0ddb..7d25ff5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -29,7 +29,7 @@ export interface ServerConfig { /** * Usage guidance surfaced to MCP clients during discovery/initialization and injected into the * model's context. It documents the Stats API v2 constraints that clients most often get wrong - * (date formats, future dates, illegal metric/dimension combinations) to cut the resulting 400s + * (date formats, illegal metric/dimension combinations) to cut the resulting 400s * at the source. */ const SERVER_INSTRUCTIONS = `This server queries Plausible Analytics (Stats API v2) for a site's traffic and conversions. All tools are read-only. @@ -37,7 +37,7 @@ const SERVER_INSTRUCTIONS = `This server queries Plausible Analytics (Stats API DATE RANGES (date_range, period_a, period_b): - Relative: "7d", "30d", "12mo" (N days/months back), or "day", "month", "year", "all". - Absolute: "YYYY-MM-DD,YYYY-MM-DD" (start,end), both inclusive. -- Dates must not be in the future. Today is the latest valid date; a range that ends after today returns a 400. +- Days after today have no data and count as zero, so end ranges at today or earlier to keep totals and averages accurate. METRICS: visitors, visits, pageviews, views_per_visit, bounce_rate, visit_duration, events, scroll_depth, percentage, conversion_rate, group_conversion_rate, average_revenue, total_revenue, time_on_page. @@ -54,6 +54,8 @@ COMBINATION RULES: - For goal conversions, use get_conversions rather than passing session metrics alongside a goal. - An empty-string visit:entry_page or visit:exit_page row is a session that recorded custom events but no pageview (e.g. the pageview was blocked). Its bounce_rate is always 0 because bounce counts single-pageview visits. Present it as "no pageview recorded", not as a page. +ERRORS: a failed call returns isError. The first text block is a readable message; the second is JSON { "error": { code, message, retryable, retry_after_seconds? } }. Codes: invalid_input, unauthorized, rate_limited, upstream_unavailable, invalid_query, internal. For rate_limited and upstream_unavailable, wait retry_after_seconds (when present) before retrying. Arguments that fail the tool's input schema are rejected before the tool runs, with only the readable message and no JSON block; fix the arguments named in it. + SITE: site_id is a bare domain (e.g. "example.com"). If omitted, the server's default site is used; if there is no default, the call fails — ask the user which site to query.`; const FEEDBACK_INSTRUCTIONS = ` diff --git a/src/tools/compare-periods.ts b/src/tools/compare-periods.ts index 82e4100..a1df3c5 100644 --- a/src/tools/compare-periods.ts +++ b/src/tools/compare-periods.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/server"; import type { PlausibleClient, PlausibleResponse } from "../plausible.js"; -import { reportToolError } from "../errors.js"; +import { toolErrorResult } from "../errors.js"; import { recordMcpClientInfo } from "../mcp-telemetry.js"; import { siteIdSchemaFor, @@ -151,11 +151,7 @@ export function register( structuredContent: comparison, }; } catch (error) { - const message = reportToolError(error, "compare_periods"); - return { - content: [{ type: "text" as const, text: `Error: ${message}` }], - isError: true, - }; + return toolErrorResult(error, "compare_periods"); } } ); diff --git a/src/tools/get-breakdown.ts b/src/tools/get-breakdown.ts index 46d5b51..d33e14b 100644 --- a/src/tools/get-breakdown.ts +++ b/src/tools/get-breakdown.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/server"; -import type { PlausibleClient } from "../plausible.js"; -import { reportToolError } from "../errors.js"; +import type { PlausibleClient, PlausibleResult } from "../plausible.js"; +import { toolErrorResult } from "../errors.js"; import { recordMcpClientInfo } from "../mcp-telemetry.js"; import { siteIdSchemaFor, @@ -31,6 +31,15 @@ function defaultMetrics(dimension: string): string[] { return ["visitors", "pageviews", "bounce_rate"]; } +const PLAUSIBLE_MAX_ROWS = 10_000; + +function byFirstMetricThenName(a: PlausibleResult, b: PlausibleResult): number { + const byMetric = (b.metrics[0] ?? 0) - (a.metrics[0] ?? 0); + if (byMetric !== 0) return byMetric; + const [nameA, nameB] = [String(a.dimensions[0]), String(b.dimensions[0])]; + return nameA < nameB ? -1 : nameA > nameB ? 1 : 0; +} + export function register( server: McpServer, client: PlausibleClient, @@ -57,7 +66,15 @@ export function register( .min(1) .max(1000) .default(20) - .describe("Max results to return") + .describe( + "Max results to return per page. Use offset to fetch further pages; pagination.has_more in the result says whether more rows exist." + ) + .optional(), + offset: z + .number() + .int() + .min(0) + .describe("Number of rows to skip, for paging past the first `limit` rows") .optional(), }), }, @@ -67,6 +84,7 @@ export function register( const siteId = resolveSiteId(args.site_id, defaultSiteId); const metrics = args.metrics ?? defaultMetrics(args.dimension); const limit = args.limit ?? 20; + const offset = args.offset ?? 0; assertNoShortcutOverlap(args.property_filters, { page: args.page }); const filters: unknown[][] = []; @@ -75,25 +93,47 @@ export function register( filters.push(...buildPropertyFilters(args.property_filters)); } + // Plausible orders only by the first metric; without a tiebreaker, tied rows can + // swap between requests and offset pages would skip or repeat them. Plausible + // rejects event:goal in order_by, so goals (few per site) are fetched in full and + // paged here over a stable order instead. + const isGoal = args.dimension === "event:goal"; const result = await client.query({ site_id: siteId, metrics, date_range: args.date_range, dimensions: [args.dimension], filters, - pagination: { limit }, + // One extra row reveals whether another page exists without relying on + // include.total_rows, which older self-hosted versions lack. + pagination: isGoal ? { limit: PLAUSIBLE_MAX_ROWS } : { limit: limit + 1, offset }, + order_by: isGoal + ? [[metrics[0], "desc"]] + : [ + [metrics[0], "desc"], + [args.dimension, "asc"], + ], }); + const rows = isGoal + ? [...result.results].sort(byFirstMetricThenName).slice(offset, offset + limit + 1) + : result.results; + const trimmed = { ...result, results: rows.slice(0, limit) }; + const pagination = { limit, offset, has_more: rows.length > limit }; + return { - content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], - structuredContent: buildQueryStructuredContent(result, metrics, [args.dimension]), + content: [ + { type: "text" as const, text: JSON.stringify({ ...trimmed, pagination }, null, 2) }, + ], + structuredContent: buildQueryStructuredContent( + trimmed, + metrics, + [args.dimension], + pagination + ), }; } catch (error) { - const message = reportToolError(error, "get_breakdown"); - return { - content: [{ type: "text" as const, text: `Error: ${message}` }], - isError: true, - }; + return toolErrorResult(error, "get_breakdown"); } } ); diff --git a/src/tools/get-conversions.ts b/src/tools/get-conversions.ts index 665d90e..7806fbd 100644 --- a/src/tools/get-conversions.ts +++ b/src/tools/get-conversions.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/server"; import type { PlausibleClient } from "../plausible.js"; -import { reportToolError } from "../errors.js"; +import { toolErrorResult } from "../errors.js"; import { recordMcpClientInfo } from "../mcp-telemetry.js"; import { siteIdSchemaFor, @@ -78,11 +78,7 @@ export function register( structuredContent: buildQueryStructuredContent(result, metrics, dimensions), }; } catch (error) { - const message = reportToolError(error, "get_conversions"); - return { - content: [{ type: "text" as const, text: `Error: ${message}` }], - isError: true, - }; + return toolErrorResult(error, "get_conversions"); } } ); diff --git a/src/tools/get-timeseries.ts b/src/tools/get-timeseries.ts index 5359d15..dd8155e 100644 --- a/src/tools/get-timeseries.ts +++ b/src/tools/get-timeseries.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/server"; import type { PlausibleClient } from "../plausible.js"; -import { reportToolError, UserFacingError } from "../errors.js"; +import { toolErrorResult, UserFacingError } from "../errors.js"; import { recordMcpClientInfo } from "../mcp-telemetry.js"; import { siteIdSchemaFor, @@ -89,11 +89,7 @@ export function register( structuredContent: buildQueryStructuredContent(result, metrics, [timeKey]), }; } catch (error) { - const message = reportToolError(error, "get_timeseries"); - return { - content: [{ type: "text" as const, text: `Error: ${message}` }], - isError: true, - }; + return toolErrorResult(error, "get_timeseries"); } } );