diff --git a/docs/contributing/adding-tools.md b/docs/contributing/adding-tools.md index aa76fff4a..d8d9e4a11 100644 --- a/docs/contributing/adding-tools.md +++ b/docs/contributing/adding-tools.md @@ -141,6 +141,12 @@ annotations: { } ``` +The catalog gives every tool with `readOnlyHint: true` and +`openWorldHint: true` a shared `fresh` input (`ParamFresh`), except the docs +tools, which do not call the Sentry API. Do not declare `fresh` yourself. A +call with `fresh: true` runs the handler in a request scope where the shared +transport skips cached reads, and the handler never sees the input. + ### Writing LLM-Friendly Descriptions Critical for LLM success: diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 988733871..959287882 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -38,6 +38,7 @@ import { createApiTransport } from "@sentry/toolkit-core/api-client/transport"; import { z } from "zod"; import { DEFAULT_SEARCH_ISSUES_PERIOD } from "../constants"; import { ConfigurationError } from "../errors"; +import { isFreshRead } from "../internal/fresh-reads"; import { logIssue, logWarn } from "../telem/logging"; import type { SentryProtocol } from "../types"; import { @@ -333,10 +334,12 @@ const REQUEST_RETRY_POLICY: RetryPolicy = { /** * Every request goes through the shared transport. `SentryApiService` sets its * own headers, including Authorization, so the transport only retries; as - * before, it adds no timeout. + * before, it adds no timeout. `fresh` passes on a tool call's `fresh: true`, + * for when the transport gets a cache. */ const transport = createApiTransport({ retry: REQUEST_RETRY_POLICY, + fresh: isFreshRead, isIdempotent: createIdempotentOperationMatcher(() => selectIdempotentWrites(operations), ), diff --git a/packages/mcp-core/src/internal/fresh-reads.test.ts b/packages/mcp-core/src/internal/fresh-reads.test.ts new file mode 100644 index 000000000..daae7523a --- /dev/null +++ b/packages/mcp-core/src/internal/fresh-reads.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import catalogTools from "../tools/catalog"; +import { prepareToolParams } from "../tools/catalog-runtime/availability"; +import type { ToolConfig } from "../tools/types"; +import { createTestContext } from "../test-utils/context"; +import { isFreshRead, withFreshReadInput } from "./fresh-reads"; + +function probeTool() { + const calls: { params: Record; fresh: boolean }[] = []; + const tool = withFreshReadInput({ + name: "probe", + description: "Probe", + inputSchema: { id: z.string() }, + skills: ["inspect"], + requiredScopes: [], + annotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: true, + }, + async handler(params, _context) { + // Read after an await, as the transport does mid-request. + await Promise.resolve(); + calls.push({ params, fresh: isFreshRead() }); + return "ok"; + }, + } satisfies ToolConfig); + return { tool, calls }; +} + +describe("withFreshReadInput", () => { + it("runs the handler in a fresh scope only when fresh is true", async () => { + const { tool, calls } = probeTool(); + const context = createTestContext(); + + for (const args of [{ id: "a", fresh: true }, { id: "b" }]) { + await tool.handler( + prepareToolParams({ tool, params: args, context }) as never, + context, + ); + } + + expect(calls).toEqual([ + { params: { id: "a" }, fresh: true }, + { params: { id: "b" }, fresh: false }, + ]); + expect(isFreshRead()).toBe(false); + }); + + it("rejects a non-boolean fresh", () => { + const { tool } = probeTool(); + expect(() => + prepareToolParams({ + tool, + params: { id: "a", fresh: "yes" }, + context: createTestContext(), + }), + ).toThrow(/fresh/); + }); +}); + +describe("catalog fresh input", () => { + it("is on read tools that call the Sentry API, and nowhere else", () => { + const withFresh = Object.values(catalogTools as Record) + .filter((tool) => "fresh" in tool.inputSchema) + .map((tool) => tool.name); + + expect(withFresh).toContain("get_issue_details"); + expect(withFresh).toContain("search_issues"); + expect(withFresh).not.toContain("update_issue"); + expect(withFresh).not.toContain("search_docs"); + expect(withFresh).not.toContain("get_doc"); + expect(withFresh).not.toContain("get_sentry_mcp_info"); + }); +}); diff --git a/packages/mcp-core/src/internal/fresh-reads.ts b/packages/mcp-core/src/internal/fresh-reads.ts new file mode 100644 index 000000000..99721d1b4 --- /dev/null +++ b/packages/mcp-core/src/internal/fresh-reads.ts @@ -0,0 +1,33 @@ +/** + * Request-scoped fresh reads for MCP tool calls. + * + * A read tool called with `fresh: true` runs inside a scope where the shared + * transport skips cache reads. The scope is host state, kept here with + * `AsyncLocalStorage`; the transport sees it only through its `fresh()` + * callback, so shared code stays free of Node APIs. + */ +import { AsyncLocalStorage } from "node:async_hooks"; +import { ParamFresh } from "../schema"; +import type { ToolConfig } from "../tools/types"; + +const freshScope = new AsyncLocalStorage(); + +/** Whether the current tool call asked to bypass cached reads. */ +export function isFreshRead(): boolean { + return freshScope.getStore() === true; +} + +/** + * Add the `fresh` input to a read tool. The handler runs in a fresh scope when + * the caller sets it, and never sees the input itself. + */ +export function withFreshReadInput>(tool: T): T { + return { + ...tool, + inputSchema: { ...tool.inputSchema, fresh: ParamFresh }, + handler: async ({ fresh, ...params }, context) => + fresh === true + ? await freshScope.run(true, () => tool.handler(params, context)) + : await tool.handler(params, context), + }; +} diff --git a/packages/mcp-core/src/schema.ts b/packages/mcp-core/src/schema.ts index fb86c4381..12dda75e0 100644 --- a/packages/mcp-core/src/schema.ts +++ b/packages/mcp-core/src/schema.ts @@ -63,6 +63,13 @@ export const ParamCursor = z "Pagination cursor from a previous call's nextCursor. Reuse it with the same filters and scope to fetch the next page.", ); +// Shared by every read tool that calls the Sentry API, so keep it short: it +// ships in the schema of each one. +export const ParamFresh = z + .boolean() + .optional() + .describe("Bypass cached reads."); + export const ParamIssueShortId = z .string() .toUpperCase() diff --git a/packages/mcp-core/src/server.test.ts b/packages/mcp-core/src/server.test.ts index 02743d998..73b21d24b 100644 --- a/packages/mcp-core/src/server.test.ts +++ b/packages/mcp-core/src/server.test.ts @@ -2919,6 +2919,7 @@ describe("buildServer", () => { ).toEqual([ "categories", "endOffsetSeconds", + "fresh", "limit", "regionUrl", "replayId", diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index da1ef1fa0..44b13f99e 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -1629,6 +1629,10 @@ "exclusiveMinimum": 0, "maximum": 100, "description": "Maximum number of alert rules to return per alert family." + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug"] @@ -1874,6 +1878,10 @@ "exclusiveMinimum": 0, "maximum": 100, "description": "Maximum number of dashboards to return." + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug"] @@ -2082,6 +2090,10 @@ "type": "null" } ] + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug"] @@ -2214,6 +2226,10 @@ "projectSlug": { "type": "string", "description": "The project's slug. You can find a list of existing projects in an organization using the `find_projects()` tool." + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug", "projectSlug"] @@ -2288,6 +2304,10 @@ "type": "integer", "minimum": 1, "maximum": 100 + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug"] @@ -2424,6 +2444,10 @@ "exclusiveMinimum": 0, "maximum": 99, "description": "Maximum number of monitors to return." + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug"] @@ -2602,6 +2626,10 @@ "type": "null" } ] + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } } }, @@ -2702,6 +2730,10 @@ "type": "null" } ] + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug"] @@ -2781,6 +2813,10 @@ "type": "null" } ] + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug"] @@ -2994,6 +3030,10 @@ "type": "null" } ] + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug"] @@ -3078,6 +3118,10 @@ "exclusiveMinimum": 0, "maximum": 99, "description": "Maximum number of monitors to return." + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug"] @@ -3179,6 +3223,10 @@ "regionUrl": { "type": "string", "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool." + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug", "conversationId"] @@ -3630,6 +3678,10 @@ "regionUrl": { "type": "string", "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool." + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug", "conversationId"] @@ -4162,6 +4214,10 @@ "type": "integer", "minimum": 1, "maximum": 100 + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug", "section"] @@ -4446,6 +4502,10 @@ "type": "string", "minLength": 1, "description": "An Alert ID or exact name. Metric references accept legacy numeric alert-rule IDs or detector:123 for canonical monitor IDs." + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug", "ruleIdOrName"] @@ -4480,6 +4540,10 @@ "type": "string", "minLength": 1, "description": "The dashboard's numeric ID or exact title." + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug", "dashboardIdOrTitle"] @@ -4547,6 +4611,10 @@ "type": "null" } ] + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug", "projectSlug", "eventId"] @@ -4609,6 +4677,10 @@ "minLength": 1 } ] + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug", "issueId"] @@ -4657,6 +4729,10 @@ "type": "integer", "exclusiveMinimum": 0, "maximum": 100 + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } } }, @@ -4709,6 +4785,10 @@ "description": "The ID of the event. e.g. `c49541c747cb4d8aa3efb70ca5aba243`" } ] + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } } }, @@ -4751,6 +4831,10 @@ "type": "string", "format": "uri", "description": "The URL of the issue. e.g. https://my-organization.sentry.io/issues/PROJECT-1Z43" + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } } }, @@ -4793,6 +4877,10 @@ "type": "string", "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$", "description": "The tag key to get values for (e.g., 'url', 'browser', 'environment', 'release')." + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["tagKey"] @@ -4895,6 +4983,10 @@ "exclusiveMinimum": 0, "maximum": 100, "description": "Maximum number of user reports to return." + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } } }, @@ -4951,6 +5043,10 @@ "type": "null" } ] + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug", "appId"] @@ -4989,6 +5085,10 @@ "type": "string", "pattern": "^\\d+$", "description": "Native Metric Monitor ID from find_metric_monitors." + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug", "monitorId"] @@ -5274,6 +5374,10 @@ "type": "null" } ] + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug", "monitorSlug"] @@ -5339,6 +5443,10 @@ "type": "integer", "minimum": 1, "maximum": 20 + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } } }, @@ -5397,6 +5505,10 @@ "default": true, "description": "Show only user code frames in the hotspot table. Set to false to include library frames.", "type": "boolean" + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } } }, @@ -5464,6 +5576,10 @@ "exclusiveMinimum": 0, "maximum": 50, "description": "Maximum number of deploys and commits to return, up to 50." + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug", "releaseVersion"] @@ -5558,6 +5674,10 @@ "type": "null" } ] + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } } }, @@ -5616,6 +5736,10 @@ "organizationSlug": { "type": "string", "description": "The organization's slug. You can find a existing list of organizations you have access to using the `find_organizations()` tool." + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } } }, @@ -5654,6 +5778,10 @@ "type": "null" } ] + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug", "snapshotId"] @@ -5699,6 +5827,10 @@ "type": "null" } ] + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug", "snapshotId", "imageIdentifier"] @@ -5738,6 +5870,10 @@ "type": "string", "minLength": 1, "description": "The span ID within a trace. Use this with a trace resource to focus on a specific span." + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug", "traceId", "spanId"] @@ -5777,6 +5913,10 @@ "type": "string", "minLength": 1, "description": "The span ID within a trace. Use this with a trace resource to focus on a specific span." + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug", "traceId"] @@ -5839,6 +5979,10 @@ "exclusiveMinimum": 0, "maximum": 50, "description": "Maximum number of recent checks to include." + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug", "projectSlug", "uptimeMonitorId"] @@ -6278,6 +6422,10 @@ "regionUrl": { "type": "string", "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool." + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug"] @@ -6501,6 +6649,10 @@ "regionUrl": { "type": "string", "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool." + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug"] @@ -6879,6 +7031,10 @@ "default": false, "description": "Include explanation of how the query was translated or repaired", "type": "boolean" + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug"] @@ -6981,6 +7137,10 @@ "default": false, "description": "Include explanation of how the query was translated or repaired", "type": "boolean" + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug"] @@ -7067,6 +7227,10 @@ "default": false, "description": "Include explanation of how the query was translated or repaired", "type": "boolean" + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } } }, @@ -7129,6 +7293,10 @@ "default": false, "description": "Include explanation of how the query was translated or repaired", "type": "boolean" + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug"] @@ -7209,6 +7377,10 @@ "default": false, "description": "Include explanation of how the query was translated or repaired", "type": "boolean" + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug"] @@ -7289,6 +7461,10 @@ "default": false, "description": "Include explanation of how the query was translated or repaired", "type": "boolean" + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug"] @@ -7369,6 +7545,10 @@ "default": false, "description": "Include explanation of how the query was translated or repaired", "type": "boolean" + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug"] @@ -7452,6 +7632,10 @@ "default": false, "description": "Include explanation of how the query was translated or repaired", "type": "boolean" + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug"] @@ -7616,6 +7800,10 @@ "default": false, "description": "Include explanation of how the query was translated or repaired", "type": "boolean" + }, + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" } }, "required": ["organizationSlug"] @@ -9067,7 +9255,15 @@ { "name": "whoami", "description": "Identify the authenticated user in Sentry.\n\nUse this tool when you need to:\n- Get the user's name and email address.", - "inputSchema": {}, + "inputSchema": { + "type": "object", + "properties": { + "fresh": { + "description": "Bypass cached reads.", + "type": "boolean" + } + } + }, "outputSchema": { "type": "object", "properties": { diff --git a/packages/mcp-core/src/tools/catalog/index.ts b/packages/mcp-core/src/tools/catalog/index.ts index 0b47f82f1..b2812e886 100644 --- a/packages/mcp-core/src/tools/catalog/index.ts +++ b/packages/mcp-core/src/tools/catalog/index.ts @@ -72,6 +72,7 @@ import addIssueNote from "./add-issue-note"; import onboardingStatusUpdate from "./onboarding-status-update"; import searchAttributes from "./search-attributes"; import submitFeedbackToSentryTeam from "./submit-feedback-to-sentry-team"; +import { withFreshReadInput } from "../../internal/fresh-reads"; import type { ToolConfig } from "../types"; const legacyGetAIConversationDetails = { @@ -96,6 +97,26 @@ const legacySearchAIConversations = { ].join("\n"), } satisfies ToolConfig; +// The docs tools read docs.sentry.io, not the Sentry API, so they have no +// cached reads to bypass. +const DOCS_TOOL_NAMES = new Set(["search_docs", "get_doc"]); + +/** Give every read tool that calls the Sentry API the shared `fresh` input. */ +function withFreshReadInputs>>( + tools: T, +): T { + return Object.fromEntries( + Object.entries(tools).map(([key, tool]) => [ + key, + tool.annotations.readOnlyHint && + tool.annotations.openWorldHint && + !DOCS_TOOL_NAMES.has(tool.name) + ? withFreshReadInput(tool) + : tool, + ]), + ) as T; +} + /** * Catalog of ordinary Sentry MCP operations. * @@ -105,7 +126,7 @@ const legacySearchAIConversations = { * Wrapper tools such as search_sentry_tools and execute_sentry_tool intentionally * live outside this catalog. */ -const catalogTools = { +const catalogTools = withFreshReadInputs({ whoami, find_organizations: findOrganizations, find_teams: findTeams, @@ -182,7 +203,7 @@ const catalogTools = { add_issue_note: addIssueNote, onboarding_status_update: onboardingStatusUpdate, submit_feedback_to_sentry_team: submitFeedbackToSentryTeam, -} as const satisfies Record>; +} as const satisfies Record>); export default catalogTools; export type CatalogToolName = keyof typeof catalogTools;