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
6 changes: 6 additions & 0 deletions docs/contributing/adding-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion packages/mcp-core/src/api-client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
),
Expand Down
76 changes: 76 additions & 0 deletions packages/mcp-core/src/internal/fresh-reads.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>; 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<any>);
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<string, ToolConfig>)
.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");
});
});
33 changes: 33 additions & 0 deletions packages/mcp-core/src/internal/fresh-reads.ts
Original file line number Diff line number Diff line change
@@ -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<true>();

/** 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<T extends ToolConfig<any>>(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),
};
}
7 changes: 7 additions & 0 deletions packages/mcp-core/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions packages/mcp-core/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2919,6 +2919,7 @@ describe("buildServer", () => {
).toEqual([
"categories",
"endOffsetSeconds",
"fresh",
"limit",
"regionUrl",
"replayId",
Expand Down
Loading
Loading