From 340cdcd908698cfcd49374faab6c0ef2922ef369 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:55:13 +0000 Subject: [PATCH] feat(mcp): Accept an / target for sentry mcp `sentry mcp [/[]]` scopes the server like --org and --project do: `acme/` limits every call to an organization, `acme/web` to a project. The target goes through the CLI's shared org/project parser (parseOrgProjectInput, the side-effect-free parseOrgProjectArg). A bare `acme` is refused with a hint to pass `acme/`, since every other command reads it as a project search. URLs are refused with a hint to set SENTRY_URL, since the host comes from the environment. A target and --org/--project (or their deprecated aliases) that disagree are a ValidationError, like the existing alias conflicts in mergeMcpArgs. Where `mcp` is the default command (`@sentry/mcp-server`), a first word that names no route is the target, so `npx @sentry/mcp-server acme/web` starts the server without the CLI's telemetry. Route names still win. Refs #1357, #1731 Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- apps/cli-docs/src/fragments/commands/mcp.md | 17 ++++ docs/testing/stdio.md | 9 +- .../sentry-cli/skills/sentry-cli/SKILL.md | 2 +- .../skills/sentry-cli/references/mcp.md | 14 ++- packages/cli/src/cli.ts | 7 +- packages/cli/src/commands/mcp.ts | 32 +++++- packages/cli/src/lib/mcp.ts | 11 ++- packages/cli/src/lib/mcp/args.ts | 90 +++++++++++++++-- packages/cli/test/lib/mcp-command.test.ts | 42 ++++++++ packages/cli/test/lib/mcp-start.test.ts | 29 +++++- packages/cli/test/lib/mcp/args.test.ts | 98 ++++++++++++++++++- packages/cli/test/lib/mcp/parse-flags.ts | 25 ++++- packages/mcp-server/test/bundle.test.mjs | 11 ++- 13 files changed, 357 insertions(+), 30 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/mcp.md b/apps/cli-docs/src/fragments/commands/mcp.md index b9275899b..1fa3ac8d8 100644 --- a/apps/cli-docs/src/fragments/commands/mcp.md +++ b/apps/cli-docs/src/fragments/commands/mcp.md @@ -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 `/` to limit every call to one organization, or `/` 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`. diff --git a/docs/testing/stdio.md b/docs/testing/stdio.md index 004f55f6d..0f41ef24e 100644 --- a/docs/testing/stdio.md +++ b/docs/testing/stdio.md @@ -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 diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 2b289e54b..ac0aa3623 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -694,7 +694,7 @@ View Sentry logs Start a local Sentry MCP server -- `sentry mcp` — Start a local Sentry MCP server +- `sentry mcp []` — Start a local Sentry MCP server → Full flags and examples: `references/mcp.md` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/mcp.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/mcp.md index a32fe9c19..8e8c06927 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/mcp.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/mcp.md @@ -11,10 +11,16 @@ requires: Start a local Sentry MCP server -### `sentry mcp` +### `sentry mcp []` Start a local Sentry MCP server +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `[]` | <org>/ (an organization) or <org>/<project> to limit calls to | + **Flags:** - `--access-token - Deprecated: set SENTRY_AUTH_TOKEN instead` - `--org - Limit every call to this organization` @@ -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 diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index d964f5192..0baa93c26 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -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 `/` 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 @@ -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) { diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index 5431c1e64..4ca2105de 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -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 @@ -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", @@ -107,6 +121,18 @@ export const mcpCommand = buildCommand({ ], }, parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org/project", + brief: + "/ (an organization) or / 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"), @@ -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); }, }); diff --git a/packages/cli/src/lib/mcp.ts b/packages/cli/src/lib/mcp.ts index f7e74135e..d44cb8f11 100644 --- a/packages/cli/src/lib/mcp.ts +++ b/packages/cli/src/lib/mcp.ts @@ -13,6 +13,7 @@ import { AuthError, HostScopeError } from "./errors.js"; import { deprecatedFlags, type McpFlags, + type McpScope, mergeMcpArgs, parseMcpEnv, } from "./mcp/args.js"; @@ -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 `[/[]]` + * positional, if one was passed */ -export async function serveMcp(flags: McpFlags, cwd: string): Promise { +export async function serveMcp( + flags: McpFlags, + cwd: string, + scope?: McpScope, +): Promise { 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. diff --git a/packages/cli/src/lib/mcp/args.ts b/packages/cli/src/lib/mcp/args.ts index 155cd5e7f..c1c681851 100644 --- a/packages/cli/src/lib/mcp/args.ts +++ b/packages/cli/src/lib/mcp/args.ts @@ -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 = { @@ -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 / for an organization, or / for a project."; + +/** + * Parse the optional `[/[]]` 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 `` 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 '/${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. @@ -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]; @@ -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; } /** @@ -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, @@ -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), }; } diff --git a/packages/cli/test/lib/mcp-command.test.ts b/packages/cli/test/lib/mcp-command.test.ts index 0c42a8a5e..481807cc7 100644 --- a/packages/cli/test/lib/mcp-command.test.ts +++ b/packages/cli/test/lib/mcp-command.test.ts @@ -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", () => { @@ -117,9 +129,39 @@ describe("@sentry/mcp-server application", () => { "all-skills": true, }); }); + + test("starts the server for an / 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 / 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); diff --git a/packages/cli/test/lib/mcp-start.test.ts b/packages/cli/test/lib/mcp-start.test.ts index 0f44bb0c1..3b2ac668f 100644 --- a/packages/cli/test/lib/mcp-start.test.ts +++ b/packages/cli/test/lib/mcp-start.test.ts @@ -13,6 +13,7 @@ import { captureEnvTokenHost } from "../../src/lib/env-token-host.js"; import { HostScopeError, MalformedAuthTokenError, + ValidationError, } from "../../src/lib/errors.js"; import type { McpServerConfig } from "../../src/lib/mcp/config.js"; import { @@ -26,7 +27,7 @@ import { useEnvSandbox, useTestConfigDir, } from "../helpers.js"; -import { parseMcpFlags } from "./mcp/parse-flags.js"; +import { parseMcpArgs } from "./mcp/parse-flags.js"; type Served = { config: McpServerConfig; options: McpServeOptions }; @@ -92,7 +93,8 @@ afterEach(async () => { /** Run `sentry mcp ` up to the (mocked) stdio server. */ async function serve(args: string[]): Promise { const { serveMcp } = await import("../../src/lib/mcp.js"); - await serveMcp(await parseMcpFlags(args), workingDir); + const { flags, scope } = await parseMcpArgs(args); + await serveMcp(flags, workingDir, scope); } async function start(args: string[]): Promise { @@ -153,6 +155,29 @@ describe("serveMcp credential wiring", () => { }); }); +describe("serveMcp scope", () => { + test("serves the / target as the server's scope", async () => { + setAuthToken("cli-session-token", undefined, undefined, { + host: "https://sentry.io", + }); + + const { config } = await start(["acme/web"]); + expect(config.organizationSlug).toBe("acme"); + expect(config.projectSlug).toBe("web"); + }); + + test("refuses a target that conflicts with --org before serving", async () => { + setAuthToken("cli-session-token", undefined, undefined, { + host: "https://sentry.io", + }); + + await expect(serve(["acme/", "--org=other"])).rejects.toBeInstanceOf( + ValidationError, + ); + expect(served.calls).toHaveLength(0); + }); +}); + describe("serveMcp account pinning", () => { const originalFetch = globalThis.fetch; const refreshedWith: string[] = []; diff --git a/packages/cli/test/lib/mcp/args.test.ts b/packages/cli/test/lib/mcp/args.test.ts index 9b4886e7a..bc789a08e 100644 --- a/packages/cli/test/lib/mcp/args.test.ts +++ b/packages/cli/test/lib/mcp/args.test.ts @@ -4,9 +4,10 @@ import { deprecatedFlags, mergeMcpArgs, parseMcpEnv, + parseMcpTarget, splitInlineFlagValues, } from "../../../src/lib/mcp/args.js"; -import { parseMcpFlags } from "./parse-flags.js"; +import { parseMcpArgs, parseMcpFlags } from "./parse-flags.js"; describe("sentry mcp flags", () => { it("parses known flags", async () => { @@ -61,9 +62,9 @@ describe("sentry mcp flags", () => { expect(flags["access-token"]).toBe("a\nb"); }); - it("rejects unknown flags and positionals", async () => { + it("rejects unknown flags and a second positional", async () => { await expect(parseMcpFlags(["--unknown"])).rejects.toThrow(/unknown/); - await expect(parseMcpFlags(["extra"])).rejects.toThrow(/extra/); + await expect(parseMcpFlags(["acme/", "extra"])).rejects.toThrow(/extra/); }); }); @@ -311,6 +312,97 @@ describe("sentry mcp scope flags", () => { }); }); +describe("sentry mcp / target", () => { + it("scopes nothing without a target", async () => { + const { flags, scope } = await parseMcpArgs([]); + expect(scope).toBeUndefined(); + expect(mergeMcpArgs(flags, {}, scope)).toMatchObject({ + organizationSlug: undefined, + projectSlug: undefined, + }); + }); + + it("reads / as an organization", async () => { + const { flags, scope } = await parseMcpArgs(["acme/"]); + expect(scope).toEqual({ org: "acme" }); + expect(mergeMcpArgs(flags, {}, scope)).toMatchObject({ + organizationSlug: "acme", + projectSlug: undefined, + }); + }); + + it("reads / as an organization and project", async () => { + const { flags, scope } = await parseMcpArgs(["acme/web", "--all-skills"]); + expect(scope).toEqual({ org: "acme", project: "web" }); + expect(mergeMcpArgs(flags, {}, scope)).toMatchObject({ + organizationSlug: "acme", + projectSlug: "web", + allSkills: true, + }); + }); + + it("refuses a bare slug, pointing at /", () => { + expect(() => parseMcpTarget("acme")).toThrow(ValidationError); + expect(() => parseMcpTarget("acme")).toThrow("pass 'acme/'"); + expect(() => parseMcpTarget("acme")).toThrow("names no command"); + expect(() => parseMcpTarget("/web")).toThrow(/no organization/); + }); + + it("refuses a URL, pointing at SENTRY_URL", () => { + for (const url of [ + "https://sentry.io/organizations/acme/projects/web/", + "https://acme.sentry.io/issues/", + "http://sentry.example.com/acme/", + ]) { + expect(() => parseMcpTarget(url)).toThrow(/SENTRY_URL/); + } + }); + + it("refuses more than one project", () => { + expect(() => parseMcpTarget("acme/web/api")).toThrow(ValidationError); + expect(() => parseMcpTarget("acme/web,api")).toThrow(ValidationError); + }); + + it("refuses an invalid slug", () => { + expect(() => parseMcpTarget("acme/web?x=1")).toThrow(ValidationError); + }); + + it("rejects a bad target at parse time", async () => { + await expect(parseMcpArgs(["acme"])).rejects.toThrow(/acme\//); + }); + + it("combines / with --project", async () => { + const { flags, scope } = await parseMcpArgs(["acme/", "--project=web"]); + expect(mergeMcpArgs(flags, {}, scope)).toMatchObject({ + organizationSlug: "acme", + projectSlug: "web", + }); + }); + + it("accepts flags that repeat the target", async () => { + const { flags, scope } = await parseMcpArgs([ + "acme/web", + "--org=acme", + "--project-slug=web", + ]); + expect(mergeMcpArgs(flags, {}, scope)).toMatchObject({ + organizationSlug: "acme", + projectSlug: "web", + }); + }); + + it("rejects flags that conflict with the target", async () => { + for (const args of [ + ["acme/", "--org=other"], + ["acme/web", "--project=api"], + ["acme/", "--organization-slug=other"], + ]) { + const { flags, scope } = await parseMcpArgs(args); + expect(() => mergeMcpArgs(flags, {}, scope)).toThrow(ValidationError); + } + }); +}); + describe("sentry mcp target flags", () => { it("keeps --host, --url, and --insecure-http as deprecated aliases", async () => { const flags = await parseMcpFlags([ diff --git a/packages/cli/test/lib/mcp/parse-flags.ts b/packages/cli/test/lib/mcp/parse-flags.ts index fd6f944ff..2d259d328 100644 --- a/packages/cli/test/lib/mcp/parse-flags.ts +++ b/packages/cli/test/lib/mcp/parse-flags.ts @@ -14,25 +14,40 @@ import { import { mcpCommand } from "../../../src/commands/mcp.js"; import { type McpFlags, + type McpScope, splitInlineFlagValues, } from "../../../src/lib/mcp/args.js"; +type McpPositional = [McpScope?]; + type McpParameters = Parameters< - typeof buildCommand + typeof buildCommand >[0]["parameters"]; +/** What `sentry mcp ` hands to the server. */ +type McpArgs = { flags: McpFlags; scope?: McpScope }; + /** The flags `sentry mcp ` hands to the server. Rejects what Stricli rejects. */ export async function parseMcpFlags( args: readonly string[], ): Promise { - let parsed: McpFlags | undefined; + return (await parseMcpArgs(args)).flags; +} + +/** + * The flags and `[/[]]` target `sentry mcp ` hands to the + * server. Rejects what Stricli rejects, including a target the command's + * parser refuses. + */ +export async function parseMcpArgs(args: readonly string[]): Promise { + let parsed: McpArgs | undefined; let stderr = ""; - const command = buildCommand({ + const command = buildCommand({ docs: { brief: "Parse sentry mcp flags" }, parameters: (mcpCommand as unknown as { parameters: McpParameters }) .parameters, - func(flags) { - parsed = flags; + func(flags, scope) { + parsed = { flags, scope }; }, }); await run( diff --git a/packages/mcp-server/test/bundle.test.mjs b/packages/mcp-server/test/bundle.test.mjs index e2bf774df..e9e00dee8 100644 --- a/packages/mcp-server/test/bundle.test.mjs +++ b/packages/mcp-server/test/bundle.test.mjs @@ -117,9 +117,14 @@ test("runs the CLI's auth commands", () => { }); test("rejects the CLI's other commands", () => { - const result = run(["issue", "list"]); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /issue/); + // An unknown first word reaches `mcp` as its / target. + const command = run(["issue"]); + assert.notEqual(command.status, 0); + assert.match(command.stderr, /'issue' names no command/); + + const withArgs = run(["issue", "list"]); + assert.notEqual(withArgs.status, 0); + assert.match(withArgs.stderr, /list/); }); /**