From 28c8ae285ba03b45edb42f04364604f8a97f066a Mon Sep 17 00:00:00 2001 From: Krzysztof Modras Date: Fri, 24 Jul 2026 13:38:50 +0200 Subject: [PATCH] Support querying custom event properties Break down by any event:props: dimension and filter any query tool by custom property values, so sites can slice by the properties their own tracker sends. --- README.md | 7 ++ __tests__/schemas.test.ts | 92 +++++++++++++++++++++++++ __tests__/tools/compare-periods.test.ts | 14 ++++ __tests__/tools/get-breakdown.test.ts | 52 ++++++++++++++ __tests__/tools/get-conversions.test.ts | 19 +++++ __tests__/tools/get-timeseries.test.ts | 19 +++++ evals/cases.ts | 36 ++++++++++ src/schemas.ts | 79 +++++++++++++++++++++ src/server.ts | 2 + src/tools/compare-periods.ts | 6 ++ src/tools/get-breakdown.ts | 12 ++-- src/tools/get-conversions.ts | 6 ++ src/tools/get-timeseries.ts | 6 ++ 13 files changed, 346 insertions(+), 4 deletions(-) create mode 100644 __tests__/schemas.test.ts diff --git a/README.md b/README.md index b5b3914..c667ef0 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,13 @@ This server wraps the [Plausible Stats API v2](https://plausible.io/docs/stats-a The `*_name` geography dimensions return human-readable names (e.g. "Canada"); the plain `visit:country`/`region`/`city` return ISO/Geoname codes. +### Custom Properties + +Sites send their own [custom event properties](https://plausible.io/docs/custom-props/introduction), addressed as `event:props:`. These are site-specific, so there's no fixed list. + +- **Break down by** a custom property: pass `get_breakdown` a `dimension` of `event:props:` (e.g. `event:props:plan`). +- **Filter by** a custom property on any query tool via `property_filters`, e.g. `[{ "property": "plan", "operator": "is", "values": ["pro"] }]`. The `property` is the bare name (no `event:props:` prefix); operators are `is`, `is_not`, `contains`, `contains_not`, and multiple entries combine with AND. + ## Development ```bash diff --git a/__tests__/schemas.test.ts b/__tests__/schemas.test.ts new file mode 100644 index 0000000..fa26444 --- /dev/null +++ b/__tests__/schemas.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { + buildPropertyFilters, + isCustomPropertyDimension, + dimensionSchema, + propertyFilterSchema, +} from "../src/schemas.js"; + +describe("isCustomPropertyDimension", () => { + it("accepts event:props:", () => { + expect(isCustomPropertyDimension("event:props:plan")).toBe(true); + }); + + it("rejects standard dimensions", () => { + expect(isCustomPropertyDimension("event:page")).toBe(false); + expect(isCustomPropertyDimension("visit:source")).toBe(false); + }); + + it("rejects the bare prefix with no name", () => { + expect(isCustomPropertyDimension("event:props:")).toBe(false); + }); +}); + +describe("dimensionSchema", () => { + it("parses a standard dimension", () => { + expect(dimensionSchema.safeParse("event:page").success).toBe(true); + expect(dimensionSchema.safeParse("visit:country_name").success).toBe(true); + }); + + it("parses a custom property dimension", () => { + expect(dimensionSchema.safeParse("event:props:destination_host").success).toBe(true); + }); + + it("rejects an unknown dimension", () => { + expect(dimensionSchema.safeParse("event:nonsense").success).toBe(false); + }); + + it("rejects the bare custom-property prefix", () => { + expect(dimensionSchema.safeParse("event:props:").success).toBe(false); + }); +}); + +describe("propertyFilterSchema", () => { + it("defaults the operator to is", () => { + const parsed = propertyFilterSchema.parse({ property: "plan", values: ["pro"] }); + expect(parsed.operator).toBe("is"); + }); + + it("rejects an empty values array", () => { + expect( + propertyFilterSchema.safeParse({ property: "plan", values: [] }).success + ).toBe(false); + }); + + it("rejects an unknown operator", () => { + expect( + propertyFilterSchema.safeParse({ + property: "plan", + operator: "matches", + values: ["pro"], + }).success + ).toBe(false); + }); +}); + +describe("buildPropertyFilters", () => { + it("prefixes the property name and defaults the operator to is", () => { + expect(buildPropertyFilters([{ property: "plan", values: ["pro"] }])).toEqual([ + ["is", "event:props:plan", ["pro"]], + ]); + }); + + it("passes through explicit operators and multiple values", () => { + expect( + buildPropertyFilters([ + { property: "destination_host", operator: "contains", values: ["github", "gitlab"] }, + ]) + ).toEqual([["contains", "event:props:destination_host", ["github", "gitlab"]]]); + }); + + it("builds one filter per entry", () => { + expect( + buildPropertyFilters([ + { property: "plan", operator: "is", values: ["pro"] }, + { property: "ctry", operator: "is_not", values: ["US"] }, + ]) + ).toEqual([ + ["is", "event:props:plan", ["pro"]], + ["is_not", "event:props:ctry", ["US"]], + ]); + }); +}); diff --git a/__tests__/tools/compare-periods.test.ts b/__tests__/tools/compare-periods.test.ts index 482605e..320e3fb 100644 --- a/__tests__/tools/compare-periods.test.ts +++ b/__tests__/tools/compare-periods.test.ts @@ -139,6 +139,20 @@ describe("compare_periods tool", () => { } }); + it("passes custom property filters to both calls", async () => { + const handler = getToolHandler(server, "compare_periods"); + await handler({ + site_id: "example.com", + period_a: "2024-01-01,2024-01-07", + period_b: "2024-01-08,2024-01-14", + property_filters: [{ property: "plan", operator: "is", values: ["pro"] }], + }); + + for (const call of client.query.mock.calls) { + expect(call[0].filters).toEqual([["is", "event:props:plan", ["pro"]]]); + } + }); + it("uses default site_id", async () => { const handler = getToolHandler(server, "compare_periods"); await handler({ diff --git a/__tests__/tools/get-breakdown.test.ts b/__tests__/tools/get-breakdown.test.ts index 7b81833..4e01182 100644 --- a/__tests__/tools/get-breakdown.test.ts +++ b/__tests__/tools/get-breakdown.test.ts @@ -89,6 +89,58 @@ describe("get_breakdown tool", () => { } }); + it("breaks down by a custom property dimension", async () => { + const handler = getToolHandler(server, "get_breakdown"); + const result = await handler({ + site_id: "example.com", + date_range: "30d", + dimension: "event:props:destination_host", + }); + + expect(result.isError).toBeFalsy(); + expect(client.query).toHaveBeenCalledWith( + expect.objectContaining({ + dimensions: ["event:props:destination_host"], + }) + ); + }); + + it("adds custom property filters", async () => { + const handler = getToolHandler(server, "get_breakdown"); + await handler({ + site_id: "example.com", + date_range: "7d", + dimension: "event:page", + property_filters: [{ property: "plan", operator: "is", values: ["pro"] }], + }); + + expect(client.query).toHaveBeenCalledWith( + expect.objectContaining({ + filters: [["is", "event:props:plan", ["pro"]]], + }) + ); + }); + + it("combines page and custom property filters", async () => { + const handler = getToolHandler(server, "get_breakdown"); + await handler({ + site_id: "example.com", + date_range: "7d", + dimension: "event:props:destination_host", + page: "/pricing", + property_filters: [{ property: "ctry", operator: "is", values: ["US"] }], + }); + + expect(client.query).toHaveBeenCalledWith( + expect.objectContaining({ + filters: [ + ["is", "event:page", ["/pricing"]], + ["is", "event:props:ctry", ["US"]], + ], + }) + ); + }); + it("returns structuredContent labelled with the metric and dimension keys", async () => { const handler = getToolHandler(server, "get_breakdown"); const result = await handler({ diff --git a/__tests__/tools/get-conversions.test.ts b/__tests__/tools/get-conversions.test.ts index a9ccfc3..021f35d 100644 --- a/__tests__/tools/get-conversions.test.ts +++ b/__tests__/tools/get-conversions.test.ts @@ -74,6 +74,25 @@ describe("get_conversions tool", () => { ); }); + it("filters by a custom property", async () => { + const handler = getToolHandler(server, "get_conversions"); + await handler({ + site_id: "example.com", + date_range: "30d", + goal: "Signup", + property_filters: [{ property: "plan", operator: "is", values: ["pro"] }], + }); + + expect(client.query).toHaveBeenCalledWith( + expect.objectContaining({ + filters: [ + ["is", "event:goal", ["Signup"]], + ["is", "event:props:plan", ["pro"]], + ], + }) + ); + }); + it("uses default site_id", async () => { const handler = getToolHandler(server, "get_conversions"); await handler({ date_range: "7d" }); diff --git a/__tests__/tools/get-timeseries.test.ts b/__tests__/tools/get-timeseries.test.ts index f244e97..83d122b 100644 --- a/__tests__/tools/get-timeseries.test.ts +++ b/__tests__/tools/get-timeseries.test.ts @@ -131,6 +131,25 @@ describe("get_timeseries tool", () => { ); }); + it("adds custom property filters alongside page filters", async () => { + const handler = getToolHandler(server, "get_timeseries"); + await handler({ + site_id: "example.com", + date_range: "7d", + page: "/pricing", + property_filters: [{ property: "plan", operator: "is", values: ["pro"] }], + }); + + expect(client.query).toHaveBeenCalledWith( + expect.objectContaining({ + filters: [ + ["is", "event:page", ["/pricing"]], + ["is", "event:props:plan", ["pro"]], + ], + }) + ); + }); + it("uses custom metrics", async () => { const handler = getToolHandler(server, "get_timeseries"); await handler({ diff --git a/evals/cases.ts b/evals/cases.ts index c96b5ee..9a86fcc 100644 --- a/evals/cases.ts +++ b/evals/cases.ts @@ -100,4 +100,40 @@ export const cases: EvalCase[] = [ return errors; }, }, + { + name: "breakdown by a custom property", + prompt: + "Break down visitors by the custom property `destination_host` for example.com this month.", + expectedTool: "get_breakdown", + assertions: (args) => { + const errors: string[] = []; + if (args.dimension !== "event:props:destination_host") { + errors.push( + `Expected dimension "event:props:destination_host", got "${args.dimension}"` + ); + } + return errors; + }, + }, + { + name: "filter timeseries by a custom property value", + prompt: + "Show daily visitors to example.com over the last 30 days, but only for events where the custom property `plan` is `pro`.", + expectedTool: "get_timeseries", + assertions: (args) => { + const errors: string[] = []; + const filters = args.property_filters as + | Array<{ property?: string; values?: string[] }> + | undefined; + const match = filters?.find( + (f) => f.property === "plan" && (f.values ?? []).includes("pro") + ); + if (!match) { + errors.push( + `Expected a property_filters entry for plan=pro, got ${JSON.stringify(args.property_filters)}` + ); + } + return errors; + }, + }, ]; diff --git a/src/schemas.ts b/src/schemas.ts index 8352319..7f1ac9a 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -123,6 +123,85 @@ export function buildGoalFilter(goal: string): unknown[] { return ["is", "event:goal", [goal]]; } +/** + * Custom event properties are addressed in the Stats API v2 as `event:props:`, both + * as breakdown dimensions and as filter operands. The set of property names is defined by + * whatever the site's own tracker sends, so it can't be enumerated here — the tools accept + * any `event:props:` generically. + */ +export const CUSTOM_PROPERTY_PREFIX = "event:props:"; + +export function isCustomPropertyDimension(value: string): boolean { + return ( + value.startsWith(CUSTOM_PROPERTY_PREFIX) && + value.length > CUSTOM_PROPERTY_PREFIX.length + ); +} + +const customPropertyDimensionSchema = z + .string() + .regex( + /^event:props:\S/, + 'Custom property dimension must look like "event:props:"' + ) + .max(200); + +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).' + ); + +export const PROPERTY_FILTER_OPERATORS = [ + "is", + "is_not", + "contains", + "contains_not", +] as const; + +export type PropertyFilter = { + property: string; + operator?: (typeof PROPERTY_FILTER_OPERATORS)[number]; + values: string[]; +}; + +export const propertyFilterSchema = z.object({ + property: z + .string() + .min(1) + .max(200) + .describe( + 'Custom property name WITHOUT the "event:props:" prefix (e.g. "plan" targets event:props:plan)' + ), + operator: z + .enum(PROPERTY_FILTER_OPERATORS) + .default("is") + .describe("Match operator: is, is_not, contains, contains_not (default: is)"), + values: z + .array(z.string()) + .min(1) + .describe("One or more values to match the property against"), +}); + +export const propertyFiltersSchema = z + .array(propertyFilterSchema) + .describe( + 'Filter by custom event properties, e.g. [{ "property": "plan", "operator": "is", "values": ["pro"] }]. Combined with other filters using AND.' + ) + .optional(); + +/** + * Build Plausible Stats API v2 filters for custom event properties. + * Each entry becomes `[operator, "event:props:", values]`. + */ +export function buildPropertyFilters(filters: PropertyFilter[]): unknown[][] { + return filters.map((f) => [ + f.operator ?? "is", + `${CUSTOM_PROPERTY_PREFIX}${f.property}`, + f.values, + ]); +} + /** * Shared `outputSchema` (a ZodRawShape) for the query-style tools. Declaring it makes the * tools return machine-readable `structuredContent` (validated by the MCP SDK) alongside the diff --git a/src/server.ts b/src/server.ts index 0dc910c..8f43a7d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -43,6 +43,8 @@ METRICS: visitors, visits, pageviews, views_per_visit, bounce_rate, visit_durati DIMENSIONS (get_breakdown): event:page, event:goal, event:hostname, visit:entry_page, visit:exit_page, visit:source, visit:referrer, visit:channel, visit:utm_medium/source/campaign/content/term, visit:device, visit:browser(_version), visit:os(_version). Geography comes in two forms: visit:country/region/city return ISO/Geoname codes, while visit:country_name/region_name/city_name return human-readable names — prefer the *_name variants when presenting geography to users. +CUSTOM PROPERTIES: sites send their own custom event properties, addressed as "event:props:". Break down by one in get_breakdown with dimension "event:props:" (e.g. "event:props:plan"). Filter by one on any tool with property_filters, e.g. [{ "property": "plan", "operator": "is", "values": ["pro"] }] — the property is the bare name without the "event:props:" prefix; operators are is, is_not, contains, contains_not. Property names are site-specific; if you don't know them, break down by the property to see its values, or ask the user. + COMBINATION RULES: - Session metrics (bounce_rate, visit_duration, views_per_visit, visits) cannot be combined with event-level dimensions (event:goal, event:page, event:hostname) or goal filters. Use event-level metrics (visitors, pageviews, events, conversion_rate) in those cases. - For goal conversions, use get_conversions rather than passing session metrics alongside a goal. diff --git a/src/tools/compare-periods.ts b/src/tools/compare-periods.ts index d60c415..adf4ecf 100644 --- a/src/tools/compare-periods.ts +++ b/src/tools/compare-periods.ts @@ -8,9 +8,11 @@ import { pageSchema, goalSchema, metricsSchema, + propertyFiltersSchema, DEFAULT_METRICS, buildPageFilter, buildGoalFilter, + buildPropertyFilters, } from "../schemas.js"; import { resolveSiteId } from "./get-timeseries.js"; @@ -102,6 +104,7 @@ export function register( page: pageSchema, metrics: metricsSchema, goal: goalSchema, + property_filters: propertyFiltersSchema, }, }, async (args) => { @@ -112,6 +115,9 @@ export function register( const filters: unknown[][] = []; if (args.page) filters.push(buildPageFilter(args.page)); if (args.goal) filters.push(buildGoalFilter(args.goal)); + if (args.property_filters?.length) { + filters.push(...buildPropertyFilters(args.property_filters)); + } const queryBase = { site_id: siteId, diff --git a/src/tools/get-breakdown.ts b/src/tools/get-breakdown.ts index ea3e483..21b3cd3 100644 --- a/src/tools/get-breakdown.ts +++ b/src/tools/get-breakdown.ts @@ -7,8 +7,10 @@ import { dateRangeSchema, pageSchema, metricsSchema, - VALID_DIMENSIONS, + dimensionSchema, + propertyFiltersSchema, buildPageFilter, + buildPropertyFilters, queryResultOutputSchema, buildQueryStructuredContent, } from "../schemas.js"; @@ -30,10 +32,9 @@ export function register( inputSchema: { site_id: siteIdSchemaFor(defaultSiteId), date_range: dateRangeSchema, - dimension: z - .enum(VALID_DIMENSIONS) - .describe("Dimension to group results by"), + dimension: dimensionSchema, page: pageSchema, + property_filters: propertyFiltersSchema, metrics: metricsSchema, limit: z .number() @@ -53,6 +54,9 @@ export function register( const filters: unknown[][] = []; if (args.page) filters.push(buildPageFilter(args.page)); + if (args.property_filters?.length) { + filters.push(...buildPropertyFilters(args.property_filters)); + } const result = await client.query({ site_id: siteId, diff --git a/src/tools/get-conversions.ts b/src/tools/get-conversions.ts index 2100a2d..bdb897b 100644 --- a/src/tools/get-conversions.ts +++ b/src/tools/get-conversions.ts @@ -7,8 +7,10 @@ import { dateRangeSchema, pageSchema, goalSchema, + propertyFiltersSchema, buildPageFilter, buildGoalFilter, + buildPropertyFilters, queryResultOutputSchema, buildQueryStructuredContent, } from "../schemas.js"; @@ -32,6 +34,7 @@ export function register( date_range: dateRangeSchema, goal: goalSchema, page: pageSchema, + property_filters: propertyFiltersSchema, breakdown_by_page: z .boolean() .default(false) @@ -47,6 +50,9 @@ export function register( const filters: unknown[][] = []; if (args.goal) filters.push(buildGoalFilter(args.goal)); if (args.page) filters.push(buildPageFilter(args.page)); + if (args.property_filters?.length) { + filters.push(...buildPropertyFilters(args.property_filters)); + } const dimensions = args.breakdown_by_page ? ["event:goal", "event:page"] diff --git a/src/tools/get-timeseries.ts b/src/tools/get-timeseries.ts index 24cad01..a40cb83 100644 --- a/src/tools/get-timeseries.ts +++ b/src/tools/get-timeseries.ts @@ -8,9 +8,11 @@ import { pageSchema, goalSchema, metricsSchema, + propertyFiltersSchema, DEFAULT_METRICS, buildPageFilter, buildGoalFilter, + buildPropertyFilters, queryResultOutputSchema, buildQueryStructuredContent, } from "../schemas.js"; @@ -51,6 +53,7 @@ export function register( page: pageSchema, metrics: metricsSchema, goal: goalSchema, + property_filters: propertyFiltersSchema, }, }, async (args) => { @@ -62,6 +65,9 @@ export function register( const filters: unknown[][] = []; if (args.page) filters.push(buildPageFilter(args.page)); if (args.goal) filters.push(buildGoalFilter(args.goal)); + if (args.property_filters?.length) { + filters.push(...buildPropertyFilters(args.property_filters)); + } const result = await client.query({ site_id: siteId,