diff --git a/AGENTS.md b/AGENTS.md index b279d8d..33d3fd7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 1c66ea9..b5b3914 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/__tests__/server.test.ts b/__tests__/server.test.ts index 14fad45..31d6892 100644 --- a/__tests__/server.test.ts +++ b/__tests__/server.test.ts @@ -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, @@ -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); + }); +}); diff --git a/__tests__/tools/send-feedback.test.ts b/__tests__/tools/send-feedback.test.ts new file mode 100644 index 0000000..c87a625 --- /dev/null +++ b/__tests__/tools/send-feedback.test.ts @@ -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; + const tool = tools?.[toolName]; + if (!tool) throw new Error(`Tool ${toolName} not registered`); + return async (args: Record) => 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)["send_feedback"]; + expect(tool.annotations.readOnlyHint).toBe(false); + expect(tool.annotations.idempotentHint).toBe(false); + }); +}); diff --git a/src/server.ts b/src/server.ts index 8d18bca..0668dc9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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; @@ -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; } /** @@ -41,6 +49,10 @@ 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( @@ -48,7 +60,11 @@ export function createServer(config: ServerConfig): McpServer { name: "plausible-mcp", version: "0.5.4", }, - { instructions: SERVER_INSTRUCTIONS }, + { + instructions: config.enableFeedbackTool + ? SERVER_INSTRUCTIONS + FEEDBACK_INSTRUCTIONS + : SERVER_INSTRUCTIONS, + }, ), { recordInputs: config.recordToolIO ?? false, @@ -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; } diff --git a/src/tools/send-feedback.ts b/src/tools/send-feedback.ts new file mode 100644 index 0000000..1c1725e --- /dev/null +++ b/src/tools/send-feedback.ts @@ -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 }); + }); + + 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 }, + }; + } + ); +} diff --git a/src/worker.ts b/src/worker.ts index 94c3c61..718e1c8 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -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); @@ -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);