Skip to content
Draft
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
1 change: 1 addition & 0 deletions apps/cli-docs/src/content/docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ toolkit/
│ │ │ ├── help.ts # Help command
│ │ │ ├── info.ts # Print configuration and verify authentication
│ │ │ ├── init.ts # Initialize Sentry in your project (experimental)
│ │ │ ├── mcp.ts # Start a local Sentry MCP server
│ │ │ ├── schema.ts # Browse the Sentry API schema
│ │ │ └── wasm-split.ts# Add build ids to WebAssembly modules and split out debug data
│ │ ├── lib/ # Shared utilities
Expand Down
30 changes: 30 additions & 0 deletions apps/cli-docs/src/fragments/commands/mcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@

`sentry mcp` starts the local stdio Sentry MCP server using the active Sentry CLI session. Authenticate once with `sentry auth login`; no separate MCP login or token cache is required.

## Configure an MCP client

Point your MCP client at the installed Sentry CLI:

```json
{
"mcpServers": {
"sentry": {
"command": "sentry",
"args": ["mcp"]
}
}
}
```

For a self-hosted instance, first authenticate the CLI against that host. You can also override the target for this MCP server invocation:

```json
{
"mcpServers": {
"sentry": {
"command": "sentry",
"args": ["mcp", "--host=sentry.example.com"]
}
}
}
```
11 changes: 6 additions & 5 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,16 @@
"tsx": "tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs",
"cli": "tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs src/bin.ts",
"dev": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && tsx --import ./script/require-shim.mjs src/bin.ts",
"build": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts --single",
"build:all": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts",
"bundle": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/bundle.ts",
"typecheck": "pnpm run generate:docs && pnpm run generate:sdk && tsc --noEmit",
"build": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts --single",
"build:all": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts",
"bundle": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/bundle.ts",
"typecheck": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:docs && pnpm run generate:sdk && tsc --noEmit",
"lint": "biome check --no-errors-on-unmatched --error-on-warnings --max-diagnostics=none ./",
"lint:fix": "biome check --write --no-errors-on-unmatched --max-diagnostics=none ./",
"test": "pnpm run test:unit",
"test:unit": "pnpm run generate:docs && pnpm run generate:sdk && vitest run test/lib test/commands test/types test/script --coverage",
"test:changed": "pnpm run generate:docs && pnpm run generate:sdk && vitest run --changed",
"test:e2e": "pnpm run generate:docs && pnpm run generate:sdk && vitest run test/e2e",
"test:e2e": "pnpm tsx script/prepare-e2e-bundle.ts && vitest run test/e2e",
"test:init-eval": "vitest run test/init-eval --testTimeout 600000",
"generate:parser": "pnpm tsx script/generate-parser.ts",
"generate:sdk": "pnpm tsx script/generate-sdk.ts",
Expand All @@ -92,6 +92,7 @@
"ci:policy": "pnpm run check:patches && pnpm run check:deps && pnpm run check:errors && pnpm run check:fragments && pnpm run check:docs-sections && pnpm run check:env-coverage && pnpm run check:stale-refs"
},
"devDependencies": {
"@sentry/mcp-server": "workspace:*",
"@anthropic-ai/sdk": "^0.39.0",
"@biomejs/biome": "2.3.8",
"@clack/prompts": "0.11.0",
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,14 @@ View Sentry logs

→ Full flags and examples: `references/log.md`

### Mcp

Start a local Sentry MCP server

- `sentry mcp` — Start a local Sentry MCP server

→ Full flags and examples: `references/mcp.md`

### Monitor

Work with Sentry cron monitors
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
name: sentry-cli-mcp
version: 0.47.0-dev.0
description: Start a local Sentry MCP server
requires:
bins: ["sentry"]
auth: true
---

# Mcp Commands

Start a local Sentry MCP server

### `sentry mcp`

Start a local Sentry MCP server

All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags.
19 changes: 19 additions & 0 deletions packages/cli/script/prepare-e2e-bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { spawn } from "node:child_process";

const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
const child = spawn(pnpm, ["run", "bundle"], {
env: {
...process.env,
SENTRY_CLIENT_ID: process.env.SENTRY_CLIENT_ID ?? "test-client-id",
},
stdio: "inherit",
});

const exitCode = await new Promise<number>((resolve, reject) => {
child.once("error", reject);
child.once("close", (code) => resolve(code ?? 1));
});

if (exitCode !== 0) {
process.exitCode = exitCode;
}
2 changes: 2 additions & 0 deletions packages/cli/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { listCommand as issueListCommand } from "./commands/issue/list.js";
import { localRoute } from "./commands/local/index.js";
import { logRoute } from "./commands/log/index.js";
import { listCommand as logListCommand } from "./commands/log/list.js";
import { mcpCommand } from "./commands/mcp.js";
import { monitorRoute } from "./commands/monitor/index.js";
import { listCommand as monitorListCommand } from "./commands/monitor/list.js";
import { orgRoute } from "./commands/org/index.js";
Expand Down Expand Up @@ -142,6 +143,7 @@ export const routes = buildRouteMap({
explore: exploreCommand,
feedback: feedbackRoute,
log: logRoute,
mcp: mcpCommand,
monitor: monitorRoute,
snapshots: snapshotsRoute,
sourcemap: sourcemapRoute,
Expand Down
82 changes: 82 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { redactCredentialText } from "./lib/credential-redaction.js";
import { getEnv } from "./lib/env.js";
import { CliError, formatError } from "./lib/errors.js";
import { buildTopLevelFlags } from "./lib/global-flags.js";
Comment thread
MathurAditya724 marked this conversation as resolved.
import { initTimezone } from "./lib/timezone.js";

/**
Expand Down Expand Up @@ -229,13 +230,94 @@
retryArgs: string[]
) => Promise<void>;

/**
* Return MCP's arguments when it is the command after leading global flags.
*
* MCP owns stdout for JSON-RPC, so CLI-level output flags are deliberately
* ignored before it starts. Flags after `mcp` belong to the MCP server.
*/
function skipLeadingGlobalFlag(
cliArgs: readonly string[],
index: number
): number | undefined {
const { booleanFlags, valueFlags } = buildTopLevelFlags();
const token = cliArgs[index] ?? "";
const flag = token.split("=", 1)[0] ?? token;

if (booleanFlags.has(flag)) {
return index + 1;
}
if (!valueFlags.has(flag)) {
return;
}
return token.includes("=") || cliArgs[index + 1] === undefined
? index + 1
: index + 2;
}

Check warning on line 256 in packages/cli/src/cli.ts

View check run for this annotation

@sentry/warden / warden: code-review

Leading `--flag=value` globals prevent `sentry mcp` handoff

`token.split("=", 1)[0]` never strips the value, so argv like `sentry --org=acme mcp` fails MCP detection and falls through to the docs stub instead of starting the server. Use `split("=", 2)[0]` (or equivalent) when matching global flags.

export function getMcpArgs(cliArgs: readonly string[]): string[] | undefined {
for (let index = 0; index < cliArgs.length; ) {
const token = cliArgs[index] ?? "";
if (token === "--") {
return;
}
if (!token.startsWith("-")) {
return token === "mcp" ? cliArgs.slice(index + 1) : undefined;
}

const nextIndex = skipLeadingGlobalFlag(cliArgs, index);
if (nextIndex === undefined) {
return;
}
index = nextIndex;
}

return;
}

/** Run MCP and return whether the current invocation was handled by it. */
async function runMcpCommand(cliArgs: string[]): Promise<boolean> {
const mcpArgs = getMcpArgs(cliArgs);
if (!mcpArgs) {
return false;
}

const [{ startMcpServer }, { getExitCode }] = await Promise.all([
import("./lib/mcp.js"),
import("./lib/errors.js"),
]);

try {
await startMcpServer(mcpArgs);
} catch (mcpError) {
process.stderr.write(`${formatError(mcpError)}\n`);
process.exitCode = getExitCode(mcpError);
// MCP setup errors are terminal, unlike a running stdio server. Clean up
// network resources only on this error path so successful servers retain
// their dispatcher and are not force-exited on macOS.
const [{ scheduleForceExit }, { closeGlobalDispatcher }] =
await Promise.all([
import("./lib/force-exit.js"),
import("./lib/close-dispatcher.js"),
]);
scheduleForceExit();
await closeGlobalDispatcher();
}
Comment thread
MathurAditya724 marked this conversation as resolved.

return true;
}

/**
* Full CLI execution with telemetry, middleware, and error recovery.
*
* All heavy imports are loaded here (not at module top level) so the
* `__complete` fast-path can skip them entirely.
*/
export async function runCli(cliArgs: string[]): Promise<void> {
if (await runMcpCommand(cliArgs)) {
return;
}

const { isatty } = await import("node:tty");
const { ExitCode, run } = await import("@stricli/core");
const { app } = await import("./app.js");
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/src/commands/mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { SentryContext } from "../context.js";
import { buildCommand } from "../lib/command.js";
import { CommandOutput } from "../lib/formatters/output.js";

/** Documentation route for the stdio handoff in {@link runCli}. */
export const mcpCommand = buildCommand({
auth: false,
docs: {
brief: "Start a local Sentry MCP server",
fullDescription:
"Start the local stdio MCP server using the current Sentry CLI session. " +
"Configure an MCP client with `sentry mcp`; authenticate first with `sentry auth login`.",
},
output: { human: (message: string) => message },
parameters: {},
// biome-ignore lint/suspicious/useAwait: async generator required by buildCommand
async *func(this: SentryContext) {
yield new CommandOutput(
"The local MCP server is started by running `sentry mcp` from an MCP client configuration."
);
},
});
95 changes: 95 additions & 0 deletions packages/cli/src/lib/mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { getConfiguredSentryUrl } from "./constants.js";
import { refreshToken } from "./db/auth.js";
import { getEnv } from "./env.js";
import { HostScopeError, ValidationError } from "./errors.js";
import { getActiveTokenHost, isHostTrusted } from "./token-host.js";

type McpServerConfig = {
sentryHost: string;
sentryProtocol: "http" | "https";
};

function hasSentryTargetArg(args: readonly string[]): boolean {
return args.some(
(arg) =>
arg === "--host" ||
arg.startsWith("--host=") ||
arg === "--url" ||
arg.startsWith("--url=")
);
}

/**
* Translate the CLI's URL setting into MCP's host/protocol flags.
*
* The MCP parser intentionally rejects insecure SENTRY_URL values, while the
* CLI uses them for self-hosted defaults. Passing explicit flags preserves the
* selected CLI host without letting SENTRY_URL override --insecure-http.
*/
export function prepareMcpServerArgs(
args: readonly string[],
sentryUrl = getConfiguredSentryUrl()
): string[] {
if (!sentryUrl || hasSentryTargetArg(args)) {
return [...args];
}

// biome-ignore lint/plugin: invalid URLs are passed through to the MCP parser for its normal validation error.
try {
const url = new URL(sentryUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return [...args, `--url=${sentryUrl}`];
}
return [
...args,
`--host=${url.host}`,
...(url.protocol === "http:" ? ["--insecure-http"] : []),
];
} catch {
return [...args, `--url=${sentryUrl}`];
}
}

/**
* Resolve a credential for the local MCP server from the CLI's authenticated
* session, preserving the CLI's host-scoping protections.
*/
export async function resolveCliMcpAccessToken(
config: McpServerConfig
): Promise<string> {
const targetUrl = `${config.sentryProtocol}://${config.sentryHost}`;
const { token } = await refreshToken();
const tokenHost = getActiveTokenHost();

if (!(tokenHost && isHostTrusted(targetUrl, tokenHost))) {
throw new HostScopeError(
"Cannot start MCP server with the active CLI credentials",
targetUrl,
tokenHost
);
}

return token;
}

/** Start the local stdio server without introducing a second auth flow. */
export async function startMcpServer(args: string[]): Promise<void> {
if (args[0] === "auth") {
throw new ValidationError(
"Use `sentry auth` to manage credentials for `sentry mcp`."
);
}

const { runMcpServer } = await import("@sentry/mcp-server");
const {
SENTRY_HOST: _sentryHost,
SENTRY_URL: _sentryUrl,
...mcpEnv
} = getEnv();
await runMcpServer(prepareMcpServerArgs(args), {
environment: mcpEnv,
packageName: "sentry mcp",
resolveAccessToken: resolveCliMcpAccessToken,
throwOnError: true,
});
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Comment thread
MathurAditya724 marked this conversation as resolved.
}
4 changes: 3 additions & 1 deletion packages/cli/test/e2e/bundle-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ async function runBundleBuild(): Promise<void> {
}

async function waitForBundle(): Promise<void> {
const deadline = Date.now() + 55_000;
// Building the bundled CLI also builds its MCP runtime dependencies. On
// cold CI runners that can take longer than the old 55-second allowance.
const deadline = Date.now() + 115_000;
while (Date.now() < deadline) {
if (existsSync(BUNDLE_INDEX_PATH) && !existsSync(LOCK_DIR)) {
return;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/e2e/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const INK_APP_PATH = join(ROOT_DIR, "dist/ink-app.js");
describe("npm bundle", () => {
beforeAll(async () => {
await ensureBundleBuilt();
}, 60_000); // Bundle can take a while
}, 120_000); // Cold CI builds include the MCP runtime dependencies

test("bundle file exists", () => {
expect(existsSync(BUNDLE_BIN_PATH)).toBe(true);
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/e2e/library.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ describe("library mode (bundled)", () => {

beforeAll(async () => {
await ensureBundleBuilt();
}, 60_000);
}, 120_000); // Cold CI builds include the MCP runtime dependencies

// --- Bundle structure ---

Expand Down
Loading
Loading