Skip to content

Commit 014ea7a

Browse files
authored
Merge pull request #10 from eisbachcode/feat/analytics-mcp-tools
feat(analytics): four read-only MCP tools
2 parents 7d75146 + 4ff7bd9 commit 014ea7a

14 files changed

Lines changed: 1257 additions & 49 deletions

File tree

‎.changeset/analytics-mcp-tools.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@eisbachcode/emdash-plugin-analytics": minor
3+
---
4+
5+
Adds four read-only MCP tools, so an AI agent connected to EmDash's MCP server can ask for the numbers: `analytics__top_entries`, `analytics__unviewed_entries`, `analytics__entry_views` and `analytics__site_totals`. They read what the plugin has stored, never call Cloudflare, and every answer says which UTC days it covers, where stored history starts and when the last sync ran.
6+
7+
The tools stay off until an administrator turns on **Agent access** for the plugin under Plugins. A caller needs `plugins:read` (editor and above) and a token with the `mcp:tools:analytics` or `mcp:tools` scope.
8+
9+
On EmDash 0.39 and 0.40 only sandboxed and registry installs get the tools. A plugin registered in `plugins: []` lists none; that is an EmDash limitation, and nothing else about the plugin changes there.

‎packages/analytics/AGENTS.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,21 @@ at the workspace root is enough.
4545
undefined there, so read `routeCtx.ui?.locale` and fall back to English;
4646
never return empty blocks when it is missing. The editor panel gets it in
4747
both modes, with `ui.entry`.
48+
- **MCP schemas never reach the runtime.** `emdash-plugin build` strips
49+
the `mcp` property of `src/plugin.ts` from the bundle and writes the zod
50+
schemas into the manifest as JSON Schema. `src/tools/declare.ts` is
51+
referenced only from there, so zod stays a dev dependency and out of the
52+
bundle; a schema imported by a route handler would pull it back in. The
53+
handlers in `src/tools/load.ts` validate by hand, because the routes are
54+
also reachable over HTTP without the MCP server's validation.
55+
- **An MCP output schema is strict.** Every object becomes
56+
`additionalProperties: false` and the MCP server rejects an answer that
57+
does not match, so a loader's result and its declared output have to
58+
agree key for key. `tests/tools.test.ts` checks each answer against the
59+
schema the build wrote.
60+
- **MCP tools reach sandboxed installs only.** EmDash's in-process loader
61+
reads tools from the runtime module, where the build removed them, so a
62+
`plugins: []` install lists none. Test tools sandboxed.
4863
- **Block Kit keeps no state.** Anything a page needs to remember between
4964
interactions travels in an `action_id`, a button `value` or a table
5065
cursor (see `src/ui/content.ts`).

‎packages/analytics/README.md‎

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ Most analytics plugins either inject a script or show site-wide numbers.
66
This one keeps a `path → entry` index, so views can be attributed to the
77
entry that earned them rather than to a URL string.
88

9-
> **Status: first release.** Cloudflare Web Analytics plus demo data: a
10-
> dashboard widget, an Analytics page with a setup check, a per-entry page
11-
> and an Analytics panel in the entry editor. Tested on EmDash 0.39 and 0.40.
12-
> See "Not in this version".
9+
> **Status: early releases.** Cloudflare Web Analytics plus demo data: a
10+
> dashboard widget, an Analytics page with a setup check, a per-entry page,
11+
> an Analytics panel in the entry editor and four read-only MCP tools.
12+
> Tested on EmDash 0.39 and 0.40. See "Not in this version".
1313
1414
The screenshots show demo data on a site made from EmDash's blog template.
1515

@@ -108,6 +108,40 @@ page views and the path they are counted at, and on a translated entry
108108
each language's numbers and their total. An entry that is not published
109109
at a public URL says so instead.
110110

111+
## MCP tools
112+
113+
EmDash's MCP server can hand the same numbers to an AI agent. The plugin
114+
declares four read-only tools:
115+
116+
| Tool | Answers |
117+
|---|---|
118+
| `analytics__top_entries` | The most viewed published entries over 7 or 30 days, optionally for one collection |
119+
| `analytics__unviewed_entries` | Published entries without a single view in 7 or 30 days |
120+
| `analytics__entry_views` | One entry's 7- and 30-day views, found by id or by path, with its translations and their total |
121+
| `analytics__site_totals` | Site visits and page views over 7, 30 or 90 days, against the period before |
122+
123+
They read what the sync stored and never call Cloudflare. Every answer
124+
names its window in UTC days, where stored history starts, whether the
125+
content index is complete and when the last sync ran, so an agent can say
126+
that the data is thin instead of presenting it as exact.
127+
128+
To use them:
129+
130+
1. Under **Plugins**, open Analytics and turn on **Agent access** after
131+
reviewing the tools.
132+
2. Call them as a user with `plugins:read` (editor and above), through a
133+
token with the `mcp:tools:analytics` scope, or `mcp:tools` for every
134+
plugin's tools.
135+
136+
After an update that changes a tool, EmDash stops serving the tools until
137+
they are approved again, but the switch stays on. Turn it off and on.
138+
139+
**Sandboxed installs only, for now.** A plugin registered in `plugins: []`
140+
gets none of its MCP tools on EmDash 0.39 and 0.40: the plugin build keeps
141+
the tool declarations out of the runtime module, and EmDash's in-process
142+
loader looks for them only there. Under `sandboxed: []` or installed from
143+
the registry, the tools work. This is an EmDash limitation, not a setting.
144+
111145
## What you need
112146

113147
| | |
@@ -263,6 +297,9 @@ The editor panel declares `content:edit_own`, and EmDash also checks that
263297
the user may edit that entry: **authors see their own entries' numbers,
264298
editors and admins every entry's**, contributors none.
265299

300+
The MCP tools declare `plugins:read` like the pages, so an agent acting for
301+
an author gets a permission error from all of them.
302+
266303
Authors and contributors will still see the widget card in the dashboard
267304
and get a permission error inside it — EmDash renders every declared widget
268305
for every role and gates on dispatch. That is an upstream gap, not

‎packages/analytics/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@
6464
"emdash": ">=0.39.0 <1.0.0",
6565
"tsdown": "0.20.3",
6666
"typescript": "^5.9.0",
67-
"vitest": "^4.1.0"
67+
"vitest": "^4.1.0",
68+
"zod": "4.5.4"
6869
},
6970
"publishConfig": {
7071
"access": "public"

‎packages/analytics/src/plugin.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Hooks, the cron dispatcher and the single admin route.
2+
* Hooks, the cron dispatcher, the admin routes and the MCP tools.
33
*
44
* Hooks and routes are declared here rather than in `emdash-plugin.jsonc`
55
* because the authored manifest is strict and rejects both keys outright;
@@ -50,6 +50,8 @@ import { CONTENT_PATH, loadContent, parseContentInput, renderContent } from "./u
5050
import { loadPanel, renderPanel } from "./ui/panel.js";
5151
import { loadWidget, renderWidget, REFRESH_ACTION } from "./ui/widget.js";
5252
import { DEFAULT_SYNC_INTERVAL } from "./settings.js";
53+
import { mcpTools } from "./tools/declare.js";
54+
import { entryViews, siteTotals, topEntries, TOOL_ROUTES, unviewedEntries } from "./tools/load.js";
5355

5456
/**
5557
* The cron hook's own timeout. The default is 5 000 ms, which a tick that
@@ -196,7 +198,28 @@ const plugin: SandboxedPlugin = {
196198
return { blocks: renderPanel(await loadPanel(ctx, entryId, state, new Date(), ui?.locale)) };
197199
},
198200
},
201+
202+
// The MCP tools' routes read what the analytics pages show, so they
203+
// need the same permission.
204+
[TOOL_ROUTES.topEntries]: {
205+
permission: "plugins:read",
206+
handler: async (routeCtx, ctx) => await topEntries(ctx, routeCtx.input, new Date()),
207+
},
208+
[TOOL_ROUTES.unviewedEntries]: {
209+
permission: "plugins:read",
210+
handler: async (routeCtx, ctx) => await unviewedEntries(ctx, routeCtx.input, new Date()),
211+
},
212+
[TOOL_ROUTES.entryViews]: {
213+
permission: "plugins:read",
214+
handler: async (routeCtx, ctx) => await entryViews(ctx, routeCtx.input, new Date()),
215+
},
216+
[TOOL_ROUTES.siteTotals]: {
217+
permission: "plugins:read",
218+
handler: async (routeCtx, ctx) => await siteTotals(ctx, routeCtx.input, new Date()),
219+
},
199220
},
221+
222+
mcp: { tools: mcpTools() },
200223
};
201224

202225
/**

‎packages/analytics/src/store/access.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import type { PluginContext } from "emdash/plugin";
1818

19+
import type { Day } from "../sync/window.js";
1920
import type { DailyRow, EntryRow, RollupRow } from "./rows.js";
2021

2122
export interface Page<T> {
@@ -112,3 +113,9 @@ export function rollupStore(ctx: PluginContext): Typed<RollupRow> | null {
112113
export function entriesStore(ctx: PluginContext): Typed<EntryRow> | null {
113114
return wrap<EntryRow>(ctx.storage?.entries);
114115
}
116+
117+
/** The oldest day a dated store holds, or null when it is empty or missing. */
118+
export async function oldestDay(store: Typed<{ date: Day }> | null): Promise<Day | null> {
119+
const page = await store?.query({ orderBy: { date: "asc" }, limit: 1 });
120+
return page?.items[0]?.data.date ?? null;
121+
}

‎packages/analytics/src/store/rows.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ export interface EntryRow {
7171
updatedAt: string;
7272
}
7373

74+
/** Rows kept for history after an unpublish, a delete or a move are not on the site. */
75+
export function isPublished(row: EntryRow): boolean {
76+
return row.status === "published";
77+
}
78+
7479
export const DAILY_ID_SEPARATOR = "|";
7580

7681
export function dailyId(date: Day, path: string): string {
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
/**
2+
* The MCP tool declarations.
3+
*
4+
* Only `src/plugin.ts` references `mcpTools()`, from its `mcp` property,
5+
* which the plugin build strips from the runtime: these schemas end up in
6+
* the manifest as JSON Schema, and neither zod nor this module ships in
7+
* the bundle. Keep it that way. A schema used by a route handler would
8+
* pull zod into every sandboxed invocation.
9+
*
10+
* Output schemas become strict JSON Schema (`additionalProperties: false`)
11+
* that the MCP server checks every answer against, so each one has to
12+
* match its loader's result type in `./load.ts` exactly, optional keys
13+
* included. `tests/tools.test.ts` holds each loader's output against the
14+
* schema the build wrote.
15+
*/
16+
17+
import type { SandboxedMcpTool } from "emdash/plugin";
18+
import { z } from "zod";
19+
20+
import {
21+
DEFAULT_LIMIT,
22+
MAX_CURSOR,
23+
MAX_ENTRY_ID,
24+
MAX_LIMIT,
25+
MAX_PATH,
26+
TOOL_ROUTES,
27+
} from "./load.js";
28+
29+
export function mcpTools(): Record<string, SandboxedMcpTool> {
30+
const day = z.string().describe("A UTC day, YYYY-MM-DD.");
31+
const window = z.object({
32+
days: z.number().int(),
33+
since: day,
34+
until: day.describe("Today in UTC. Its numbers still move."),
35+
partial: z
36+
.boolean()
37+
.describe("True when stored history starts after since, so the numbers cover fewer days than the window."),
38+
});
39+
const pageHistorySince = day
40+
.nullable()
41+
.describe("The oldest day of per-page numbers in storage, or null before the first sync. Older days are pruned by the retention setting.");
42+
const indexComplete = z
43+
.boolean()
44+
.describe(
45+
"False while the plugin matches the site's pages to entries, on a new install or during a rebuild; entries may be missing or misattributed until then.",
46+
);
47+
const lastSync = z.string().nullable().describe("When the plugin last synced from the provider, ISO 8601.");
48+
49+
const entryDays = z
50+
.union([z.literal(7), z.literal(30)])
51+
.optional()
52+
.describe("Window in days, ending today (UTC). Default 30.");
53+
const listInput = z.object({
54+
days: entryDays,
55+
collection: z
56+
.string()
57+
.max(63)
58+
.regex(/^[a-z][a-z0-9_]*$/)
59+
.optional()
60+
.describe("Only entries of this collection, by slug, such as posts."),
61+
limit: z
62+
.number()
63+
.int()
64+
.min(1)
65+
.max(MAX_LIMIT)
66+
.optional()
67+
.describe(`Entries per page, 1 to ${MAX_LIMIT}. Default ${DEFAULT_LIMIT}.`),
68+
cursor: z
69+
.string()
70+
.min(1)
71+
.max(MAX_CURSOR)
72+
.optional()
73+
.describe("nextCursor from the previous page, sent with the same days and collection."),
74+
});
75+
const listOutput = z.object({
76+
window,
77+
collection: z.string().nullable(),
78+
items: z.array(
79+
z.object({
80+
entryId: z.string(),
81+
title: z.string(),
82+
collection: z.string(),
83+
collectionLabel: z.string(),
84+
locale: z.string(),
85+
path: z.string(),
86+
views: z.number().describe("Page views in the window."),
87+
publishedAt: z.string().optional(),
88+
}),
89+
),
90+
nextCursor: z.string().optional().describe("Pass as cursor to get the next page. Absent on the last page."),
91+
historySince: pageHistorySince,
92+
indexComplete,
93+
lastSync,
94+
});
95+
96+
const views = { views7: z.number(), views30: z.number() };
97+
98+
return {
99+
top_entries: {
100+
description:
101+
"The site's most viewed published entries over the last 7 or 30 days, from the analytics provider's page views matched to content. " +
102+
"Use it to answer which posts or pages get the most traffic. " +
103+
"The counts are from the plugin's last sync (lastSync) and include today, which is still counting. " +
104+
"When window.partial is true, stored history is shorter than the window; when indexComplete is false, some entries are not matched yet: say so rather than presenting the list as complete. " +
105+
"A page can hold fewer items than limit; follow nextCursor for more.",
106+
route: TOOL_ROUTES.topEntries,
107+
input: listInput,
108+
output: listOutput,
109+
destructive: false,
110+
},
111+
unviewed_entries: {
112+
description:
113+
"Published entries with no page views in the last 7 or 30 days. " +
114+
"Use it to find content nobody reads. " +
115+
"Check publishedAt: an entry published inside the window had less time to be seen. " +
116+
"The list is only as complete as the data: window.partial true means stored history is shorter than the window, indexComplete false means some entries are not matched yet. " +
117+
"A page can hold fewer items than limit; follow nextCursor for more.",
118+
route: TOOL_ROUTES.unviewedEntries,
119+
input: listInput,
120+
output: listOutput,
121+
destructive: false,
122+
},
123+
entry_views: {
124+
description:
125+
"Page views of one entry over the last 7 and 30 days, with its published translations and their combined total. " +
126+
"Pass entryId, or path as the site serves it (a full URL works too). " +
127+
"found is false when no page of the entry is in the plugin's index: it may be a draft, in a collection without a URL pattern, or not matched yet (indexComplete). " +
128+
"The counts are from the plugin's last sync (lastSync) and include today, which is still counting.",
129+
route: TOOL_ROUTES.entryViews,
130+
input: z.object({
131+
entryId: z.string().min(1).max(MAX_ENTRY_ID).optional().describe("The entry's id."),
132+
path: z
133+
.string()
134+
.min(1)
135+
.max(MAX_PATH)
136+
.optional()
137+
.describe("The path or URL the entry is published at. Used when entryId is not given."),
138+
}),
139+
output: z.object({
140+
found: z.boolean(),
141+
entry: z
142+
.object({
143+
entryId: z.string(),
144+
title: z.string(),
145+
collection: z.string(),
146+
collectionLabel: z.string(),
147+
locale: z.string(),
148+
path: z.string(),
149+
status: z
150+
.enum(["published", "unpublished", "deleted", "moved"])
151+
.describe("Not published: the numbers are from the path it was last published at."),
152+
...views,
153+
publishedAt: z.string().optional(),
154+
})
155+
.nullable(),
156+
translations: z.array(
157+
z.object({
158+
entryId: z.string(),
159+
title: z.string(),
160+
locale: z.string(),
161+
path: z.string(),
162+
...views,
163+
}),
164+
),
165+
allLanguages: z
166+
.object(views)
167+
.nullable()
168+
.describe("The published pages of the entry and its translations together."),
169+
windows: z.object({ views7: window, views30: window }),
170+
historySince: pageHistorySince,
171+
indexComplete,
172+
lastSync,
173+
}),
174+
destructive: false,
175+
},
176+
site_totals: {
177+
description:
178+
"Visits and page views for the whole site over the last 7, 30 or 90 days, with the period of the same length before it for comparison. " +
179+
"Use it for overall traffic and its trend. " +
180+
"previous is null when stored history does not reach back far enough to compare. " +
181+
"estimated is true when the provider sampled a day, so the numbers are extrapolated; provisional is true when today, still counting, is included.",
182+
route: TOOL_ROUTES.siteTotals,
183+
input: z.object({
184+
days: z
185+
.union([z.literal(7), z.literal(30), z.literal(90)])
186+
.optional()
187+
.describe("Window in days, ending today (UTC). Default 30."),
188+
}),
189+
output: z.object({
190+
window,
191+
visits: z.number(),
192+
pageviews: z.number(),
193+
estimated: z.boolean(),
194+
provisional: z.boolean(),
195+
previous: z
196+
.object({
197+
since: day,
198+
until: day,
199+
visits: z.number(),
200+
pageviews: z.number(),
201+
estimated: z.boolean(),
202+
})
203+
.nullable(),
204+
historySince: day
205+
.nullable()
206+
.describe("The oldest day of site totals in storage, or null before the first sync. Site totals are kept longer than per-page numbers."),
207+
lastSync,
208+
}),
209+
destructive: false,
210+
},
211+
};
212+
}

0 commit comments

Comments
 (0)