Skip to content

feat(analytics): four read-only MCP tools - #10

Merged
danielmlr merged 1 commit into
mainfrom
feat/analytics-mcp-tools
Sep 25, 2026
Merged

danielmlr merged 1 commit into
mainfrom
feat/analytics-mcp-tools

Conversation

@danielmlr

Copy link
Copy Markdown
Member

Four read-only MCP tools for the analytics plugin, released as 0.2.0 through the release workflow (changeset minor). This is the first changeset since the workflow landed, so merging the "Version Packages" PR it opens is also the first trusted publish.

Tools

Tool Answers
analytics__top_entries Most viewed published entries over 7 or 30 days, optional collection, paged
analytics__unviewed_entries Published entries with no view in 7 or 30 days, paged
analytics__entry_views One entry by id or path (a full URL works): 7/30-day views, published translations, their total
analytics__site_totals Site visits and page views over 7, 30 or 90 days, against the period before

Each tool is a private route with plugins:read (the analytics pages' permission) and destructive: false. It reads storage only, and none of them calls Cloudflare. Every answer states its UTC window, partial (whether stored history covers it), historySince, indexComplete and lastSync; site_totals adds estimated and provisional. The descriptions tell the agent to say when the data is thin.

How it's built

  • zod stays out of the bundle. The schemas live in src/tools/declare.ts, which only the plugin's mcp property references. emdash-plugin build strips that property from the runtime and writes the schemas into the manifest as JSON Schema. zod 4.5.4 (the version emdash and plugin-cli pin) is a dev dependency, and dist/plugin.mjs grows from 81 to 86 KB, the loaders only.
  • Handlers validate their own input, because the routes are also reachable over HTTP without the MCP server's check. Unknown values fall back to the defaults, and the answer names the window actually used.
  • Output schemas are strict (additionalProperties: false), and the MCP server rejects an answer that does not match. tests/tools.test.ts checks every loader's answer against the schema the build wrote, converted the way EmDash converts it for a sandboxed install.
  • List paging reads past rows kept for history (unpublished, deleted, moved), at least 25 rows per read and up to four reads, then repeats the last read up to the final row taken so the cursor points right after it. Cursors carry their tool, window and collection; one from another query is refused with a message instead of seeking by the wrong column.
  • Budget: worst case 7 bridge calls per tool (tests/budget.test.ts).
  • isPublished and oldestDay moved to store/ and are now shared by the per-entry page, the panel and the tools.

Limitation: sandboxed installs only

Registered in plugins: [], EmDash 0.39 and 0.40 load none of a plugin's MCP tools. The build removes mcp from the runtime module, and the in-process loader (adaptSandboxEntry) reads tools only from that module and never gets the descriptor's mcp. Sandboxed and registry installs read the descriptor and work. Same code on upstream/main. The README and changeset say so. This means eisbachcode.de, which runs the plugin in-process, will not see the tools until upstream changes that.

Verified

  • pnpm typecheck, pnpm test (23 files, 312 tests), pnpm build.
  • scripts/compat-matrix.sh 0.40.1: 312/312 on the harness baseline and on 0.40.1.
  • End to end on a create-emdash blog site with EmDash 0.39.1 and seeded plugin storage:
    • sandboxed (@emdash-cms/sandbox-workerd 0.7.1): tools/list shows the four tools with output schemas; all four calls pass the server's output validation, with numbers matching the seed by hand. days: 90 on top_entries is refused by input validation. A cursor from another window comes back as ROUTE_ERROR with the refusal text. unviewed_entries with limit: 1 finds the entry behind 30 moved rows, and top_entries at limit: 2 pages through every published entry once, in order.
    • in-process (plugins: []): mcpTools: [], tools/call answers "Tool not found".
  • /code-review high on the diff: 10 findings, 8 fixed (cursor scope, index state during a rebuild, allLanguages counting an unpublished entry's old numbers, per-store historySince wording, small-limit scanning, parallel reads, shared helpers, config-pin tests). The other two: the in-process gap (upstream, documented), and the changeset, which this PR adds.

Seen along the way, for upstream and not changed here: after an update that changes a tool, EmDash stops serving the tools until they are re-approved, but the plugin's Agent access switch still shows on. The README tells users to switch it off and on.

top_entries, unviewed_entries, entry_views and site_totals expose what
the sync stored to EmDash's MCP server. Each is a private route with
plugins:read, the permission the analytics pages use, and reads storage
only: a sandboxed invocation has ten bridge calls, and the worst case
per tool is seven. Every answer states its UTC window, whether stored
history covers it, whether the content index is complete and when the
last sync ran.

The zod schemas live in src/tools/declare.ts and are referenced only
from the plugin's `mcp` property, which the plugin build strips from
the runtime, so zod stays a dev dependency and the bundle grows by the
loaders only (81 KB to 86 KB). The handlers validate their input by
hand, because the routes are also reachable over HTTP.

The list tools read past rows kept for history (unpublished, deleted,
moved) until the page is full, at least 25 rows per read, and repeat
the last read up to the final row taken so the cursor stays exact.
Cursors name their tool, window and collection; one from another query
is refused instead of seeking by the wrong column.

Verified on a create-emdash site with EmDash 0.39.1: sandboxed through
@emdash-cms/sandbox-workerd, tools/list shows all four and every call
passes the MCP server's output validation. Registered in plugins: [],
EmDash lists none of them: its in-process loader reads tools from the
runtime module, where the build removed them. The README says so.

isPublished and oldestDay move to the store modules, shared by the
per-entry page, the panel and the tools.
@danielmlr
danielmlr merged commit 014ea7a into main Sep 25, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant