Skip to content
Open
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>`. These are site-specific, so there's no fixed list.

- **Break down by** a custom property: pass `get_breakdown` a `dimension` of `event:props:<name>` (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
Expand Down
92 changes: 92 additions & 0 deletions __tests__/schemas.test.ts
Original file line number Diff line number Diff line change
@@ -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:<name>", () => {
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"]],
]);
});
});
14 changes: 14 additions & 0 deletions __tests__/tools/compare-periods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
52 changes: 52 additions & 0 deletions __tests__/tools/get-breakdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
19 changes: 19 additions & 0 deletions __tests__/tools/get-conversions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
19 changes: 19 additions & 0 deletions __tests__/tools/get-timeseries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
36 changes: 36 additions & 0 deletions evals/cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
},
];
79 changes: 79 additions & 0 deletions src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>`, 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:<name>` 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:<name>"'
)
.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:<name>" (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:<name>", 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
Expand Down
2 changes: 2 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<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.

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.
Expand Down
Loading