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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>`. These are site-specific, so there's no fixed list.
Expand Down
72 changes: 72 additions & 0 deletions __tests__/schemas.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { describe, it, expect } from "vitest";
import {
buildDimensionFilters,
buildPropertyFilters,
isCustomPropertyDimension,
dimensionSchema,
dimensionFilterSchema,
propertyFilterSchema,
} from "../src/schemas.js";

Expand Down Expand Up @@ -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([
Expand Down
22 changes: 22 additions & 0 deletions __tests__/tools/compare-periods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
48 changes: 48 additions & 0 deletions __tests__/tools/get-breakdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
21 changes: 21 additions & 0 deletions __tests__/tools/get-conversions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
21 changes: 21 additions & 0 deletions __tests__/tools/get-timeseries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
34 changes: 34 additions & 0 deletions evals/cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
41 changes: 38 additions & 3 deletions src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>" (e.g. event:props:plan).'
);

export const PROPERTY_FILTER_OPERATORS = [
export const FILTER_OPERATORS = [
"is",
"is_not",
"contains",
Expand All @@ -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[];
};

Expand All @@ -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
Expand Down Expand Up @@ -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, "<dimension>", 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
Expand Down
5 changes: 4 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>". Break down by one in get_breakdown with dimension "event:props:<name>" (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:<name>". Break down by one in get_breakdown with dimension "event:props:<name>" (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.`;
Expand Down
6 changes: 6 additions & 0 deletions src/tools/compare-periods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -104,6 +106,7 @@ export function register(
page: pageSchema,
metrics: metricsSchema,
goal: goalSchema,
dimension_filters: dimensionFiltersSchema,
property_filters: propertyFiltersSchema,
},
},
Expand All @@ -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));
}
Expand Down
Loading
Loading