Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3a880b1
feat(mcp): Serve CLI commands through search_sentry and the execute t…
sentry-junior[bot] Nov 3, 2026
39d90f1
fix(mcp): Keep cut-off command results valid JSON
sentry-junior[bot] Nov 3, 2026
9c5de1c
feat(mcp): Serve each exposed CLI command as its own MCP tool
sentry-junior[bot] Nov 4, 2026
5395f34
Merge remote-tracking branch 'origin/feat/command-catalog' into feat/…
sentry-junior[bot] Nov 4, 2026
c358733
fix(mcp): Check every session target a command call names
sentry-junior[bot] Nov 4, 2026
070593f
fix(mcp): Check the project in a three-part feedback or issue ID
sentry-junior[bot] Nov 4, 2026
d25d5b7
fix(mcp): Keep project sessions and cut pages from leaking
sentry-junior[bot] Nov 4, 2026
c8076bb
fix(mcp): Close the scope gaps Warden found in command tools
sentry-junior[bot] Nov 4, 2026
8731582
fix(mcp): Accept api/0/ endpoints without a leading slash in sessions
sentry-junior[bot] Nov 4, 2026
de13bc4
fix(mcp): Pin sentry api and # issue references to the session project
sentry-junior[bot] Nov 4, 2026
138ae47
Merge branch 'feat/command-catalog' into feat/cli-first-mcp-execute
sentry-junior[bot] Nov 5, 2026
8a06e45
fix(mcp): Give each command tool a display title
sentry-junior[bot] Nov 5, 2026
5e08735
Merge branch 'feat/command-catalog' into feat/cli-first-mcp-execute
sentry-junior[bot] Nov 5, 2026
59a6feb
fix(mcp): Refuse absolute URLs in the sentry api tool
sentry-junior[bot] Nov 5, 2026
1db2a1f
Merge remote-tracking branch 'origin/feat/cli-first-mcp-execute' into…
sentry-junior[bot] Nov 5, 2026
0fa21c4
Merge branch 'feat/command-catalog' into feat/cli-first-mcp-execute
sentry-junior[bot] Nov 5, 2026
0b80422
Merge remote-tracking branch 'origin/feat/cli-first-mcp-execute' into…
sentry-junior[bot] Nov 5, 2026
8e4e42a
fix(mcp): Pin dashboard org words and short org/project targets
sentry-junior[bot] Nov 5, 2026
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
12 changes: 12 additions & 0 deletions docs/architecture/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,18 @@ The same availability filters (skills, constraints, experimental mode, and
required capabilities) apply before either direct registration or catalog
execution.

`sentry mcp --commands` (a preview) serves CLI commands instead of the
catalog, through `packages/mcp-core/src/command-surface/`: every `exposed`
command in the generated `command-catalog.json` is its own tool, named from
its path (`sentry issue list` → `sentry_issue_list`), with the catalog's
brief, examples, and parameter schema, and hints from its permission class.
There is no search or execute meta-tool; harnesses search the tool list
themselves. A call is checked against the command's schema and the
session's constraints before a runner executes it. The stdio runner
(`packages/cli/src/lib/mcp/command-surface.ts`) runs calls through
`createSentrySDK().run()`; the hosted server will pass its own.
`--read-only` drops the write tools.

### 6. Error Handling Philosophy

Two-tier error system:
Expand Down
4 changes: 3 additions & 1 deletion docs/operations/stdio-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ until the server restarts.
Routing is off when the credential is a static token (`SENTRY_AUTH_TOKEN`,
`--access-token`, `SENTRY_ACCESS_TOKEN`): that token is the only account.
`--organization-slug` (or an `<org>/` target) picks the account that knows the
organization once, at startup, and every call uses it. To use accounts on
organization once, at startup, and every call uses it. `--commands` does not
route calls either: it uses the account that knows its `<org>/` target, else
the startup account. To use accounts on
different servers at once (say, sentry.io and a self-hosted server), configure
one MCP server per host via `SENTRY_HOST`.

Expand Down
7 changes: 7 additions & 0 deletions docs/operations/token-cost-tracking.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ Per-Tool Breakdown:
...
```

**Measure the command surface** (`sentry mcp --commands`: one tool per
exposed CLI command) instead of the tool catalog:

```bash
pnpm run measure-tokens -- --commands
```

**Write JSON to file:**

```bash
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ Start a local Sentry MCP server
- `--all-skills - Grant every active skill, including seer on self-hosted`
- `--disable-skills <value> - Skills to remove, comma-separated`
- `--experimental - Enable forward-looking tool variants and experimental features`
- `--commands - Serve each CLI command as its own tool (sentry_issue_list, ...) instead of the tool catalog (preview)`
- `--read-only - With --commands: list no write tools and refuse write calls`
- `--agent-provider <value> - LLM provider for AI-powered search: openai, azure-openai, anthropic, or openrouter`
- `--openai-base-url <value> - OpenAI API base URL`
- `--openai-model <value> - OpenAI model (default: gpt-5)`
Expand All @@ -54,6 +56,9 @@ sentry mcp --all-skills
# Grant only some skills
sentry mcp --skills inspect,triage

# Serve CLI commands instead of the tool catalog, read-only
sentry mcp --commands --read-only

# Pick the LLM provider for AI-powered search
sentry mcp --agent-provider anthropic
```
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ export const mcpCommand = buildCommand({
description: "Grant only some skills",
command: "sentry mcp --skills inspect,triage",
},
{
description:
"Serve CLI commands instead of the tool catalog, read-only",
command: "sentry mcp --commands --read-only",
},
{
description: "Pick the LLM provider for AI-powered search",
command: "sentry mcp --agent-provider anthropic",
Expand Down Expand Up @@ -165,6 +170,12 @@ export const mcpCommand = buildCommand({
experimental: booleanFlag(
"Enable forward-looking tool variants and experimental features",
),
commands: booleanFlag(
"Serve each CLI command as its own tool (sentry_issue_list, ...) instead of the tool catalog (preview)",
),
"read-only": booleanFlag(
"With --commands: list no write tools and refuse write calls",
),
"agent-provider": stringFlag(
"LLM provider for AI-powered search: openai, azure-openai, anthropic, or openrouter",
),
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/lib/command-access.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/**
* What a command may do (permission class) and where agents may run it
* (exposure). `sentry mcp` lists and runs commands by these fields:
* `execute_sentry_read` runs only read commands, and hosts allowlist by
* permission class (#1254). `check:command-access` keeps them honest.
* (exposure). `sentry mcp` lists and runs commands by these fields: its
* command tools carry hints from the permission class, which hosts allowlist
* by (#1254). `check:command-access` keeps them honest.
*/

/**
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/lib/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,13 +440,31 @@ export async function serveMcp(
const { token, pin } = await withEnv(credentialEnv, () =>
resolveMcpCredentialWithLogin(config),
);

// A static token (`--access-token`, `SENTRY_AUTH_TOKEN`) is the only
// credential; a stored login can route calls to the other stored logins.
const router =
pin.kind === "stored" && !deprecatedToken
? createRouter(config, credentialEnv, pin)
: undefined;

if (config.commands) {
// One account for the session: the stored login that knows
// `--organization-slug`, else the startup account. Calls are not routed
// between logins, and the router refreshes a token near expiry.
const target = { organizationSlug: config.organizationSlug ?? null };
const getToken = router
? async () => (await router.resolve(target)).accessToken
: async () => token;
const { createCliCommandTools } = await import("./mcp/command-surface.js");
await serveMcpStdio(config, {
accessToken: await getToken(),
env,
tools: createCliCommandTools({ config, cwd, getToken }),
});
return;
Comment thread
sentry-junior[bot] marked this conversation as resolved.
Resolved
}

if (router && !config.organizationSlug) {
await serveMcpStdio(config, {
accessToken: token,
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/lib/mcp/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ export type McpFlags = {
readonly "all-skills": boolean;
readonly "disable-skills"?: string;
readonly experimental: boolean;
/** Serve CLI commands instead of the tool catalog. */
readonly commands: boolean;
/** With `--commands`: no write tool, and write calls are refused. */
readonly "read-only": boolean;
readonly org?: string;
readonly project?: string;
/** Deprecated alias of `--org`. */
Expand Down Expand Up @@ -193,6 +197,8 @@ export type McpMergedArgs = {
allSkills: boolean;
disableSkills?: string;
experimental: boolean;
commands: boolean;
readOnly: boolean;
organizationSlug?: string;
projectSlug?: string;
};
Expand Down Expand Up @@ -248,6 +254,8 @@ export function mergeMcpArgs(
allSkills: flags["all-skills"],
disableSkills: flags["disable-skills"] ?? env.disableSkills,
experimental: flags.experimental,
commands: flags.commands,
readOnly: flags["read-only"],
organizationSlug: scopeFlag(flags, "org", "organization-slug", scope?.org),
projectSlug: scopeFlag(flags, "project", "project-slug", scope?.project),
};
Expand Down
66 changes: 66 additions & 0 deletions packages/cli/src/lib/mcp/command-surface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* `sentry mcp --commands`: serve each exposed CLI command as its own MCP tool
* through mcp-core's command surface.
*
* The tools come from the generated command catalog, and each call runs
* through `createSentrySDK().run()`, which loads only the top-level route the
* call runs, never the whole command tree.
*
* Loaded only in commands mode. `@sentry/mcp-server` does not bundle command
* handlers, so its bundle replaces this module with a stub.
*/

import {
type CommandCall,
CommandFailedError,
createCommandTools,
type ToolRegistry,
toCommandArgs,
} from "@sentry/mcp-core/command-surface";
import { createSentrySDK, SentryError } from "../../index.js";
import type { McpServerConfig } from "./config.js";

export type CommandSurfaceSetup = {
config: McpServerConfig;
/** Where commands run, for DSN detection and project roots. */
cwd: string;
/** The session's current token, refreshed as needed. */
getToken: () => Promise<string>;
};

/** Run one call through the CLI SDK, with JSON output and no telemetry of its own. */
async function runCall(
setup: CommandSurfaceSetup,
call: CommandCall,
): Promise<unknown> {
const { config } = setup;
const sdk = createSentrySDK({
token: await setup.getToken(),
url: `${config.sentryProtocol}://${config.sentryHost}`,
org: call.org,
project: call.project,
cwd: setup.cwd,
// The MCP server owns the process's Sentry client.
telemetry: false,
});
try {
// `run()` is typed for streaming flags too; the command surface refuses them.
return await (sdk.run(...toCommandArgs(call)) as Promise<unknown>);
} catch (error) {
if (error instanceof SentryError) {
throw new CommandFailedError(call.path.join(" "), error.message);
}
throw error;
}
}

/** The command surface's tools, running calls through the CLI SDK. */
export function createCliCommandTools(
setup: CommandSurfaceSetup,
): ToolRegistry {
return createCommandTools({
runtime: "stdio",
readOnly: setup.config.readOnly,
run: (call) => runCall(setup, call),
});
}
11 changes: 11 additions & 0 deletions packages/cli/src/lib/mcp/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ export type McpServerConfig = {
agentProvider?: McpAgentProvider;
agentProviderFromFlag: boolean;
experimental: boolean;
/** Serve each CLI command as its own tool instead of the tool catalog. */
commands: boolean;
/** With `commands`: list no write tools, and refuse write calls. */
readOnly: boolean;
/** Skills granted for this session. */
finalSkills: Set<Skill>;
organizationSlug?: string;
Expand Down Expand Up @@ -173,6 +177,11 @@ export function finalizeMcpConfig(input: McpMergedArgs): McpServerConfig {
);
}
const finalSkills = resolveSkills(input, sentryHost);
if (input.readOnly && !input.commands) {
throw new ValidationError(
"--read-only works only with --commands. To limit the tool catalog, pass --skills.",
);
}

return {
sentryHost,
Expand All @@ -192,6 +201,8 @@ export function finalizeMcpConfig(input: McpMergedArgs): McpServerConfig {
agentProvider: resolveAgentProvider(input.agentProvider),
agentProviderFromFlag: input.agentProviderFromFlag,
experimental: input.experimental,
commands: input.commands,
readOnly: input.readOnly,
finalSkills,
organizationSlug: input.organizationSlug,
projectSlug: input.projectSlug,
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/lib/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { buildServer } from "@sentry/mcp-core/server";
import { sentryBeforeSend } from "@sentry/mcp-core/telem/sentry";
import { startStdio } from "@sentry/mcp-core/transports/stdio";
import type { ToolRegistry } from "@sentry/mcp-core/command-surface";
import type { CredentialRouter, ServerContext } from "@sentry/mcp-core/types";
import { setServerVersion } from "@sentry/mcp-core/version";
import {
Expand Down Expand Up @@ -59,6 +60,8 @@ export type McpServeOptions = {
*/
credentialRouter?: CredentialRouter;
env: NodeJS.ProcessEnv;
/** Tools to serve instead of the tool catalog (`--commands`). */
tools?: ToolRegistry;
};

const SENTRY_FLUSH_TIMEOUT_MS = 5000;
Expand Down Expand Up @@ -426,6 +429,7 @@ export async function serveMcpStdio(
const server = buildServer({
context,
experimentalMode: config.experimental,
tools: options.tools,
});
const connection = await startStdio(server, context).catch(
(error: unknown) => {
Expand Down
Loading
Loading