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
9 changes: 9 additions & 0 deletions .changeset/analytics-mcp-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@eisbachcode/emdash-plugin-analytics": minor
---

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.

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.

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.
15 changes: 15 additions & 0 deletions packages/analytics/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,21 @@ at the workspace root is enough.
undefined there, so read `routeCtx.ui?.locale` and fall back to English;
never return empty blocks when it is missing. The editor panel gets it in
both modes, with `ui.entry`.
- **MCP schemas never reach the runtime.** `emdash-plugin build` strips
the `mcp` property of `src/plugin.ts` from the bundle and writes the zod
schemas into the manifest as JSON Schema. `src/tools/declare.ts` is
referenced only from there, so zod stays a dev dependency and out of the
bundle; a schema imported by a route handler would pull it back in. The
handlers in `src/tools/load.ts` validate by hand, because the routes are
also reachable over HTTP without the MCP server's validation.
- **An MCP output schema is strict.** Every object becomes
`additionalProperties: false` and the MCP server rejects an answer that
does not match, so a loader's result and its declared output have to
agree key for key. `tests/tools.test.ts` checks each answer against the
schema the build wrote.
- **MCP tools reach sandboxed installs only.** EmDash's in-process loader
reads tools from the runtime module, where the build removed them, so a
`plugins: []` install lists none. Test tools sandboxed.
- **Block Kit keeps no state.** Anything a page needs to remember between
interactions travels in an `action_id`, a button `value` or a table
cursor (see `src/ui/content.ts`).
45 changes: 41 additions & 4 deletions packages/analytics/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ Most analytics plugins either inject a script or show site-wide numbers.
This one keeps a `path → entry` index, so views can be attributed to the
entry that earned them rather than to a URL string.

> **Status: first release.** Cloudflare Web Analytics plus demo data: a
> dashboard widget, an Analytics page with a setup check, a per-entry page
> and an Analytics panel in the entry editor. Tested on EmDash 0.39 and 0.40.
> See "Not in this version".
> **Status: early releases.** Cloudflare Web Analytics plus demo data: a
> dashboard widget, an Analytics page with a setup check, a per-entry page,
> an Analytics panel in the entry editor and four read-only MCP tools.
> Tested on EmDash 0.39 and 0.40. See "Not in this version".

The screenshots show demo data on a site made from EmDash's blog template.

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

## MCP tools

EmDash's MCP server can hand the same numbers to an AI agent. The plugin
declares four read-only tools:

| Tool | Answers |
|---|---|
| `analytics__top_entries` | The most viewed published entries over 7 or 30 days, optionally for one collection |
| `analytics__unviewed_entries` | Published entries without a single view in 7 or 30 days |
| `analytics__entry_views` | One entry's 7- and 30-day views, found by id or by path, with its translations and their total |
| `analytics__site_totals` | Site visits and page views over 7, 30 or 90 days, against the period before |

They read what the sync stored and never call Cloudflare. Every answer
names its window in UTC days, where stored history starts, whether the
content index is complete and when the last sync ran, so an agent can say
that the data is thin instead of presenting it as exact.

To use them:

1. Under **Plugins**, open Analytics and turn on **Agent access** after
reviewing the tools.
2. Call them as a user with `plugins:read` (editor and above), through a
token with the `mcp:tools:analytics` scope, or `mcp:tools` for every
plugin's tools.

After an update that changes a tool, EmDash stops serving the tools until
they are approved again, but the switch stays on. Turn it off and on.

**Sandboxed installs only, for now.** A plugin registered in `plugins: []`
gets none of its MCP tools on EmDash 0.39 and 0.40: the plugin build keeps
the tool declarations out of the runtime module, and EmDash's in-process
loader looks for them only there. Under `sandboxed: []` or installed from
the registry, the tools work. This is an EmDash limitation, not a setting.

## What you need

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

The MCP tools declare `plugins:read` like the pages, so an agent acting for
an author gets a permission error from all of them.

Authors and contributors will still see the widget card in the dashboard
and get a permission error inside it — EmDash renders every declared widget
for every role and gates on dispatch. That is an upstream gap, not
Expand Down
3 changes: 2 additions & 1 deletion packages/analytics/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@
"emdash": ">=0.39.0 <1.0.0",
"tsdown": "0.20.3",
"typescript": "^5.9.0",
"vitest": "^4.1.0"
"vitest": "^4.1.0",
"zod": "4.5.4"
},
"publishConfig": {
"access": "public"
Expand Down
25 changes: 24 additions & 1 deletion packages/analytics/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Hooks, the cron dispatcher and the single admin route.
* Hooks, the cron dispatcher, the admin routes and the MCP tools.
*
* Hooks and routes are declared here rather than in `emdash-plugin.jsonc`
* because the authored manifest is strict and rejects both keys outright;
Expand Down Expand Up @@ -50,6 +50,8 @@ import { CONTENT_PATH, loadContent, parseContentInput, renderContent } from "./u
import { loadPanel, renderPanel } from "./ui/panel.js";
import { loadWidget, renderWidget, REFRESH_ACTION } from "./ui/widget.js";
import { DEFAULT_SYNC_INTERVAL } from "./settings.js";
import { mcpTools } from "./tools/declare.js";
import { entryViews, siteTotals, topEntries, TOOL_ROUTES, unviewedEntries } from "./tools/load.js";

/**
* The cron hook's own timeout. The default is 5 000 ms, which a tick that
Expand Down Expand Up @@ -196,7 +198,28 @@ const plugin: SandboxedPlugin = {
return { blocks: renderPanel(await loadPanel(ctx, entryId, state, new Date(), ui?.locale)) };
},
},

// The MCP tools' routes read what the analytics pages show, so they
// need the same permission.
[TOOL_ROUTES.topEntries]: {
permission: "plugins:read",
handler: async (routeCtx, ctx) => await topEntries(ctx, routeCtx.input, new Date()),
},
[TOOL_ROUTES.unviewedEntries]: {
permission: "plugins:read",
handler: async (routeCtx, ctx) => await unviewedEntries(ctx, routeCtx.input, new Date()),
},
[TOOL_ROUTES.entryViews]: {
permission: "plugins:read",
handler: async (routeCtx, ctx) => await entryViews(ctx, routeCtx.input, new Date()),
},
[TOOL_ROUTES.siteTotals]: {
permission: "plugins:read",
handler: async (routeCtx, ctx) => await siteTotals(ctx, routeCtx.input, new Date()),
},
},

mcp: { tools: mcpTools() },
};

/**
Expand Down
7 changes: 7 additions & 0 deletions packages/analytics/src/store/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

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

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

export interface Page<T> {
Expand Down Expand Up @@ -112,3 +113,9 @@ export function rollupStore(ctx: PluginContext): Typed<RollupRow> | null {
export function entriesStore(ctx: PluginContext): Typed<EntryRow> | null {
return wrap<EntryRow>(ctx.storage?.entries);
}

/** The oldest day a dated store holds, or null when it is empty or missing. */
export async function oldestDay(store: Typed<{ date: Day }> | null): Promise<Day | null> {
const page = await store?.query({ orderBy: { date: "asc" }, limit: 1 });
return page?.items[0]?.data.date ?? null;
}
5 changes: 5 additions & 0 deletions packages/analytics/src/store/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ export interface EntryRow {
updatedAt: string;
}

/** Rows kept for history after an unpublish, a delete or a move are not on the site. */
export function isPublished(row: EntryRow): boolean {
return row.status === "published";
}

export const DAILY_ID_SEPARATOR = "|";

export function dailyId(date: Day, path: string): string {
Expand Down
212 changes: 212 additions & 0 deletions packages/analytics/src/tools/declare.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
/**
* The MCP tool declarations.
*
* Only `src/plugin.ts` references `mcpTools()`, from its `mcp` property,
* which the plugin build strips from the runtime: these schemas end up in
* the manifest as JSON Schema, and neither zod nor this module ships in
* the bundle. Keep it that way. A schema used by a route handler would
* pull zod into every sandboxed invocation.
*
* Output schemas become strict JSON Schema (`additionalProperties: false`)
* that the MCP server checks every answer against, so each one has to
* match its loader's result type in `./load.ts` exactly, optional keys
* included. `tests/tools.test.ts` holds each loader's output against the
* schema the build wrote.
*/

import type { SandboxedMcpTool } from "emdash/plugin";
import { z } from "zod";

import {
DEFAULT_LIMIT,
MAX_CURSOR,
MAX_ENTRY_ID,
MAX_LIMIT,
MAX_PATH,
TOOL_ROUTES,
} from "./load.js";

export function mcpTools(): Record<string, SandboxedMcpTool> {
const day = z.string().describe("A UTC day, YYYY-MM-DD.");
const window = z.object({
days: z.number().int(),
since: day,
until: day.describe("Today in UTC. Its numbers still move."),
partial: z
.boolean()
.describe("True when stored history starts after since, so the numbers cover fewer days than the window."),
});
const pageHistorySince = day
.nullable()
.describe("The oldest day of per-page numbers in storage, or null before the first sync. Older days are pruned by the retention setting.");
const indexComplete = z
.boolean()
.describe(
"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.",
);
const lastSync = z.string().nullable().describe("When the plugin last synced from the provider, ISO 8601.");

const entryDays = z
.union([z.literal(7), z.literal(30)])
.optional()
.describe("Window in days, ending today (UTC). Default 30.");
const listInput = z.object({
days: entryDays,
collection: z
.string()
.max(63)
.regex(/^[a-z][a-z0-9_]*$/)
.optional()
.describe("Only entries of this collection, by slug, such as posts."),
limit: z
.number()
.int()
.min(1)
.max(MAX_LIMIT)
.optional()
.describe(`Entries per page, 1 to ${MAX_LIMIT}. Default ${DEFAULT_LIMIT}.`),
cursor: z
.string()
.min(1)
.max(MAX_CURSOR)
.optional()
.describe("nextCursor from the previous page, sent with the same days and collection."),
});
const listOutput = z.object({
window,
collection: z.string().nullable(),
items: z.array(
z.object({
entryId: z.string(),
title: z.string(),
collection: z.string(),
collectionLabel: z.string(),
locale: z.string(),
path: z.string(),
views: z.number().describe("Page views in the window."),
publishedAt: z.string().optional(),
}),
),
nextCursor: z.string().optional().describe("Pass as cursor to get the next page. Absent on the last page."),
historySince: pageHistorySince,
indexComplete,
lastSync,
});

const views = { views7: z.number(), views30: z.number() };

return {
top_entries: {
description:
"The site's most viewed published entries over the last 7 or 30 days, from the analytics provider's page views matched to content. " +
"Use it to answer which posts or pages get the most traffic. " +
"The counts are from the plugin's last sync (lastSync) and include today, which is still counting. " +
"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. " +
"A page can hold fewer items than limit; follow nextCursor for more.",
route: TOOL_ROUTES.topEntries,
input: listInput,
output: listOutput,
destructive: false,
},
unviewed_entries: {
description:
"Published entries with no page views in the last 7 or 30 days. " +
"Use it to find content nobody reads. " +
"Check publishedAt: an entry published inside the window had less time to be seen. " +
"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. " +
"A page can hold fewer items than limit; follow nextCursor for more.",
route: TOOL_ROUTES.unviewedEntries,
input: listInput,
output: listOutput,
destructive: false,
},
entry_views: {
description:
"Page views of one entry over the last 7 and 30 days, with its published translations and their combined total. " +
"Pass entryId, or path as the site serves it (a full URL works too). " +
"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). " +
"The counts are from the plugin's last sync (lastSync) and include today, which is still counting.",
route: TOOL_ROUTES.entryViews,
input: z.object({
entryId: z.string().min(1).max(MAX_ENTRY_ID).optional().describe("The entry's id."),
path: z
.string()
.min(1)
.max(MAX_PATH)
.optional()
.describe("The path or URL the entry is published at. Used when entryId is not given."),
}),
output: z.object({
found: z.boolean(),
entry: z
.object({
entryId: z.string(),
title: z.string(),
collection: z.string(),
collectionLabel: z.string(),
locale: z.string(),
path: z.string(),
status: z
.enum(["published", "unpublished", "deleted", "moved"])
.describe("Not published: the numbers are from the path it was last published at."),
...views,
publishedAt: z.string().optional(),
})
.nullable(),
translations: z.array(
z.object({
entryId: z.string(),
title: z.string(),
locale: z.string(),
path: z.string(),
...views,
}),
),
allLanguages: z
.object(views)
.nullable()
.describe("The published pages of the entry and its translations together."),
windows: z.object({ views7: window, views30: window }),
historySince: pageHistorySince,
indexComplete,
lastSync,
}),
destructive: false,
},
site_totals: {
description:
"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. " +
"Use it for overall traffic and its trend. " +
"previous is null when stored history does not reach back far enough to compare. " +
"estimated is true when the provider sampled a day, so the numbers are extrapolated; provisional is true when today, still counting, is included.",
route: TOOL_ROUTES.siteTotals,
input: z.object({
days: z
.union([z.literal(7), z.literal(30), z.literal(90)])
.optional()
.describe("Window in days, ending today (UTC). Default 30."),
}),
output: z.object({
window,
visits: z.number(),
pageviews: z.number(),
estimated: z.boolean(),
provisional: z.boolean(),
previous: z
.object({
since: day,
until: day,
visits: z.number(),
pageviews: z.number(),
estimated: z.boolean(),
})
.nullable(),
historySince: day
.nullable()
.describe("The oldest day of site totals in storage, or null before the first sync. Site totals are kept longer than per-page numbers."),
lastSync,
}),
destructive: false,
},
};
}
Loading
Loading