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
17 changes: 17 additions & 0 deletions apps/cli-docs/src/fragments/commands/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,20 @@ For a self-hosted instance, first authenticate the CLI against that host. You ca
```

`--host`, `--url`, and `--insecure-http` still work but are deprecated.

## Limit the server to an organization or project

Pass `<org>/` to limit every call to one organization, or `<org>/<project>` to one project:

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

`--org` and `--project` do the same; a target and a flag that name different values are an error. A bare `acme` is refused, because other commands read it as a project search: pass `acme/` for the organization. The target takes no URL; pick the Sentry instance with `SENTRY_HOST` or `SENTRY_URL`.
9 changes: 6 additions & 3 deletions docs/testing/stdio.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,9 +369,12 @@ Run `pnpm --silent --dir packages/cli run cli mcp --help` for the full list.
# Host configuration: set SENTRY_HOST or SENTRY_URL (http:// for plain
# HTTP). --host, --url, and --insecure-http still work but are deprecated.

# Constraints (the CLI's --org/--project; --organization-slug and
# --project-slug still work but are deprecated)
--org=my-org # Limit every call to this organization
# Constraints: an optional my-org/ or my-org/my-project target, or the
# CLI's --org/--project (--organization-slug and --project-slug still work
# but are deprecated). A target and a flag must not disagree.
my-org/ # Limit every call to this organization
my-org/my-project # Limit calls to this project
--org=my-org # Same as my-org/
--project=my-project # Limit calls to this project

# Skills management
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,7 @@ View Sentry logs

Start a local Sentry MCP server

- `sentry mcp` — Start a local Sentry MCP server
- `sentry mcp [<org/project>]` — Start a local Sentry MCP server

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,16 @@ requires:

Start a local Sentry MCP server

### `sentry mcp`
### `sentry mcp [<org/project>]`

Start a local Sentry MCP server

**Arguments:**

| Argument | Description |
|----------|-------------|
| `[<org/project>]` | &lt;org&gt;/ (an organization) or &lt;org&gt;/&lt;project&gt; to limit calls to |

**Flags:**
- `--access-token <value> - Deprecated: set SENTRY_AUTH_TOKEN instead`
- `--org <value> - Limit every call to this organization`
Expand All @@ -37,6 +43,12 @@ Start a local Sentry MCP server
# Start the server with the current CLI login
sentry mcp

# Limit every call to one organization
sentry mcp acme/

# Limit calls to one project
sentry mcp acme/web

# Grant every skill, including seer on self-hosted
sentry mcp --all-skills

Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ function skipLeadingGlobalFlag(
/**
* Whether `cliArgs` runs the `mcp` command: `mcp` after any leading global
* flags, or no command at all when `mcp` is the default (`@sentry/mcp-server`).
* There, a first word that names no route is `mcp`'s `<org>/<project>` target:
* Stricli routes it to the default command. Route names win over a target.
*
* The stdio server owns stdout for JSON-RPC and reports its own telemetry, so
* the runner starts it without the CLI's telemetry, recovery middleware, and
Expand All @@ -220,7 +222,10 @@ export function isMcpInvocation(
for (let index = 0; index < cliArgs.length;) {
const token = cliArgs[index] ?? "";
if (!token.startsWith("-")) {
return token === "mcp";
return (
token === "mcp" ||
(isDefault && !routes.getRoutingTargetForInput(token))
);
}
const nextIndex = skipLeadingGlobalFlag(cliArgs, index);
if (nextIndex === undefined) {
Expand Down
32 changes: 29 additions & 3 deletions packages/cli/src/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,17 @@
import type { SentryContext } from "../context.js";
import { buildCommand } from "../lib/command.js";
import {
completingParser,
ORG_PROJECT_TARGET,
parseOrgSlug,
parseProjectSlug,
parseText,
} from "../lib/completion-target.js";
import type { McpFlags } from "../lib/mcp/args.js";
import {
type McpFlags,
type McpScope,
parseMcpTarget,
} from "../lib/mcp/args.js";

/**
* mcp-core's `ALL_SKILLS`, copied so the command tree does not import
Expand Down Expand Up @@ -92,6 +98,14 @@ export const mcpCommand = buildCommand({
description: "Start the server with the current CLI login",
command: "sentry mcp",
},
{
description: "Limit every call to one organization",
command: "sentry mcp acme/",
},
{
description: "Limit calls to one project",
command: "sentry mcp acme/web",
},
{
description: "Grant every skill, including seer on self-hosted",
command: "sentry mcp --all-skills",
Expand All @@ -107,6 +121,18 @@ export const mcpCommand = buildCommand({
],
},
parameters: {
positional: {
kind: "tuple",
parameters: [
{
placeholder: "org/project",
brief:
"<org>/ (an organization) or <org>/<project> to limit calls to",
parse: completingParser(ORG_PROJECT_TARGET, parseMcpTarget),
optional: true,
},
],
},
flags: {
"access-token": stringFlag("Deprecated: set SENTRY_AUTH_TOKEN instead"),
host: deprecatedStringFlag("SENTRY_HOST"),
Expand Down Expand Up @@ -152,10 +178,10 @@ export const mcpCommand = buildCommand({
},
},
// oxlint-disable-next-line require-yield -- The server speaks JSON-RPC on stdout; the command yields no output.
async *func(this: SentryContext, flags: McpFlags) {
async *func(this: SentryContext, flags: McpFlags, scope?: McpScope) {
const { serveMcp } = await import("../lib/mcp.js");
// Resolves when the client disconnects, so the runner tears down only
// after the server is done.
await serveMcp(flags, this.cwd);
await serveMcp(flags, this.cwd, scope);
},
});
11 changes: 9 additions & 2 deletions packages/cli/src/lib/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { AuthError, HostScopeError } from "./errors.js";
import {
deprecatedFlags,
type McpFlags,
type McpScope,
mergeMcpArgs,
parseMcpEnv,
} from "./mcp/args.js";
Expand Down Expand Up @@ -343,11 +344,17 @@ function preferEnvToken(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
* @param cwd - The command context's working directory, where a repo-local
* `.sentryclirc` is looked up. Unlike `process.cwd()`, it survives a deleted
* working directory (see `resolveCwd` in `context.ts`).
* @param scope - The organization and project of the `[<org>/[<project>]]`
* positional, if one was passed
*/
export async function serveMcp(flags: McpFlags, cwd: string): Promise<void> {
export async function serveMcp(
flags: McpFlags,
cwd: string,
scope?: McpScope,
): Promise<void> {
const env = getEnv();
const config = finalizeMcpConfig(
mergeMcpArgs(withConfiguredTarget(flags), parseMcpEnv(env)),
mergeMcpArgs(withConfiguredTarget(flags), parseMcpEnv(env), scope),
);
for (const { flag, replacement } of deprecatedFlags(flags)) {
// stderr only: stdout carries the stdio MCP transport.
Expand Down
90 changes: 84 additions & 6 deletions packages/cli/src/lib/mcp/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
* env token (see `lib/mcp.ts`).
*/

import { parseOrgProjectInput } from "../arg-parsing.js";
import { ValidationError } from "../errors.js";
import { validateResourceId } from "../input-validation.js";

/** `sentry mcp` flags, as Stricli parses them (see `commands/mcp.ts`). */
export type McpFlags = {
Expand Down Expand Up @@ -38,6 +40,63 @@ export type McpFlags = {
readonly "project-slug"?: string;
};

/** The organization, and optionally project, of a `sentry mcp` target. */
export type McpScope = {
readonly org: string;
readonly project?: string;
};

const TARGET_USAGE =
"Pass <org>/ for an organization, or <org>/<project> for a project.";

/**
* Parse the optional `[<org>/[<project>]]` positional of `sentry mcp`.
*
* Reads the target with the CLI's shared parser but takes only the forms that
* name an organization: the server is scoped up front, with no project search
* or auto-detection. A bare `<slug>` would be a project search to every other
* command, so it is refused instead of read as an organization; under
* `@sentry/mcp-server` it is also what an unknown command reaches. A URL is
* refused too: the server's host comes from `SENTRY_HOST`/`SENTRY_URL`.
*
* @returns `undefined` for an empty target, which scopes nothing
* @throws {ValidationError} For any other form
*/
export function parseMcpTarget(input: string): McpScope | undefined {
const text = input.trim();
if (/^[a-z][a-z\d+.-]*:\/\//i.test(text)) {
throw new ValidationError(
`'${text}' is a URL. ${TARGET_USAGE} Set SENTRY_URL to pick the Sentry instance.`,
"target",
);
}
const { parsed } = parseOrgProjectInput(text);
switch (parsed.type) {
case "auto-detect":
return;
case "org-all":
validateResourceId(parsed.org, "organization slug");
return { org: parsed.org };
case "explicit":
if (/[/,]/.test(parsed.project)) {
throw new ValidationError(
`'${text}' names more than one project. ${TARGET_USAGE}`,
"target",
);
}
validateResourceId(parsed.org, "organization slug");
validateResourceId(parsed.project, "project slug");
return { org: parsed.org, project: parsed.project };
case "project-search":
throw new ValidationError(
text.includes("/")
? `'${text}' has no organization. ${TARGET_USAGE}`
: `'${text}' names no command, and a target needs an organization: pass '${text}/' for the organization, or '<org>/${text}' for a project.`,
"target",
);
}
}

/**
* Deprecated flags and what replaces each: a CLI-wide flag, or the
* environment variable every other command reads the Sentry instance from.
Expand All @@ -59,11 +118,15 @@ export function deprecatedFlags(
).map(([flag, replacement]) => ({ flag, replacement }));
}

/** A scope flag's value, from its CLI-wide name or its deprecated alias. */
/**
* A scope's value, from the target positional, the CLI-wide flag, or the
* flag's deprecated alias. Any two of them must agree.
*/
function scopeFlag(
flags: McpFlags,
name: "org" | "project",
alias: "organization-slug" | "project-slug",
fromTarget: string | undefined,
): string | undefined {
const value = flags[name];
const aliased = flags[alias];
Expand All @@ -72,7 +135,18 @@ function scopeFlag(
`--${name} and --${alias} name different values; pass only --${name}.`,
);
}
return value ?? aliased;
const fromFlag = value ?? aliased;
if (
fromTarget !== undefined &&
fromFlag !== undefined &&
fromTarget !== fromFlag
) {
const flag = value === undefined ? alias : name;
throw new ValidationError(
`The target and --${flag} name different values; pass only the target.`,
);
}
return fromTarget ?? fromFlag;
}

/**
Expand Down Expand Up @@ -158,8 +232,12 @@ export function parseMcpEnv(env: NodeJS.ProcessEnv): McpEnvArgs {
return fromEnv;
}

/** Merge flags over environment values. */
export function mergeMcpArgs(flags: McpFlags, env: McpEnvArgs): McpMergedArgs {
/** Merge flags and the target positional over environment values. */
export function mergeMcpArgs(
flags: McpFlags,
env: McpEnvArgs,
scope?: McpScope,
): McpMergedArgs {
return {
url: flags.url,
host: flags.host,
Expand All @@ -177,7 +255,7 @@ export function mergeMcpArgs(flags: McpFlags, env: McpEnvArgs): McpMergedArgs {
allSkills: flags["all-skills"],
disableSkills: flags["disable-skills"] ?? env.disableSkills,
experimental: flags.experimental,
organizationSlug: scopeFlag(flags, "org", "organization-slug"),
projectSlug: scopeFlag(flags, "project", "project-slug"),
organizationSlug: scopeFlag(flags, "org", "organization-slug", scope?.org),
projectSlug: scopeFlag(flags, "project", "project-slug", scope?.project),
};
}
42 changes: 42 additions & 0 deletions packages/cli/test/lib/mcp-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,18 @@ describe("isMcpInvocation", () => {
expect(isMcpInvocation(mcpServerRoutes, ["auth", "status"])).toBe(false);
expect(isMcpInvocation(mcpServerRoutes, ["help"])).toBe(false);
});

test("reads a first word that names no route as the default command's target", () => {
expect(isMcpInvocation(mcpServerRoutes, ["acme/web"])).toBe(true);
expect(isMcpInvocation(mcpServerRoutes, ["--all-skills", "acme/"])).toBe(
true,
);
expect(isMcpInvocation(mcpServerRoutes, ["mcp", "acme/"])).toBe(true);
// Route names win over a target, so they still reach their command.
expect(isMcpInvocation(mcpServerRoutes, ["auth"])).toBe(false);
// Without `mcp` as the default, a first word is a command, never a target.
expect(isMcpInvocation(routes, ["acme/web"])).toBe(false);
});
});

describe("@sentry/mcp-server application", () => {
Expand All @@ -117,9 +129,39 @@ describe("@sentry/mcp-server application", () => {
"all-skills": true,
});
});

test("starts the server for an <org>/<project> target", async () => {
await runCli(["acme/web", "--all-skills"], MCP_SERVER);

expect(stderr).toBe("");
expect(mocks.serveMcp).toHaveBeenCalledOnce();
expect(mocks.serveMcp.mock.lastCall?.[0]).toMatchObject({
"all-skills": true,
});
expect(mocks.serveMcp.mock.lastCall?.[2]).toEqual({
org: "acme",
project: "web",
});
expect(mocks.withTelemetry).not.toHaveBeenCalled();
});

test("rejects a bare slug target with a hint", async () => {
await runCli(["acme"], MCP_SERVER);

expect(mocks.serveMcp).not.toHaveBeenCalled();
expect(stderr).toContain("acme/");
expect(stdout).toBe("");
});
});

describe("running `mcp`", () => {
test("passes the <org>/ target to the server", async () => {
await runCli(["mcp", "acme/"], SENTRY_CLI);

expect(mocks.serveMcp).toHaveBeenCalledOnce();
expect(mocks.serveMcp.mock.lastCall?.[2]).toEqual({ org: "acme" });
});

test("serves through Stricli, without the CLI's telemetry", async () => {
await runCli(["mcp", "--access-token=tok"], SENTRY_CLI);

Expand Down
Loading
Loading