diff --git a/README.md b/README.md index c667ef0..b53e067 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,10 @@ 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. +### Dimension Filters + +Every query tool accepts `dimension_filters` to filter by any standard dimension, e.g. `[{ "dimension": "visit:utm_campaign", "operator": "contains", "values": ["spring-launch"] }]`. Operators are `is`, `is_not`, `contains`, `contains_not`, and multiple entries combine with AND (also with the `page`, `goal`, and `property_filters` parameters). Unlike the event-level `page` filter, `visit:*` dimension filters combine with session metrics (`visits`, `bounce_rate`, `visit_duration`) — use them to count sessions per campaign, source, or country. + ### 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. diff --git a/__tests__/schemas.test.ts b/__tests__/schemas.test.ts index fa26444..225bb46 100644 --- a/__tests__/schemas.test.ts +++ b/__tests__/schemas.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect } from "vitest"; import { + buildDimensionFilters, buildPropertyFilters, isCustomPropertyDimension, dimensionSchema, + dimensionFilterSchema, propertyFilterSchema, } from "../src/schemas.js"; @@ -63,6 +65,76 @@ describe("propertyFilterSchema", () => { }); }); +describe("dimensionFilterSchema", () => { + it("defaults the operator to is", () => { + const parsed = dimensionFilterSchema.parse({ + dimension: "visit:utm_campaign", + values: ["spring-launch"], + }); + expect(parsed.operator).toBe("is"); + }); + + it("rejects a custom property dimension", () => { + expect( + dimensionFilterSchema.safeParse({ + dimension: "event:props:plan", + values: ["pro"], + }).success + ).toBe(false); + }); + + it("rejects an empty values array", () => { + expect( + dimensionFilterSchema.safeParse({ + dimension: "visit:country", + values: [], + }).success + ).toBe(false); + }); + + it("rejects an unknown operator", () => { + expect( + dimensionFilterSchema.safeParse({ + dimension: "visit:country", + operator: "matches", + values: ["US"], + }).success + ).toBe(false); + }); +}); + +describe("buildDimensionFilters", () => { + it("defaults the operator to is", () => { + expect( + buildDimensionFilters([{ dimension: "visit:country", values: ["US"] }]) + ).toEqual([["is", "visit:country", ["US"]]]); + }); + + it("passes through explicit operators and multiple values", () => { + expect( + buildDimensionFilters([ + { + dimension: "visit:utm_campaign", + operator: "contains", + values: ["spring", "summer"], + }, + ]) + ).toEqual([["contains", "visit:utm_campaign", ["spring", "summer"]]]); + }); + + it("builds one filter per entry", () => { + expect( + buildDimensionFilters([ + { dimension: "visit:source", operator: "is", values: ["Google"] }, + { dimension: "visit:device", operator: "is_not", values: ["Mobile"] }, + ]) + ).toEqual([ + ["is", "visit:source", ["Google"]], + ["is_not", "visit:device", ["Mobile"]], + ]); + }); +}); + describe("buildPropertyFilters", () => { it("prefixes the property name and defaults the operator to is", () => { expect(buildPropertyFilters([{ property: "plan", values: ["pro"] }])).toEqual([ diff --git a/__tests__/tools/compare-periods.test.ts b/__tests__/tools/compare-periods.test.ts index 320e3fb..61dd844 100644 --- a/__tests__/tools/compare-periods.test.ts +++ b/__tests__/tools/compare-periods.test.ts @@ -139,6 +139,28 @@ describe("compare_periods tool", () => { } }); + it("passes dimension 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", + dimension_filters: [ + { + dimension: "visit:utm_campaign", + operator: "contains", + values: ["spring-launch"], + }, + ], + }); + + for (const call of client.query.mock.calls) { + expect(call[0].filters).toEqual([ + ["contains", "visit:utm_campaign", ["spring-launch"]], + ]); + } + }); + it("passes custom property filters to both calls", 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 4e01182..78f50d7 100644 --- a/__tests__/tools/get-breakdown.test.ts +++ b/__tests__/tools/get-breakdown.test.ts @@ -105,6 +105,54 @@ describe("get_breakdown tool", () => { ); }); + it("adds dimension filters", async () => { + const handler = getToolHandler(server, "get_breakdown"); + await handler({ + site_id: "example.com", + date_range: "7d", + dimension: "visit:utm_campaign", + metrics: ["visits"], + dimension_filters: [ + { + dimension: "visit:utm_campaign", + operator: "contains", + values: ["spring-launch"], + }, + ], + }); + + expect(client.query).toHaveBeenCalledWith( + expect.objectContaining({ + metrics: ["visits"], + filters: [["contains", "visit:utm_campaign", ["spring-launch"]]], + }) + ); + }); + + it("combines page, dimension, and custom property filters", async () => { + const handler = getToolHandler(server, "get_breakdown"); + await handler({ + site_id: "example.com", + date_range: "7d", + dimension: "visit:source", + page: "/pricing", + dimension_filters: [ + { dimension: "visit:country", operator: "is", values: ["US"] }, + ], + property_filters: [{ property: "plan", operator: "is", values: ["pro"] }], + }); + + expect(client.query).toHaveBeenCalledWith( + expect.objectContaining({ + filters: [ + ["is", "event:page", ["/pricing"]], + ["is", "visit:country", ["US"]], + ["is", "event:props:plan", ["pro"]], + ], + }) + ); + }); + it("adds custom property filters", async () => { const handler = getToolHandler(server, "get_breakdown"); await handler({ diff --git a/__tests__/tools/get-conversions.test.ts b/__tests__/tools/get-conversions.test.ts index 021f35d..b5cff66 100644 --- a/__tests__/tools/get-conversions.test.ts +++ b/__tests__/tools/get-conversions.test.ts @@ -74,6 +74,27 @@ describe("get_conversions tool", () => { ); }); + it("adds dimension filters", async () => { + const handler = getToolHandler(server, "get_conversions"); + await handler({ + site_id: "example.com", + date_range: "30d", + goal: "Signup", + dimension_filters: [ + { dimension: "visit:country", operator: "is", values: ["US"] }, + ], + }); + + expect(client.query).toHaveBeenCalledWith( + expect.objectContaining({ + filters: [ + ["is", "event:goal", ["Signup"]], + ["is", "visit:country", ["US"]], + ], + }) + ); + }); + it("filters by a custom property", async () => { const handler = getToolHandler(server, "get_conversions"); await handler({ diff --git a/__tests__/tools/get-timeseries.test.ts b/__tests__/tools/get-timeseries.test.ts index 83d122b..66c5017 100644 --- a/__tests__/tools/get-timeseries.test.ts +++ b/__tests__/tools/get-timeseries.test.ts @@ -131,6 +131,27 @@ describe("get_timeseries tool", () => { ); }); + it("adds dimension filters", async () => { + const handler = getToolHandler(server, "get_timeseries"); + await handler({ + site_id: "example.com", + date_range: "7d", + dimension_filters: [ + { + dimension: "visit:utm_campaign", + operator: "contains", + values: ["spring-launch"], + }, + ], + }); + + expect(client.query).toHaveBeenCalledWith( + expect.objectContaining({ + filters: [["contains", "visit:utm_campaign", ["spring-launch"]]], + }) + ); + }); + it("adds custom property filters alongside page filters", async () => { const handler = getToolHandler(server, "get_timeseries"); await handler({ diff --git a/evals/cases.ts b/evals/cases.ts index 9a86fcc..c300b7a 100644 --- a/evals/cases.ts +++ b/evals/cases.ts @@ -115,6 +115,40 @@ export const cases: EvalCase[] = [ return errors; }, }, + { + name: "filter a breakdown by a dimension value", + prompt: + "How many visits did each utm_campaign starting with `spring-launch` bring to example.com this month? Sessions, not visitors.", + expectedTool: "get_breakdown", + assertions: (args) => { + const errors: string[] = []; + if (args.dimension !== "visit:utm_campaign") { + errors.push( + `Expected dimension "visit:utm_campaign", got "${args.dimension}"` + ); + } + const filters = args.dimension_filters as + | Array<{ dimension?: string; values?: string[] }> + | undefined; + const match = filters?.find( + (f) => + f.dimension === "visit:utm_campaign" && + (f.values ?? []).some((v) => v.includes("spring-launch")) + ); + if (!match) { + errors.push( + `Expected a dimension_filters entry on visit:utm_campaign for "spring-launch", got ${JSON.stringify(args.dimension_filters)}` + ); + } + const metrics = args.metrics as string[] | undefined; + if (metrics && !metrics.includes("visits")) { + errors.push( + `Expected metrics to include "visits", got ${JSON.stringify(metrics)}` + ); + } + return errors; + }, + }, { name: "filter timeseries by a custom property value", prompt: diff --git a/src/schemas.ts b/src/schemas.ts index 7f1ac9a..aaa8bcc 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -152,7 +152,7 @@ export const dimensionSchema = z '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 = [ +export const FILTER_OPERATORS = [ "is", "is_not", "contains", @@ -161,7 +161,7 @@ export const PROPERTY_FILTER_OPERATORS = [ export type PropertyFilter = { property: string; - operator?: (typeof PROPERTY_FILTER_OPERATORS)[number]; + operator?: (typeof FILTER_OPERATORS)[number]; values: string[]; }; @@ -174,7 +174,7 @@ export const propertyFilterSchema = z.object({ 'Custom property name WITHOUT the "event:props:" prefix (e.g. "plan" targets event:props:plan)' ), operator: z - .enum(PROPERTY_FILTER_OPERATORS) + .enum(FILTER_OPERATORS) .default("is") .describe("Match operator: is, is_not, contains, contains_not (default: is)"), values: z @@ -202,6 +202,41 @@ export function buildPropertyFilters(filters: PropertyFilter[]): unknown[][] { ]); } +export type DimensionFilter = { + dimension: (typeof VALID_DIMENSIONS)[number]; + operator?: (typeof FILTER_OPERATORS)[number]; + values: string[]; +}; + +export const dimensionFilterSchema = z.object({ + dimension: z + .enum(VALID_DIMENSIONS) + .describe('Dimension to filter on (e.g. "visit:utm_campaign", "visit:country")'), + operator: z + .enum(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 dimension against"), +}); + +export const dimensionFiltersSchema = z + .array(dimensionFilterSchema) + .describe( + 'Filter by standard dimensions, e.g. [{ "dimension": "visit:utm_campaign", "operator": "contains", "values": ["spring-launch"] }]. Combined with other filters using AND. visit:* filters work with session metrics (visits, bounce_rate, visit_duration); for custom properties use property_filters instead.' + ) + .optional(); + +/** + * Build Plausible Stats API v2 filters for standard dimensions. + * Each entry becomes `[operator, "", values]`. + */ +export function buildDimensionFilters(filters: DimensionFilter[]): unknown[][] { + return filters.map((f) => [f.operator ?? "is", f.dimension, 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 8f43a7d..20124d1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -43,10 +43,13 @@ 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. +DIMENSION FILTERS: every query tool accepts dimension_filters to filter by a standard dimension, e.g. [{ "dimension": "visit:utm_campaign", "operator": "contains", "values": ["spring-launch"] }] — operators are is, is_not, contains, contains_not; entries combine with AND (and with the page/goal/property filters). + +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 as in dimension_filters. 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. +- visit:* dimension_filters DO combine with session metrics — to count visits per campaign, filter on visit:utm_campaign instead of the page filter. - For goal conversions, use get_conversions rather than passing session metrics alongside a goal. 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.`; diff --git a/src/tools/compare-periods.ts b/src/tools/compare-periods.ts index adf4ecf..3a6df42 100644 --- a/src/tools/compare-periods.ts +++ b/src/tools/compare-periods.ts @@ -8,10 +8,12 @@ import { pageSchema, goalSchema, metricsSchema, + dimensionFiltersSchema, propertyFiltersSchema, DEFAULT_METRICS, buildPageFilter, buildGoalFilter, + buildDimensionFilters, buildPropertyFilters, } from "../schemas.js"; import { resolveSiteId } from "./get-timeseries.js"; @@ -104,6 +106,7 @@ export function register( page: pageSchema, metrics: metricsSchema, goal: goalSchema, + dimension_filters: dimensionFiltersSchema, property_filters: propertyFiltersSchema, }, }, @@ -115,6 +118,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.dimension_filters?.length) { + filters.push(...buildDimensionFilters(args.dimension_filters)); + } if (args.property_filters?.length) { filters.push(...buildPropertyFilters(args.property_filters)); } diff --git a/src/tools/get-breakdown.ts b/src/tools/get-breakdown.ts index 21b3cd3..b2dfcc2 100644 --- a/src/tools/get-breakdown.ts +++ b/src/tools/get-breakdown.ts @@ -8,8 +8,10 @@ import { pageSchema, metricsSchema, dimensionSchema, + dimensionFiltersSchema, propertyFiltersSchema, buildPageFilter, + buildDimensionFilters, buildPropertyFilters, queryResultOutputSchema, buildQueryStructuredContent, @@ -34,6 +36,7 @@ export function register( date_range: dateRangeSchema, dimension: dimensionSchema, page: pageSchema, + dimension_filters: dimensionFiltersSchema, property_filters: propertyFiltersSchema, metrics: metricsSchema, limit: z @@ -54,6 +57,9 @@ export function register( const filters: unknown[][] = []; if (args.page) filters.push(buildPageFilter(args.page)); + if (args.dimension_filters?.length) { + filters.push(...buildDimensionFilters(args.dimension_filters)); + } if (args.property_filters?.length) { filters.push(...buildPropertyFilters(args.property_filters)); } diff --git a/src/tools/get-conversions.ts b/src/tools/get-conversions.ts index bdb897b..ac03841 100644 --- a/src/tools/get-conversions.ts +++ b/src/tools/get-conversions.ts @@ -7,9 +7,11 @@ import { dateRangeSchema, pageSchema, goalSchema, + dimensionFiltersSchema, propertyFiltersSchema, buildPageFilter, buildGoalFilter, + buildDimensionFilters, buildPropertyFilters, queryResultOutputSchema, buildQueryStructuredContent, @@ -34,6 +36,7 @@ export function register( date_range: dateRangeSchema, goal: goalSchema, page: pageSchema, + dimension_filters: dimensionFiltersSchema, property_filters: propertyFiltersSchema, breakdown_by_page: z .boolean() @@ -50,6 +53,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.dimension_filters?.length) { + filters.push(...buildDimensionFilters(args.dimension_filters)); + } if (args.property_filters?.length) { filters.push(...buildPropertyFilters(args.property_filters)); } diff --git a/src/tools/get-timeseries.ts b/src/tools/get-timeseries.ts index a40cb83..5845667 100644 --- a/src/tools/get-timeseries.ts +++ b/src/tools/get-timeseries.ts @@ -8,10 +8,12 @@ import { pageSchema, goalSchema, metricsSchema, + dimensionFiltersSchema, propertyFiltersSchema, DEFAULT_METRICS, buildPageFilter, buildGoalFilter, + buildDimensionFilters, buildPropertyFilters, queryResultOutputSchema, buildQueryStructuredContent, @@ -53,6 +55,7 @@ export function register( page: pageSchema, metrics: metricsSchema, goal: goalSchema, + dimension_filters: dimensionFiltersSchema, property_filters: propertyFiltersSchema, }, }, @@ -65,6 +68,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.dimension_filters?.length) { + filters.push(...buildDimensionFilters(args.dimension_filters)); + } if (args.property_filters?.length) { filters.push(...buildPropertyFilters(args.property_filters)); }