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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file provides guidance to AI coding agents (Claude Code, and others via the

## What This Is

MCP server for Plausible Analytics — wraps the Plausible Stats API v2 (`POST /api/v2/query`). Provides four read-only tools (`get_timeseries`, `get_breakdown`, `get_conversions`, `compare_periods`) for querying traffic and conversion data from any MCP-compatible AI tool.
MCP server for Plausible Analytics — wraps the Plausible Stats API v2 (`POST /api/v2/query`). Provides four read-only tools (`get_timeseries`, `get_breakdown`, `get_conversions`, `compare_periods`) for querying traffic and conversion data from any MCP-compatible AI tool. On the Worker, a fifth tool (`send_feedback`, gated by `ServerConfig.enableFeedbackTool`) lets calling agents file friction reports into Sentry User Feedback; the STDIO entry point leaves it off since it has no Sentry SDK.

Two entry points:
- **STDIO** (`src/index.ts`) — local use, reads `PLAUSIBLE_API_KEY` from env
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ Built for teams that want to ask questions like:
| `get_conversions` | Goal conversion rates, optionally per-page |
| `compare_periods` | Side-by-side comparison of two date ranges with absolute and % deltas |

All tools are **read-only** and annotated with `readOnlyHint: true`.
All query tools are **read-only** and annotated with `readOnlyHint: true`.

Hosted deployments additionally expose `send_feedback`, which files feedback about the server itself (confusing errors, missing capabilities) into the maintainers' Sentry User Feedback inbox. It is only registered when the server runs with Sentry (`enableFeedbackTool`).

## Quick Start

Expand Down
65 changes: 65 additions & 0 deletions __tests__/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ describe("MCP Server Integration", () => {
expect(body.site_id).toBe("example.com");
});

it("does not register send_feedback by default", async () => {
const { tools } = await client.listTools();
expect(tools.map((t) => t.name)).not.toContain("send_feedback");
});

it("returns error when Plausible API fails", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
Expand All @@ -217,3 +222,63 @@ describe("MCP Server Integration", () => {
expect(content[0].text).toContain("401");
});
});

describe("MCP Server with feedback tool enabled", () => {
let client: Client;

beforeAll(async () => {
const server = createServer({
apiKey: "test-key-123",
defaultSiteId: "example.com",
enableFeedbackTool: true,
});

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);

client = new Client({ name: "test-client", version: "0.0.1" });
await client.connect(clientTransport);
});

afterAll(async () => {
await client.close();
});

it("registers send_feedback alongside the query tools", async () => {
const { tools } = await client.listTools();
expect(tools.map((t) => t.name)).toContain("send_feedback");
});

it("mentions send_feedback in the server instructions", () => {
expect(client.getInstructions()).toContain("send_feedback");
});

it("records feedback and returns structured confirmation", async () => {
const result = await client.callTool({
name: "send_feedback",
arguments: {
message: "The combination-rules error message could name the offending metric",
category: "confusing_error",
},
});

expect(result.isError).toBeFalsy();
const structured = result.structuredContent as {
recorded: boolean;
feedback_id?: string;
};
expect(structured.recorded).toBe(true);
// Uses the real (unmocked) Sentry withScope/captureFeedback: proves withScope
// propagates the callback's return value, so the feedback id is not lost.
expect(structured.feedback_id).toMatch(/^[0-9a-f]{32}$/);
});

it("rejects messages that are too short to act on", async () => {
const result = await client.callTool({
name: "send_feedback",
arguments: { message: "bad" },
});

expect(result.isError).toBe(true);
});
});
69 changes: 69 additions & 0 deletions __tests__/tools/send-feedback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

const sentry = vi.hoisted(() => ({
captureFeedback: vi.fn(() => "feedback-event-id"),
withScope: vi.fn(),
setTag: vi.fn(),
}));

vi.mock("@sentry/cloudflare", () => ({
captureFeedback: sentry.captureFeedback,
withScope: (cb: (scope: { setTag: typeof sentry.setTag }) => unknown) =>
cb({ setTag: sentry.setTag }),
}));

import { register } from "../../src/tools/send-feedback.js";

function getToolHandler(server: McpServer, toolName: string) {
const tools = (server as any)._registeredTools as Record<string, any>;
const tool = tools?.[toolName];
if (!tool) throw new Error(`Tool ${toolName} not registered`);
return async (args: Record<string, unknown>) => tool.handler(args, {} as any);
}

describe("send_feedback tool", () => {
let server: McpServer;

beforeEach(() => {
sentry.captureFeedback.mockClear();
sentry.setTag.mockClear();
server = new McpServer({ name: "test", version: "0.0.1" });
register(server);
});

it("captures the message as Sentry feedback and returns the feedback id", async () => {
const handler = getToolHandler(server, "send_feedback");
const result = await handler({
message: "get_breakdown rejected my dimension and the error did not say which values are valid",
category: "confusing_error",
tool_name: "get_breakdown",
});

expect(sentry.captureFeedback).toHaveBeenCalledWith({
message:
"get_breakdown rejected my dimension and the error did not say which values are valid",
});
expect(sentry.setTag).toHaveBeenCalledWith("feedback.category", "confusing_error");
expect(sentry.setTag).toHaveBeenCalledWith("feedback.tool", "get_breakdown");
expect(result.structuredContent).toEqual({
recorded: true,
feedback_id: "feedback-event-id",
});
expect(result.isError).toBeUndefined();
});

it("defaults the category tag when none is given", async () => {
const handler = getToolHandler(server, "send_feedback");
await handler({ message: "something long enough to pass validation" });

expect(sentry.setTag).toHaveBeenCalledWith("feedback.category", "bug");
expect(sentry.setTag).not.toHaveBeenCalledWith("feedback.tool", expect.anything());
});

it("is not registered as read-only", () => {
const tool = ((server as any)._registeredTools as Record<string, any>)["send_feedback"];
expect(tool.annotations.readOnlyHint).toBe(false);
expect(tool.annotations.idempotentHint).toBe(false);
});
});
21 changes: 20 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { register as registerTimeseries } from "./tools/get-timeseries.js";
import { register as registerBreakdown } from "./tools/get-breakdown.js";
import { register as registerConversions } from "./tools/get-conversions.js";
import { register as registerComparePeriods } from "./tools/compare-periods.js";
import { register as registerSendFeedback } from "./tools/send-feedback.js";

export interface ServerConfig {
apiKey: string;
Expand All @@ -16,6 +17,13 @@ export interface ServerConfig {
* bring-your-own-key `/mcp` traffic, whose inputs/outputs are the caller's own data.
*/
recordToolIO?: boolean;
/**
* Register the `send_feedback` tool, which files agent/user feedback into Sentry User
* Feedback. Only meaningful where the Sentry SDK is initialized (the Worker); the STDIO
* entry point leaves it off so the tool is never offered somewhere submissions would be
* dropped.
*/
enableFeedbackTool?: boolean;
}

/**
Expand All @@ -41,14 +49,22 @@ COMBINATION RULES:

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.`;

const FEEDBACK_INSTRUCTIONS = `

FEEDBACK: If a tool result confuses you, an error message doesn't help you fix the call, or you cannot express the query you need, report it with send_feedback — it goes straight to the server's maintainers.`;

export function createServer(config: ServerConfig): McpServer {
const server = Sentry.wrapMcpServerWithSentry(
new McpServer(
{
name: "plausible-mcp",
version: "0.5.4",
},
{ instructions: SERVER_INSTRUCTIONS },
{
instructions: config.enableFeedbackTool
? SERVER_INSTRUCTIONS + FEEDBACK_INSTRUCTIONS
: SERVER_INSTRUCTIONS,
},
),
{
recordInputs: config.recordToolIO ?? false,
Expand All @@ -65,6 +81,9 @@ export function createServer(config: ServerConfig): McpServer {
registerBreakdown(server, client, config.defaultSiteId);
registerConversions(server, client, config.defaultSiteId);
registerComparePeriods(server, client, config.defaultSiteId);
if (config.enableFeedbackTool) {
registerSendFeedback(server);
}

return server;
}
76 changes: 76 additions & 0 deletions src/tools/send-feedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { z } from "zod";
import * as Sentry from "@sentry/cloudflare";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

export const FEEDBACK_CATEGORIES = [
"bug",
"confusing_error",
"unexpected_results",
"feature_request",
"praise",
] as const;

/**
* Lets the calling agent (or its user) file feedback about this MCP server into
* Sentry User Feedback, linked to the active trace. Registered only when the
* hosting entry point runs with Sentry (`ServerConfig.enableFeedbackTool`) —
* without an initialized SDK the feedback would go nowhere, so the tool is
* hidden rather than silently dropping submissions.
*
* The message is untrusted caller input: it is bounded by the schema and passed
* to Sentry as data, never interpreted. On `/internal` the feedback inherits the
* authenticated user from the isolation scope; BYOK `/mcp` feedback stays
* anonymous, matching the endpoint's privacy posture.
*/
export function register(server: McpServer) {
server.registerTool(
"send_feedback",
{
title: "Send Feedback",
description:
"Report feedback about this MCP server to its maintainers: a confusing error, a query you could not express, results that did not match expectations, or something that worked well. Use this after hitting friction with the other tools so the server can improve.",
annotations: { readOnlyHint: false, idempotentHint: false, openWorldHint: true },
outputSchema: {
recorded: z.boolean(),
feedback_id: z.string().optional(),
},
inputSchema: {
message: z
.string()
.min(10)
.max(4000)
.describe(
"What happened and what you expected. Name the tool and the arguments that caused friction; never include API keys or secrets."
),
category: z
.enum(FEEDBACK_CATEGORIES)
.default("bug")
.describe("The kind of feedback"),
tool_name: z
.string()
.max(64)
.optional()
.describe("Which tool the feedback is about, if any (e.g. get_breakdown)"),
},
},
async (args) => {
const feedbackId = Sentry.withScope((scope) => {
scope.setTag("feedback.category", args.category ?? "bug");
if (args.tool_name) {
scope.setTag("feedback.tool", args.tool_name);
}
return Sentry.captureFeedback({ message: args.message });
});
Comment thread
sentry[bot] marked this conversation as resolved.

return {
content: [
{
type: "text" as const,
text: "Feedback recorded — thank you. The maintainers review submissions in Sentry User Feedback.",
},
],
structuredContent: { recorded: true, feedback_id: feedbackId },
};
}
);
}
2 changes: 2 additions & 0 deletions src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ async function handleInternalMcp(
// — and Authorization/Cookie/JWT headers are still stripped by beforeSendSpan.
// BYOK (/mcp) deliberately leaves this off: that traffic is a third party's own data.
recordToolIO: true,
enableFeedbackTool: true,
});

return createMcpHandler(server, { route: "/internal" })(request, env, ctx);
Expand Down Expand Up @@ -238,6 +239,7 @@ async function handleDirectMcp(
apiKey,
baseUrl: env.PLAUSIBLE_BASE_URL,
defaultSiteId: env.PLAUSIBLE_DEFAULT_SITE_ID,
enableFeedbackTool: true,
});
} catch (error) {
Sentry.captureException(error);
Expand Down
Loading