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
10 changes: 5 additions & 5 deletions docs/adding-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ Step-by-step guide for adding new tools to the Sentry MCP server.

Not every tool is exposed to every consumer. We rely on several mechanisms to keep the active tool set manageable:

- **Catalog by default** — Most tools are searchable/executable through `search_tools` + `execute_tool` automatically when experimental mode is enabled. Search uses the tool's existing name and description.
- **Catalog by default** — Most tools are searchable/executable through `search_tools` + `execute_tool` automatically. Search uses the tool's existing name and description.
- **Catalog registry** — `packages/mcp-core/src/tools/catalog/index.ts` lists ordinary Sentry operation tools. The catalog directory is intentionally flat: one tool entry per file.
- **Special tools** — Wrapper/gateway tools (`search_tools`, `execute_tool`, `use_sentry`) live in `packages/mcp-core/src/tools/special/`. They still use the same tool types, but they are kept out of the ordinary catalog.
- **Central direct exposure policy** — `packages/mcp-core/src/tools/surfaces.ts` lists the catalog tools that are also exposed directly through MCP `tools/list`. Stable and experimental modes have separate direct surfaces; experimental mode intentionally keeps more long-tail tools catalog-only because `search_tools` + `execute_tool` are available there.
- **Central direct exposure policy** — `packages/mcp-core/src/tools/surfaces.ts` lists the catalog tools that are also exposed directly through MCP `tools/list`. The direct surface intentionally keeps long-tail tools catalog-only because `search_tools` + `execute_tool` are available as primary primitives.
- **`requiredCapabilities`** — Tools declare which project capabilities they need (e.g. `profiles`, `replays`, `traces`). If the upstream project doesn't have a capability enabled, the tool is automatically hidden.
- **`experimental` / `hideInExperimentalMode`** — Feature flags for tools that are being tested or replaced.
- **Skills & constraints** — The server filters tools based on granted skills and org/project constraints.
Expand All @@ -30,9 +30,9 @@ Before adding a new tool, consider if it could be:

After creating a tool module, add it to `packages/mcp-core/src/tools/catalog/index.ts`. Then update `packages/mcp-core/src/tools/surfaces.ts` only when it should be directly exposed through MCP `tools/list`:

- Add high-frequency, foundational stable tools to `TOP_LEVEL_TOOL_NAMES`.
- Add only the most essential experimental direct tools to `EXPERIMENTAL_TOP_LEVEL_TOOL_NAMES`. Tools omitted from this list remain available through `search_tools` and `execute_tool` after the normal skill, constraint, experimental, and capability filters pass.
- Leave long-tail tools out of the experimental direct surface unless there is a clear reason they need to be visible without discovery. The catalog gateway tools themselves are experimental for now.
- Add only high-frequency, foundational tools to `TOP_LEVEL_TOOL_NAMES`.
- Leave long-tail tools out of the direct surface unless there is a clear reason they need to be visible without discovery. Tools omitted from this list remain available through `search_tools` and `execute_tool` after the normal skill, constraint, experimental, and capability filters pass.
- Keep `EXPERIMENTAL_TOP_LEVEL_TOOL_NAMES` aligned with `TOP_LEVEL_TOOL_NAMES` unless a future experimental mode intentionally needs a different direct surface.
- Keep private implementation helpers as plain modules/functions instead of MCP tools.

Do not add search-only summaries or catalog-only schemas. `search_tools` indexes the existing tool name and description.
Expand Down
12 changes: 5 additions & 7 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,15 +206,14 @@ Each tool follows a consistent structure:
- Sentry MCP must stay under 25 tools (target: ~20)
- Consolidate functionality where possible
- Consider parameter variants over new tools
- Long-tail operations can live in the tool catalog and, in experimental mode,
be reached through `search_tools` and `execute_tool` instead of being exposed
directly in `tools/list`
- Long-tail operations can live in the tool catalog and be reached through
`search_tools` and `execute_tool` instead of being exposed directly in
`tools/list`

**Tool Catalog and Direct Exposure:**

Tool modules define the operation itself. Ordinary tools are catalog-eligible by
default and can be reached through `search_tools` and `execute_tool` when the
catalog gateway is enabled in experimental mode.
default and can be reached through `search_tools` and `execute_tool`.
Ordinary operation modules live as flat files under
`packages/mcp-core/src/tools/catalog/` and are registered in
`packages/mcp-core/src/tools/catalog/index.ts`.
Expand All @@ -227,8 +226,7 @@ search, schema, and execution helpers. Wrapper/gateway tools such as

`packages/mcp-core/src/tools/surfaces.ts` only centralizes the subsets of
catalog tools that should also be exposed directly through MCP `tools/list`.
Stable mode keeps a broader direct surface for compatibility. Experimental mode
keeps a smaller direct surface because long-tail operations can be discovered
The direct surface stays small because long-tail operations can be discovered
with `search_tools` and invoked with `execute_tool`.
The same availability filters (skills, constraints, experimental mode, and
required capabilities) apply before either direct registration or catalog
Expand Down
6 changes: 3 additions & 3 deletions docs/claude-code-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ Two variants are published:

| Plugin | MCP URL | Purpose |
|--------|---------|---------|
| `sentry-mcp` | `https://mcp.sentry.dev/mcp` | Stable tool defaults |
| `sentry-mcp-experimental` | `https://mcp.sentry.dev/mcp?experimental=1` | Forward-looking tool variants and experimental features |
| `sentry-mcp` | `https://mcp.sentry.dev/mcp` | Default catalog gateway surface |
| `sentry-mcp-experimental` | `https://mcp.sentry.dev/mcp?experimental=1` | Forward-looking feature flags |

## Directory Layout

Expand Down Expand Up @@ -73,7 +73,7 @@ This script:
1. Imports all tools from `packages/mcp-core/src/tools/index.ts`
2. Imports all skills from `packages/mcp-core/src/skills.ts`
3. Writes `toolDefinitions.json` and `skillDefinitions.json` to `packages/mcp-core/src/`
4. Updates `allowedTools` in both `plugins/sentry-mcp/agents/sentry-mcp.md` and `plugins/sentry-mcp-experimental/agents/sentry-mcp.md`, using the stable direct surface for the stable plugin and the experimental direct surface for the experimental plugin
4. Updates `allowedTools` in both `plugins/sentry-mcp/agents/sentry-mcp.md` and `plugins/sentry-mcp-experimental/agents/sentry-mcp.md`, using the direct surface for each plugin mode

The script runs automatically as a `prebuild` and `pretest` hook in `packages/mcp-core/package.json`. Run it explicitly after:
- Adding, removing, or renaming tools
Expand Down
2 changes: 1 addition & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ The remote deployment intentionally separates trust boundaries:
2. **Authorization is layered**: Sentry scopes, MCP skills, and MCP resource constraints each narrow access differently.
3. **Each session can be path-scoped**: `/mcp/:org` and `/mcp/:org/:project` produce tokens that only work for that scoped MCP URL.
4. **Refresh does not widen access**: MCP refresh reuses the same stored grant props and does not ask Sentry for broader permissions.
5. **Stale or invalid grants fail closed**: legacy grants, missing props, or rejected upstream tokens are revoked or require re-authentication.
5. **Stale or invalid grants fail closed**: missing props, invalid skills, or rejected upstream tokens are revoked or require re-authentication. Already-issued Cloudflare grants with the old `preprod` skill are treated as `inspect`.

## OAuth Architecture

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ Try asking me things like:
title={
endpointMode === "agent"
? "Agent mode: Only use_sentry tool (click to switch to standard)"
: "Standard mode: All 19 tools available (click to switch to agent)"
: "Standard mode: Direct tools plus searchable catalog (click to switch to agent)"
}
className={`shadow-lg max-xl:order-2 rounded-xl backdrop-blur ${
endpointMode === "agent" ? "ring-4 ring-violet-300/50" : "ring-0"
Expand Down
16 changes: 16 additions & 0 deletions packages/mcp-cloudflare/src/server/lib/mcp-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,22 @@ describe("MCP Handler", () => {
expect(await response.text()).toContain("No valid skills");
});

it("accepts legacy preprod grants as inspect", async () => {
const request = createMcpRequest("tools/list");
const ctx = createMcpContext({ grantedSkills: ["preprod"] });

const response = await mcpHandler.fetch!(request, createTestEnv(), ctx);

expect(response.status).toBe(200);
const body = await parseSSEResponse<{
result?: { tools: Array<{ name: string }> };
}>(response);
const toolNames = body.result?.tools.map((tool) => tool.name) ?? [];

expect(toolNames).toContain("search_events");
expect(toolNames).not.toContain("search_docs");
});

it("applies the authenticated MCP rate limit per user", async () => {
const mockUserRateLimiter = {
limit: vi.fn().mockResolvedValue({ success: true }),
Expand Down
6 changes: 4 additions & 2 deletions packages/mcp-cloudflare/src/server/lib/mcp-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,9 +233,11 @@ const mcpHandler: ExportedHandler<Env> = {
);
}

const { valid: validSkills, invalid: invalidSkills } = parseSkills(
rawProps.grantedSkills as string[],
const grantedSkills = (rawProps.grantedSkills as string[]).map((skill) =>
skill === "preprod" ? "inspect" : skill,
);
const { valid: validSkills, invalid: invalidSkills } =
parseSkills(grantedSkills);

if (invalidSkills.length > 0) {
logWarn("Ignoring invalid skills from OAuth provider", {
Expand Down
26 changes: 25 additions & 1 deletion packages/mcp-cloudflare/src/server/oauth/callback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,15 @@ async function createClient(testEnv: Env, name = "Test Client") {
async function createSignedCallbackState(
clientId: string,
resource?: string,
skills: string[] = ["inspect"],
): Promise<string> {
const now = Date.now();
const payload: OAuthState = {
req: {
clientId,
redirectUri: REDIRECT_URI,
scope: ["org:read"],
skills: ["inspect"],
skills,
...(resource ? { resource } : {}),
},
iat: now,
Expand Down Expand Up @@ -427,6 +428,29 @@ describe("oauth callback routes", () => {
);
});

it("rejects callback with legacy preprod skill selection", async () => {
const testEnv = createTestEnv();
const oauthApp = createTestApp();
const client = await createClient(testEnv);
const cookie = await approveClient(oauthApp, testEnv, client.clientId);
const state = await createSignedCallbackState(
client.clientId,
undefined,
["preprod"],
);

const response = await callCallback(oauthApp, testEnv, {
state,
code: "test-code",
cookie,
});

expect(response.status).toBe(400);
expect(await response.text()).toContain(
"You must select at least one valid permission",
);
});

it("rejects callback with cookie for different client", async () => {
const testEnv = createTestEnv();
const oauthApp = createTestApp();
Expand Down
31 changes: 29 additions & 2 deletions packages/mcp-core/scripts/generate-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,29 @@ async function generateSkillDefinitions() {
throw new Error("Failed to import tools from src/tools/index.ts");
}

const defaultVisibleEntries = Object.entries(toolsDefault).flatMap(
([key, tool]): Array<[string, DefinitionTool]> => {
if (!tool || typeof tool !== "object") {
throw new Error(`Invalid tool: ${key}`);
}

const t = tool as DefinitionTool;
if (
surfacesModule.isWrapperToolName(t.name) ||
!toolTypesModule.isToolVisibleInMode(t, false)
) {
return [];
}

return [[key, t]];
},
);
const defaultDirectToolNames = toolNamesFromEntries(
defaultVisibleEntries.filter(([, tool]) =>
surfacesModule.isTopLevelToolName(tool.name, false),
),
);

// Build tools array for each skill
const skillsWithTools = skills.map((skill) => {
const skillTools: Array<{
Expand All @@ -217,15 +240,19 @@ async function generateSkillDefinitions() {
return [toolKey, t.name];
}),
);
const availableToolNames = new Set([
...skillToolNames,
...defaultDirectToolNames,
]);

for (const [, tool] of skillToolEntries) {
const t = tool as DefinitionTool;
skillTools.push({
name: t.name,
description: toolTypesModule.resolveDescription(t.description, {
experimentalMode: false,
availableToolNames: skillToolNames,
directToolNames: skillToolNames,
availableToolNames,
directToolNames: defaultDirectToolNames,
}),
requiredScopes: Array.isArray(t.requiredScopes) ? t.requiredScopes : [],
});
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-core/src/internal/formatting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ describe("formatIssueOutput", () => {
});

expect(output).toContain(
"- Full distributed trace and span tree: Use the Sentry tool `get_sentry_resource(resourceType='trace', organizationSlug='sentry-mcp-evals', resourceId='3032af8bcdfe4423b937fc5c041d5d82')`",
"- Full distributed trace and span tree: Use the Sentry tool `get_sentry_resource`",
);
expect(output).toContain(
"- Related span search: Related span search is not available in this session",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,10 @@ describe("tool call formatting", () => {
},
experimentalMode: true,
}),
).toBe(
"Use the Sentry tool `search_events(organizationSlug='my-org', query='level:error')`",
);
).toBe("Use the Sentry tool `search_events`");
});

it("formats catalog guidance when a tool is not top-level in the current mode", () => {
it("formats tool-name guidance when a tool is not top-level in the current mode", () => {
expect(
formatToolCallInstruction({
toolName: "get_doc",
Expand All @@ -29,12 +27,10 @@ describe("tool call formatting", () => {
},
experimentalMode: true,
}),
).toBe(
'Use the Sentry tool `get_doc`: search `search_tools(query=\'get_doc\')`, then call `execute_tool` with name `get_doc` and arguments `{"path":"/platforms/javascript/guides/nextjs.md"}`',
);
).toBe("Use the Sentry tool `get_doc`");
});

it("formats purpose text before catalog gateway steps", () => {
it("formats purpose text in tool-name guidance", () => {
expect(
formatToolCallInstruction({
toolName: "find_releases",
Expand All @@ -50,7 +46,7 @@ describe("tool call formatting", () => {
purpose: "to list releases and their details",
}),
).toBe(
'Use the Sentry tool `find_releases` to list releases and their details: search `search_tools(query=\'find_releases\')`, then call `execute_tool` with name `find_releases` and arguments `{"organizationSlug":"my-org"}`',
"Use the Sentry tool `find_releases` to list releases and their details",
);
});

Expand All @@ -66,7 +62,7 @@ describe("tool call formatting", () => {
).toBe("Release listing is not available");
});

it("does not format non-top-level stable tools as direct calls", () => {
it("formats tool-name guidance for non-top-level tools in default mode", () => {
expect(
formatToolCallInstruction({
toolName: "get_snapshot_image",
Expand All @@ -77,9 +73,7 @@ describe("tool call formatting", () => {
},
experimentalMode: false,
}),
).toBe(
"The Sentry tool `get_snapshot_image` is not available in this session",
);
).toBe("Use the Sentry tool `get_snapshot_image`");
});

it("uses fallback guidance for unavailable tools", () => {
Expand All @@ -100,7 +94,7 @@ describe("tool call formatting", () => {
).toBe("Release listing is not available");
});

it("uses catalog guidance only when the target tool is available", () => {
it("uses tool-name guidance only when the target tool is available", () => {
expect(
formatToolCallInstruction({
toolName: "find_releases",
Expand All @@ -114,9 +108,7 @@ describe("tool call formatting", () => {
"execute_tool",
]),
}),
).toBe(
'Use the Sentry tool `find_releases`: search `search_tools(query=\'find_releases\')`, then call `execute_tool` with name `find_releases` and arguments `{"organizationSlug":"my-org"}`',
);
).toBe("Use the Sentry tool `find_releases`");
});

it("escapes arguments in direct call examples", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,6 @@ function formatArguments(args: Record<string, JsonValue>): string {
.join(", ");
}

function formatArgumentsJson(args: Record<string, JsonValue>): string {
return JSON.stringify(args);
}

function formatPurpose(purpose: string | undefined): string {
return purpose ? ` ${purpose}` : "";
}
Expand Down Expand Up @@ -62,11 +58,14 @@ export function formatToolCall({
return `${toolName}(${formattedArgs})`;
}

/**
* Formats user-facing tool guidance as a direct call, catalog gateway call, or
* fallback message based on the current session's available/direct tools.
*/
export function formatToolCallInstruction({
toolName,
arguments: args = {},
arguments: _args = {},
experimentalMode,
searchQuery = toolName,
purpose,
availableToolNames,
directToolNames,
Expand All @@ -75,7 +74,6 @@ export function formatToolCallInstruction({
toolName: string;
arguments?: Record<string, JsonValue>;
experimentalMode: boolean;
searchQuery?: string;
purpose?: string;
availableToolNames?: ReadonlySet<string>;
directToolNames?: ReadonlySet<string>;
Expand All @@ -87,28 +85,17 @@ export function formatToolCallInstruction({
targetAvailable &&
isDirectTool(toolName, experimentalMode, directToolNames)
) {
return `Use the Sentry tool \`${formatToolCall({
toolName,
arguments: args,
})}\`${formatPurpose(purpose)}`;
return `Use the Sentry tool \`${toolName}\`${formatPurpose(purpose)}`;
}

const catalogGatewayAvailable =
experimentalMode &&
isToolAvailable("search_tools", availableToolNames) &&
isToolAvailable("execute_tool", availableToolNames) &&
isDirectTool("search_tools", experimentalMode, directToolNames) &&
isDirectTool("execute_tool", experimentalMode, directToolNames);

if (targetAvailable && catalogGatewayAvailable) {
return [
`Use the Sentry tool \`${toolName}\`${formatPurpose(purpose)}:`,
`search \`${formatToolCall({
toolName: "search_tools",
arguments: { query: searchQuery },
})}\`,`,
`then call \`execute_tool\` with name \`${toolName}\` and arguments \`${formatArgumentsJson(args)}\``,
].join(" ");
return `Use the Sentry tool \`${toolName}\`${formatPurpose(purpose)}`;
}

return (
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-core/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const ParamTeamSlug = z
.trim()
.superRefine(validateSlug)
.describe(
"The team's slug. You can find a list of existing teams in an organization using the `find_teams()` tool.",
"The team's slug. You can find a list of existing teams in an organization with the Sentry tool `find_teams`.",
);

export const ParamProjectSlug = z
Expand Down
Loading
Loading