From eb46060bf176d9a3f3542fc2f8c870deeb746a07 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 29 May 2026 09:55:23 -0700 Subject: [PATCH 01/17] feat(issue): Link external issues Add URL-based external issue linking to update_issue using Sentry's native integration and Sentry App APIs. Resolve integration and installation identifiers internally so agents only provide an existing external issue URL. Add parser and resolver coverage for GitHub, GitLab, Jira, Bitbucket, Azure DevOps, Linear, and Shortcut links, plus mocks and generated tool definitions. Fixes #228 Co-Authored-By: GPT-5 Codex --- openspec/changes/issue-linking/.openspec.yaml | 2 + openspec/changes/issue-linking/design.md | 136 +++++ openspec/changes/issue-linking/proposal.md | 38 ++ .../issue-linking/specs/issue-linking/spec.md | 120 ++++ openspec/changes/issue-linking/tasks.md | 42 ++ openspec/config.yaml | 20 + .../mcp-core/src/api-client/client.test.ts | 190 ++++++ packages/mcp-core/src/api-client/client.ts | 121 ++++ packages/mcp-core/src/api-client/schema.ts | 81 +++ packages/mcp-core/src/api-client/types.ts | 16 + packages/mcp-core/src/schema.ts | 8 + packages/mcp-core/src/skillDefinitions.json | 2 +- packages/mcp-core/src/toolDefinitions.json | 7 +- .../src/tools/catalog/issue-linking.test.ts | 270 +++++++++ .../src/tools/catalog/issue-linking.ts | 571 ++++++++++++++++++ .../src/tools/catalog/update-issue.test.ts | 370 +++++++++++- .../src/tools/catalog/update-issue.ts | 173 +++++- packages/mcp-server-mocks/src/index.ts | 123 ++++ 18 files changed, 2259 insertions(+), 31 deletions(-) create mode 100644 openspec/changes/issue-linking/.openspec.yaml create mode 100644 openspec/changes/issue-linking/design.md create mode 100644 openspec/changes/issue-linking/proposal.md create mode 100644 openspec/changes/issue-linking/specs/issue-linking/spec.md create mode 100644 openspec/changes/issue-linking/tasks.md create mode 100644 openspec/config.yaml create mode 100644 packages/mcp-core/src/tools/catalog/issue-linking.test.ts create mode 100644 packages/mcp-core/src/tools/catalog/issue-linking.ts diff --git a/openspec/changes/issue-linking/.openspec.yaml b/openspec/changes/issue-linking/.openspec.yaml new file mode 100644 index 00000000..1194417e --- /dev/null +++ b/openspec/changes/issue-linking/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-29 diff --git a/openspec/changes/issue-linking/design.md b/openspec/changes/issue-linking/design.md new file mode 100644 index 00000000..894c7302 --- /dev/null +++ b/openspec/changes/issue-linking/design.md @@ -0,0 +1,136 @@ +## Context + +`update_issue` currently updates status, ignore state, assignment, and optional reason comments. `get_issue_details` already reads platform external links through `GET /organizations/{org}/issues/{issue}/external-issues/`, but there is no write path. + +Sentry has two separate external issue systems: + +- Native integrations store `ExternalIssue` plus `GroupLink` and are driven by `GroupIntegrationDetailsEndpoint`. The UI links existing issues with `PUT /organizations/{org}/issues/{issue}/integrations/{integration_id}/`. The required internal fields are provider-specific, but the real providers Sentry supports can be derived from canonical issue URLs in the common case. +- Sentry Apps store `PlatformExternalIssue` and are driven by `SentryAppInstallationExternalIssuesEndpoint`. The endpoint is `POST /sentry-app-installations/{uuid}/external-issues/` with `issueId`, `webUrl`, `project`, and `identifier`. The UUID is an installation UUID, not a user-facing provider id. + +The implementation must not use the Sentry App endpoint as a universal Jira/GitHub/Linear solution. It only covers installed Sentry Apps. Native Jira/GitHub-style integrations need the native integration endpoint so provider hooks, comments, sync behavior, and activity are preserved. + +## Goals / Non-Goals + +**Goals:** + +- Add external issue linking to `update_issue` without increasing MCP tool count. +- Support link-only and combined update-plus-link calls. +- Prefer native integration linking when the target provider is a native issue-tracking integration. +- Support Sentry App/platform links when the target is an installed Sentry App, including Linear and Shortcut-style URLs. +- Keep the tool API minimal: link by URL only. +- Keep provider discovery, URL parsing, and payload construction reusable for future external issue creation. +- Validate all link preconditions before mutating the Sentry issue. +- Keep user-facing errors actionable and avoid exposing native integration ids, installation UUIDs, or provider form fields unless needed for diagnostics. + +**Non-Goals:** + +- Creating new external issues or tickets. +- Unlinking external issues. +- Replacing provider-specific Sentry integration configuration. +- Supporting arbitrary custom Sentry App link schemas. +- Exposing provider-specific form fields as a public MCP API. +- Changing upstream Sentry APIs. + +## Decisions + +### Use A Minimal Link API + +Add only one external linking parameter to `update_issue`: + +- `externalIssueUrl`: full URL of the external issue to link. + +Rationale: the expected user workflow is "link this Sentry issue to this external issue URL." Native providers need internal fields, but those fields are implementation details that can be parsed from canonical URLs and validated against Sentry's link config. + +Alternative considered: exposing `externalIssueIntegrationId`, `externalIssueIdentifier`, `externalIssueProject`, `externalIssueFields`, and `externalIssueKind`. This is more flexible, but it leaks Sentry internals into the MCP API and makes common linking harder for agents. The implementation can still use these concepts internally. + +### Separate Link Semantics From Future Create Semantics + +Name helper types and API client methods around external issue operations rather than around one provider form. Suggested internal boundaries: + +- `parseExternalIssueUrl(url)` returns provider, host/account context, and issue identity for linking. +- `resolveExternalIssueTarget(...)` resolves native integration id or Sentry App installation UUID. +- `buildExternalIssueLinkPayload(...)` creates the internal native or platform link payload. + +Do not add creation parameters in this change. Future creation should be a separate action with its own explicit inputs, likely `externalIssueProvider`, optional `externalIssueProject`, optional `externalIssueTitle`, and optional `externalIssueDescription`, because creation does not have an existing URL to parse. + +Rationale: linking and creating share provider discovery, but they do not share the same user input model. Linking starts from a canonical external URL. Creating starts from desired ticket metadata and provider defaults. + +Alternative considered: add an `externalIssueAction` parameter now with `link` as the only supported value. That is unnecessary API surface until creation exists. + +### Resolve Native Integrations Through Sentry's Group Integration List + +For native linking, call `GET /organizations/{org}/issues/{issue}/integrations/` to list issue-capable integrations and existing links. Resolve to one integration by: + +1. URL parser result for known native providers. +2. Provider host metadata where available, such as GitHub Enterprise, GitLab self-managed, Jira Server, or Azure DevOps instance URLs. +3. Sentry link config validation. For source-control integrations, fetch candidate link configs and choose the integration whose repository choices contain the parsed repository. + +If resolution yields zero or multiple integrations, throw `UserInputError` listing candidate provider/name values and ask the user to use a URL that maps to one installed integration or adjust duplicate integration access in Sentry. Do not mutate. + +Rationale: users should not need to know Sentry integration ids. Canonical URLs include enough provider-specific context for the supported native providers: + +- Jira/Jira Server: `/browse/PROJ-123` -> `externalIssue=PROJ-123`. +- GitHub/GitHub Enterprise: `/{owner}/{repo}/issues/{number}` -> `repo=owner/repo`, `externalIssue=number`. +- GitLab: `/{group}/{project}/-/issues/{iid}` -> `externalIssue=group/project#iid`. +- Bitbucket: `/{workspace}/{repo}/issues/{id}` -> `repo=workspace/repo`, `externalIssue=id`. +- Azure DevOps/VSTS: `/_workitems/edit/{id}` -> `externalIssue=id`. + +Alternative considered: search organization integrations globally. The group integration endpoint is better because it already filters to issue-capable integrations and includes existing issue links for that group. + +### Build Native Link Payload Internally + +After resolving a native integration, fetch link config with `GET /organizations/{org}/issues/{issue}/integrations/{integration_id}/?action=link`. + +Build the `PUT` body from the provider parser and config defaults: + +1. Start with default values from returned config fields so Sentry's existing backlink/comment defaults are preserved. +2. Override the link target fields parsed from the URL. +3. Validate all config fields marked `required` have non-empty values. +4. For repo/project select fields, verify the parsed repo/project appears in config choices when choices are available. + +If required internal fields remain missing, throw `UserInputError` explaining that the URL shape is unsupported for that provider. Do not ask users to provide raw form fields in the first version. + +Rationale: this mirrors Sentry's UI behavior while keeping provider-specific forms out of the public MCP API. + +Alternative considered: expose an `externalIssueFields` escape hatch. That would support more edge cases, but it is not minimal and effectively asks the LLM/user to understand Sentry's private integration form contract. + +### Resolve Sentry App Installations Without Exposing UUIDs + +When the URL matches a known Sentry App provider such as `linear` or `shortcut`, call `GET /organizations/{org}/sentry-app-installations/`. Match installations by exact URL-derived app slug. The installation UUID remains internal. + +Build the Sentry App payload internally from URL parsers: + +- Linear: `linear.app/.../issue/ENG-123/...` -> `project=ENG`, `identifier=ENG-123`. +- Shortcut: `app.shortcut.com/.../story/123/...` -> `project=shortcut`, `identifier=123`. + +Rationale: the installation UUID is an implementation detail, and the endpoint only creates platform external links. + +Alternative considered: require `externalIssueProject` and `externalIssueIdentifier`. That mirrors the upstream endpoint but makes the MCP API worse; those fields can be inferred well enough for platform links because Sentry does not validate them against the remote provider. + +### Mutation Order And Partial Failure + +Order handler execution as: + +1. Parse issue parameters and fetch current issue. +2. Validate status/assignment/ignore/link inputs. +3. Resolve external link target and build the internal link payload if linking is requested. +4. If no Sentry issue update is needed and only a reason comment is requested, preserve current no-change behavior. +5. Apply Sentry issue status/assignment/ignore update if needed. +6. Apply external link. +7. Post reason comment if requested. +8. Return a combined result. + +Resolution and payload validation happen before any mutation. If the issue update succeeds but the external link write fails, return a partial-success message that names the completed Sentry update and the failed link operation. + +Rationale: ambiguous or invalid linking must not accidentally change issue state. Once a combined request starts mutating, partial failure needs explicit reporting. + +Alternative considered: link first, then update. Existing `update_issue` semantics center on issue updates, and linking may depend on a valid fetched issue id; update first also keeps existing status output anchored on the updated issue. + +## Risks / Trade-offs + +- URL inference can be wrong for self-hosted or customized integrations -> only infer for recognized URL shapes and exact single matches; otherwise return an unsupported or ambiguous URL error. +- Dynamic provider config can require fields not derivable from a URL -> fail with an actionable unsupported-shape error rather than exposing raw form fields. +- Sentry App and native integration links may both match a provider token -> prefer native integrations for native provider URLs; use Sentry App matching for app-only providers like Linear and Shortcut. +- Combined update-plus-link can partially succeed -> validate before mutation and report link write failures as partial success. +- Adding parameters increases `update_issue` token footprint -> keep descriptions concise and regenerate definitions, then measure token cost. +- The upstream group integration endpoint is deprecated in Sentry source but still powers the UI path -> use it because it is the current behavior source; revisit if Sentry introduces a replacement public endpoint. diff --git a/openspec/changes/issue-linking/proposal.md b/openspec/changes/issue-linking/proposal.md new file mode 100644 index 00000000..24ef19f8 --- /dev/null +++ b/openspec/changes/issue-linking/proposal.md @@ -0,0 +1,38 @@ +## Why + +Users can inspect linked external issues through MCP today, but they cannot link a Sentry issue to Jira, GitHub, Linear, or other issue trackers without leaving the agent workflow. This is now a visible source of friction in issue #228, and Sentry already exposes the needed UI-backed APIs with provider-specific constraints. + +## What Changes + +- Extend `update_issue` so it can link a Sentry issue to an existing external issue by URL, in addition to status, ignore, assignment, and reason-comment updates. +- Support native Sentry issue-tracking integrations such as Jira, GitHub, GitLab, Bitbucket, and Azure DevOps through Sentry's group integration endpoint. +- Support Sentry App/platform external links, including Linear and Shortcut-style app links, through the Sentry App external issue endpoint. +- Keep the user-facing API minimal for linking: require only `externalIssueUrl`. +- Resolve the target integration from URL shape, installed native integrations, Sentry App installations, and Sentry's own link configuration. Do not expose native integration ids, Sentry App installation UUIDs, provider hints, or provider form fields as normal user-facing parameters. +- Structure the implementation around an internal external-issue action resolver so future ticket creation can reuse provider discovery without expanding the link API. +- Fail before mutating the Sentry issue when the requested external link target is ambiguous, unavailable, or missing required fields. +- Report partial success clearly when a Sentry status/assignment update succeeds but the subsequent external link operation fails. +- Keep this in `update_issue`; do not add a new MCP tool. + +## Capabilities + +### New Capabilities + +- `issue-linking`: Tool behavior for linking Sentry issues to existing external issue trackers through native integrations and Sentry Apps. + +### Modified Capabilities + +- None. + +## Impact + +- `packages/mcp-core/src/tools/update-issue.ts`: new minimal link input parameters, provider URL parsing, validation, execution ordering, and response formatting. +- `packages/mcp-core/src/api-client/client.ts`, `schema.ts`, and `types.ts`: client methods and schemas for integration issue config/linking and Sentry App installation/linking APIs, named so create-ticket APIs can be added alongside them later. +- `packages/mcp-core/src/tools/update-issue.test.ts` and API client tests: coverage for link-only, combined update-and-link, ambiguity, validation, and partial-failure behavior. +- `packages/mcp-server-mocks/src/index.ts`: mock responses for native integration linking and Sentry App external issue creation. +- Generated tool definitions after schema/description changes. +- Upstream Sentry APIs used by this change: + - `GET /api/0/organizations/{org}/issues/{issue}/integrations/{integration_id}/?action=link` + - `PUT /api/0/organizations/{org}/issues/{issue}/integrations/{integration_id}/` + - `GET /api/0/organizations/{org}/sentry-app-installations/` + - `POST /api/0/sentry-app-installations/{uuid}/external-issues/` diff --git a/openspec/changes/issue-linking/specs/issue-linking/spec.md b/openspec/changes/issue-linking/specs/issue-linking/spec.md new file mode 100644 index 00000000..9f849009 --- /dev/null +++ b/openspec/changes/issue-linking/specs/issue-linking/spec.md @@ -0,0 +1,120 @@ +## ADDED Requirements + +### Requirement: Link Existing External Issues +The `update_issue` tool SHALL allow linking an existing external issue to a Sentry issue when `externalIssueUrl` is provided. + +#### Scenario: Link-only request succeeds +- **WHEN** `update_issue` is called with a valid Sentry issue and `externalIssueUrl`, without `status` or `assignedTo` +- **THEN** the tool links the external issue and returns a response that includes the linked issue identifier and URL + +#### Scenario: Combined update and link succeeds +- **WHEN** `update_issue` is called with both issue update parameters and `externalIssueUrl` +- **THEN** the tool updates the Sentry issue, links the external issue, and reports both changes in the response + +#### Scenario: Creation parameters are not accepted for linking +- **WHEN** `update_issue` is called with link parameters +- **THEN** the tool treats `externalIssueUrl` as an existing issue link target and does not create a new external ticket + +### Requirement: Native Integration Linking +The `update_issue` tool SHALL link native Sentry issue-tracking integrations through Sentry's group integration link endpoint. + +#### Scenario: Native integration resolved by URL +- **WHEN** `externalIssueUrl` has a recognized native provider URL shape and maps unambiguously to one issue-capable native integration +- **THEN** the tool uses that integration for the link request + +#### Scenario: Native integration resolution is ambiguous +- **WHEN** provider or URL inference matches multiple native integrations +- **THEN** the tool raises a user input error listing the candidate integration names and providers + +### Requirement: Native Provider URL Parsing +The `update_issue` tool SHALL parse canonical external issue URLs for supported native providers into Sentry's internal native integration link payload. + +#### Scenario: Jira URL is parsed +- **WHEN** `externalIssueUrl` is a Jira or Jira Server issue URL containing `/browse/PROJ-123` +- **THEN** the tool sends `externalIssue=PROJ-123` to the native integration link endpoint + +#### Scenario: GitHub URL is parsed +- **WHEN** `externalIssueUrl` is a GitHub or GitHub Enterprise issue URL containing `owner/repo/issues/123` +- **THEN** the tool sends `repo=owner/repo` and `externalIssue=123` to the native integration link endpoint + +#### Scenario: GitLab URL is parsed +- **WHEN** `externalIssueUrl` is a GitLab issue URL containing `group/project/-/issues/123` +- **THEN** the tool sends the GitLab project and issue identifier in the format expected by Sentry's GitLab integration + +#### Scenario: Bitbucket URL is parsed +- **WHEN** `externalIssueUrl` is a Bitbucket issue URL containing `workspace/repo/issues/123` +- **THEN** the tool sends `repo=workspace/repo` and `externalIssue=123` to the native integration link endpoint + +#### Scenario: Azure DevOps URL is parsed +- **WHEN** `externalIssueUrl` is an Azure DevOps or VSTS work item URL containing `/_workitems/edit/123` +- **THEN** the tool sends `externalIssue=123` to the native integration link endpoint + +### Requirement: Native Link Config Validation +The `update_issue` tool SHALL use Sentry's native integration link configuration to validate internally constructed link payloads. + +#### Scenario: Parsed fields satisfy link config +- **WHEN** Sentry's link config required fields are satisfied by the parsed URL and config defaults +- **THEN** the tool sends the constructed payload to the native integration link endpoint + +#### Scenario: Required fields are missing +- **WHEN** Sentry's link config requires fields that cannot be derived from the URL or config defaults +- **THEN** the tool raises a user input error explaining that the URL shape is unsupported for that provider + +### Requirement: Sentry App External Issue Linking +The `update_issue` tool SHALL support platform external issue links through installed Sentry Apps without exposing installation UUIDs as user-facing inputs. + +#### Scenario: Sentry App link succeeds +- **WHEN** `externalIssueUrl` targets an installed Sentry App provider and the URL contains an inferable identifier +- **THEN** the tool resolves the Sentry App installation internally and creates the external issue link + +#### Scenario: Linear URL is parsed +- **WHEN** `externalIssueUrl` is a Linear issue URL containing `/issue/ENG-123` +- **THEN** the tool creates a Sentry App platform external issue with `webUrl` set to the URL, `identifier=ENG-123`, and `project=ENG` + +#### Scenario: Shortcut URL is parsed +- **WHEN** `externalIssueUrl` is a Shortcut story URL containing `/story/123` +- **THEN** the tool creates a Sentry App platform external issue with `webUrl` set to the URL, an identifier derived from the story id, and a stable project value + +#### Scenario: Sentry App resolution is ambiguous +- **WHEN** the request matches multiple Sentry App installations +- **THEN** the tool raises a user input error listing candidate app names and slugs without listing installation UUIDs + +### Requirement: Link Validation Before Mutation +The `update_issue` tool SHALL validate external link target resolution and required link payload fields before applying Sentry issue status, ignore, or assignment changes. + +#### Scenario: Link validation fails before issue update +- **WHEN** a combined update-and-link request has an invalid, unsupported, or ambiguous `externalIssueUrl` +- **THEN** the tool raises a user input error and does not call the Sentry issue update endpoint + +#### Scenario: No action provided +- **WHEN** `update_issue` is called without status, assignment, ignore, reason-only no-op, or `externalIssueUrl` +- **THEN** the tool raises a user input error explaining the accepted actions + +### Requirement: Partial Success Reporting +The `update_issue` tool SHALL clearly report partial success when an issue update succeeds but external issue linking fails afterward. + +#### Scenario: Link write fails after issue update +- **WHEN** a combined update-and-link request successfully updates the Sentry issue but the external link write fails +- **THEN** the tool response states that the issue update succeeded and the external link failed, including the link failure message + +### Requirement: Existing Link Visibility +The `update_issue` tool SHALL report linked external issue changes consistently with existing issue detail external link formatting. + +#### Scenario: Link result has display fields +- **WHEN** the external link API returns display name, provider/service type, and URL +- **THEN** the response includes those fields in the changes made section + +#### Scenario: Link result lacks display fields +- **WHEN** the external link API omits optional display fields +- **THEN** the response falls back to the requested external issue identifier and URL + +### Requirement: Future External Issue Creation Compatibility +The issue-linking implementation SHALL keep provider resolution and payload construction separate from the public link input schema so future external issue creation can reuse provider discovery without changing link behavior. + +#### Scenario: Link API remains URL-based +- **WHEN** future ticket creation support is added +- **THEN** existing link calls using `externalIssueUrl` continue to link existing external issues without requiring creation-specific parameters + +#### Scenario: Provider resolution is reusable +- **WHEN** future ticket creation support needs to choose a native integration or Sentry App installation +- **THEN** it can reuse the provider/app discovery helpers introduced for issue linking without depending on link-only URL payload construction diff --git a/openspec/changes/issue-linking/tasks.md b/openspec/changes/issue-linking/tasks.md new file mode 100644 index 00000000..e56d0dd8 --- /dev/null +++ b/openspec/changes/issue-linking/tasks.md @@ -0,0 +1,42 @@ +## 1. API Client Surface + +- [x] 1.1 Add schemas and types for native issue integrations, link config fields, native link responses, and Sentry App installations. +- [x] 1.2 Add `listIssueIntegrations`, `getIssueIntegrationLinkConfig`, and `linkNativeExternalIssue` API client methods for the group integration endpoints. +- [x] 1.3 Add `listSentryAppInstallations` and `createSentryAppExternalIssueLink` API client methods for platform external issue links. +- [x] 1.4 Add API client tests for request paths, methods, request bodies, and response parsing. + +## 2. Link Resolution Helpers + +- [x] 2.1 Implement canonical URL parsers for Jira/Jira Server, GitHub/GitHub Enterprise, GitLab, Bitbucket, Azure DevOps/VSTS, Linear, and Shortcut. +- [x] 2.2 Implement native integration resolution from parsed URL, host metadata, and Sentry link config validation. +- [x] 2.3 Implement Sentry App installation resolution from parsed URL without exposing UUIDs in errors. +- [x] 2.4 Implement native link payload construction from URL parser output and Sentry link config defaults. +- [x] 2.5 Implement Sentry App payload construction from URL parser output. +- [x] 2.6 Keep provider/app resolution helpers independent from link payload construction so future ticket creation can reuse them. +- [x] 2.7 Add focused helper tests for provider parsing, native resolution, Sentry App resolution, ambiguity, unsupported URL shapes, and helper separation. + +## 3. `update_issue` Tool + +- [x] 3.1 Add `externalIssueUrl` to the input schema and tool description. +- [x] 3.2 Extend validation so link-only requests are valid and unsupported or ambiguous external link URLs produce `UserInputError`. +- [x] 3.3 Resolve and validate external link targets before any status, ignore, or assignment mutation. +- [x] 3.4 Execute native integration and Sentry App link writes after any required Sentry issue update. +- [x] 3.5 Format successful link changes in the `## Changes Made` section and include current linked issue details when available. +- [x] 3.6 Return explicit partial-success output when the Sentry issue update succeeds but the external link write fails. +- [x] 3.7 Document in tool description that `externalIssueUrl` links an existing external issue and does not create a new ticket. + +## 4. Mocks And Tests + +- [x] 4.1 Add MSW mocks for issue integration listing, native integration link config, native integration link PUT, Sentry App installation listing, and Sentry App external issue POST. +- [x] 4.2 Add `update_issue` tests for native link-only success, combined update plus native link, URL parsing for each supported native provider, unsupported URL shapes, and ambiguous native integrations. +- [x] 4.3 Add `update_issue` tests for Linear and Shortcut Sentry App link success, ambiguous Sentry App installations, and no UUID leakage. +- [x] 4.4 Add regression tests proving failed link resolution does not call the issue update endpoint. +- [x] 4.5 Add regression tests proving link write failure after issue update is reported as partial success. + +## 5. Generated Artifacts And Verification + +- [x] 5.1 Run `pnpm run --filter @sentry/mcp-core generate-definitions` after tool schema and description changes. +- [x] 5.2 Run `pnpm run measure-tokens` and verify the `update_issue` description remains acceptable for tool-token budget. +- [x] 5.3 Run `pnpm run tsc`. +- [x] 5.4 Run `pnpm run lint`. +- [x] 5.5 Run `pnpm run test`. diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 00000000..392946c6 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,20 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours diff --git a/packages/mcp-core/src/api-client/client.test.ts b/packages/mcp-core/src/api-client/client.test.ts index c2ad4c6a..1616ae7f 100644 --- a/packages/mcp-core/src/api-client/client.test.ts +++ b/packages/mcp-core/src/api-client/client.test.ts @@ -139,6 +139,196 @@ describe("getTraceUrl", () => { }); }); +describe("external issue linking API methods", () => { + function mockJsonResponse(body: unknown) { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: { + get: (key: string) => + key.toLowerCase() === "content-type" ? "application/json" : null, + }, + json: () => Promise.resolve(body), + }); + } + + it("lists issue integrations", async () => { + mockJsonResponse([ + { + id: "123", + name: "GitHub", + domainName: "github.com/getsentry", + provider: { key: "github", slug: "github", name: "GitHub" }, + externalIssues: [], + }, + ]); + const apiService = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + + const result = await apiService.listIssueIntegrations({ + organizationSlug: "test-org", + issueId: "PROJ-1", + }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://sentry.io/api/0/organizations/test-org/issues/PROJ-1/integrations/", + expect.any(Object), + ); + expect(result).toMatchInlineSnapshot(` + [ + { + "domainName": "github.com/getsentry", + "externalIssues": [], + "id": "123", + "name": "GitHub", + "provider": { + "key": "github", + "name": "GitHub", + "slug": "github", + }, + }, + ] + `); + }); + + it("gets issue integration link config", async () => { + mockJsonResponse({ + id: "123", + name: "GitHub", + domainName: "github.com/getsentry", + provider: { key: "github", slug: "github" }, + linkIssueConfig: [ + { + name: "repo", + label: "Repository", + type: "select", + required: true, + default: "getsentry/sentry", + choices: [["getsentry/sentry", "getsentry/sentry"]], + }, + { name: "externalIssue", required: true }, + ], + }); + const apiService = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + + const result = await apiService.getIssueIntegrationLinkConfig({ + organizationSlug: "test-org", + issueId: "PROJ-1", + integrationId: "123", + }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://sentry.io/api/0/organizations/test-org/issues/PROJ-1/integrations/123/?action=link", + expect.any(Object), + ); + expect(result.linkIssueConfig).toHaveLength(2); + }); + + it("links a native external issue", async () => { + mockJsonResponse({ + id: "456", + key: "getsentry/sentry#123", + url: "https://github.com/getsentry/sentry/issues/123", + integrationId: "123", + displayName: "getsentry/sentry#123", + }); + const apiService = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + + const result = await apiService.linkNativeExternalIssue({ + organizationSlug: "test-org", + issueId: "PROJ-1", + integrationId: "123", + data: { + repo: "getsentry/sentry", + externalIssue: "123", + comment: "Sentry Issue: PROJ-1", + }, + }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://sentry.io/api/0/organizations/test-org/issues/PROJ-1/integrations/123/", + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ + repo: "getsentry/sentry", + externalIssue: "123", + comment: "Sentry Issue: PROJ-1", + }), + }), + ); + expect(result.key).toBe("getsentry/sentry#123"); + }); + + it("lists Sentry App installations", async () => { + mockJsonResponse([ + { + uuid: "install-uuid", + status: "installed", + app: { slug: "linear", uuid: "app-uuid", sentryAppId: 1 }, + }, + ]); + const apiService = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + + const result = await apiService.listSentryAppInstallations({ + organizationSlug: "test-org", + }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://sentry.io/api/0/organizations/test-org/sentry-app-installations/", + expect.any(Object), + ); + expect(result[0]?.app.slug).toBe("linear"); + }); + + it("creates a Sentry App external issue link", async () => { + mockJsonResponse({ + id: "789", + issueId: "123", + serviceType: "linear", + displayName: "ENG-123", + webUrl: "https://linear.app/acme/issue/ENG-123/test", + }); + const apiService = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + + const result = await apiService.createSentryAppExternalIssueLink({ + installationUuid: "install-uuid", + issueId: 123, + webUrl: "https://linear.app/acme/issue/ENG-123/test", + project: "ENG", + identifier: "ENG-123", + }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://sentry.io/api/0/sentry-app-installations/install-uuid/external-issues/", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + issueId: 123, + webUrl: "https://linear.app/acme/issue/ENG-123/test", + project: "ENG", + identifier: "ENG-123", + }), + }), + ); + expect(result.serviceType).toBe("linear"); + }); +}); + describe("getEventsExplorerUrl", () => { it("should work with sentry.io", () => { const apiService = new SentryApiService({ host: "sentry.io" }); diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 9c4ecb23..e1c92714 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -50,6 +50,11 @@ import { IssueSchema, IssueTagValuesSchema, ExternalIssueListSchema, + ExternalIssueSchema, + IssueIntegrationLinkConfigSchema, + IssueIntegrationListSchema, + NativeExternalIssueSchema, + SentryAppInstallationListSchema, EventSchema, EventAttachmentListSchema, ErrorsSearchResponseSchema, @@ -99,6 +104,8 @@ import type { IssueCommentList, IssueList, IssueTagValues, + ExternalIssue, + NativeExternalIssue, ExternalIssueList, CommitList, DeployList, @@ -108,6 +115,8 @@ import type { MonitorStats, MetricAlertRule, MetricAlertRuleList, + IssueIntegrationLinkConfig, + IssueIntegrationList, OrganizationList, Project, ProjectList, @@ -126,6 +135,7 @@ import type { ReplayList, ReplayRecordingSegments, AIConversationSpanList, + SentryAppInstallationList, } from "./types"; // TODO: this is shared - so ideally, for safety, it uses @sentry/core, but currently // logger isnt exposed (or rather, it is, but its not the right logger) @@ -2864,6 +2874,117 @@ export class SentryApiService { return ExternalIssueListSchema.parse(body); } + async listIssueIntegrations( + { + organizationSlug, + issueId, + }: { + organizationSlug: string; + issueId: string; + }, + opts?: RequestOptions, + ): Promise { + const body = await this.requestJSON( + `/organizations/${organizationSlug}/issues/${issueId}/integrations/`, + undefined, + opts, + ); + return IssueIntegrationListSchema.parse(body); + } + + async getIssueIntegrationLinkConfig( + { + organizationSlug, + issueId, + integrationId, + }: { + organizationSlug: string; + issueId: string; + integrationId: string; + }, + opts?: RequestOptions, + ): Promise { + const body = await this.requestJSON( + `/organizations/${organizationSlug}/issues/${issueId}/integrations/${integrationId}/?action=link`, + undefined, + opts, + ); + return IssueIntegrationLinkConfigSchema.parse(body); + } + + async linkNativeExternalIssue( + { + organizationSlug, + issueId, + integrationId, + data, + }: { + organizationSlug: string; + issueId: string; + integrationId: string; + data: Record; + }, + opts?: RequestOptions, + ): Promise { + const body = await this.requestJSON( + `/organizations/${organizationSlug}/issues/${issueId}/integrations/${integrationId}/`, + { + method: "PUT", + body: JSON.stringify(data), + }, + opts, + ); + return NativeExternalIssueSchema.parse(body); + } + + async listSentryAppInstallations( + { + organizationSlug, + }: { + organizationSlug: string; + }, + opts?: RequestOptions, + ): Promise { + const body = await this.requestJSON( + `/organizations/${organizationSlug}/sentry-app-installations/`, + undefined, + opts, + ); + return SentryAppInstallationListSchema.parse(body); + } + + async createSentryAppExternalIssueLink( + { + installationUuid, + issueId, + webUrl, + project, + identifier, + }: { + installationUuid: string; + issueId: number; + webUrl: string; + project: string; + identifier: string; + }, + opts?: RequestOptions, + ): Promise { + const body = await this.requestJSON( + `/sentry-app-installations/${installationUuid}/external-issues/`, + { + method: "POST", + body: JSON.stringify({ + issueId, + webUrl, + project, + identifier, + }), + }, + opts, + ); + return ExternalIssueSchema.parse(body); + } + async getEventForIssue( { organizationSlug, diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index ca2392a0..9f73f57a 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -1228,6 +1228,87 @@ export const ExternalIssueSchema = z.object({ export const ExternalIssueListSchema = z.array(ExternalIssueSchema); +export const IntegrationProviderSchema = z + .object({ + key: z.string(), + slug: z.string().optional(), + name: z.string().optional(), + }) + .passthrough(); + +export const IssueIntegrationExternalIssueSchema = z + .object({ + id: z.union([z.string(), z.number()]), + key: z.string(), + url: z.string().optional(), + title: z.string().nullable().optional(), + description: z.string().nullable().optional(), + displayName: z.string().optional(), + }) + .passthrough(); + +export const IssueIntegrationSchema = z + .object({ + id: z.union([z.string(), z.number()]), + name: z.string(), + domainName: z.string().nullable().optional(), + status: z.string().optional(), + provider: IntegrationProviderSchema, + externalIssues: z.array(IssueIntegrationExternalIssueSchema).default([]), + }) + .passthrough(); + +export const IssueIntegrationListSchema = z.array(IssueIntegrationSchema); + +export const IssueIntegrationLinkConfigFieldSchema = z + .object({ + name: z.string(), + label: z.string().optional(), + type: z.string().optional(), + default: z.unknown().optional(), + required: z.boolean().optional(), + choices: z.array(z.tuple([z.string(), z.string()])).optional(), + }) + .passthrough(); + +export const IssueIntegrationLinkConfigSchema = z + .object({ + id: z.union([z.string(), z.number()]), + name: z.string(), + domainName: z.string().nullable().optional(), + provider: IntegrationProviderSchema, + linkIssueConfig: z.array(IssueIntegrationLinkConfigFieldSchema), + }) + .passthrough(); + +export const NativeExternalIssueSchema = z + .object({ + id: z.union([z.string(), z.number()]), + key: z.string(), + url: z.string().optional(), + integrationId: z.union([z.string(), z.number()]).optional(), + displayName: z.string().optional(), + }) + .passthrough(); + +export const SentryAppInstallationSchema = z + .object({ + uuid: z.string(), + status: z.string().optional(), + app: z + .object({ + uuid: z.string().optional(), + slug: z.string(), + sentryAppId: z.number().optional(), + }) + .passthrough(), + }) + .passthrough(); + +export const SentryAppInstallationListSchema = z.array( + SentryAppInstallationSchema, +); + /** * Schema for Sentry trace metadata response. * diff --git a/packages/mcp-core/src/api-client/types.ts b/packages/mcp-core/src/api-client/types.ts index 8512ccc9..7016fa0e 100644 --- a/packages/mcp-core/src/api-client/types.ts +++ b/packages/mcp-core/src/api-client/types.ts @@ -61,6 +61,10 @@ import type { EventSchema, ExternalIssueListSchema, ExternalIssueSchema, + IssueIntegrationLinkConfigSchema, + IssueIntegrationListSchema, + IssueIntegrationSchema, + NativeExternalIssueSchema, FlamegraphFrameInfoSchema, FlamegraphFrameSchema, FlamegraphProfileMetadataSchema, @@ -98,6 +102,8 @@ import type { ReplayDetailsSchema, ReplayListResponseSchema, ReplayRecordingSegmentsSchema, + SentryAppInstallationListSchema, + SentryAppInstallationSchema, TagListSchema, TagSchema, TeamListSchema, @@ -216,3 +222,13 @@ export type IssueTagValues = z.infer; // External issue links (Jira, GitHub, etc.) export type ExternalIssue = z.infer; export type ExternalIssueList = z.infer; +export type IssueIntegration = z.infer; +export type IssueIntegrationList = z.infer; +export type IssueIntegrationLinkConfig = z.infer< + typeof IssueIntegrationLinkConfigSchema +>; +export type NativeExternalIssue = z.infer; +export type SentryAppInstallation = z.infer; +export type SentryAppInstallationList = z.infer< + typeof SentryAppInstallationListSchema +>; diff --git a/packages/mcp-core/src/schema.ts b/packages/mcp-core/src/schema.ts index c144e7b8..342e82e1 100644 --- a/packages/mcp-core/src/schema.ts +++ b/packages/mcp-core/src/schema.ts @@ -66,6 +66,14 @@ export const ParamIssueUrl = z "The URL of the issue. e.g. https://my-organization.sentry.io/issues/PROJECT-1Z43", ); +export const ParamExternalIssueUrl = z + .string() + .url() + .trim() + .describe( + "The URL of an existing external issue to link to this Sentry issue. This links an existing ticket; it does not create a new ticket. Supported URL shapes include Jira, GitHub, GitLab, Bitbucket, Azure DevOps, Linear, and Shortcut issue URLs.", + ); + export const ParamReplayId = z .string() .trim() diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index f64da3ad..8044094c 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -315,7 +315,7 @@ }, { "name": "update_issue", - "description": "Update a Sentry issue's status or assignment.\n\nUse this to resolve, reopen, assign, or ignore an issue.\n\n\n```\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='resolved')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', assignedTo='user:123456')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='forever')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='untilOccurrenceCount', ignoreCount=100, ignoreWindowMinutes=60)\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', reason='Ignoring because this is expected noise from the staging deploy')\n```\n\n\n\n- Provide `issueUrl` or `organizationSlug` + `issueId`.\n- At least one of `status` or `assignedTo` is required.\n- `assignedTo` format: `user:ID` or `team:ID_OR_SLUG`.\n- Use `execute_sentry_tool(name='whoami', arguments={})` to find your user ID for self-assignment.\n- Status values: `resolved`, `resolvedInNextRelease`, `unresolved`, `ignored`.\n- `status='ignored'` defaults to `ignoreMode='untilEscalating'`.\n- Ignore modes: `untilEscalating`, `forever`, `forDuration`, `untilOccurrenceCount`, `untilUserCount`.\n- Matching ignore inputs are `ignoreDurationMinutes`, `ignoreCount` + optional `ignoreWindowMinutes`, or `ignoreUserCount` + optional `ignoreUserWindowMinutes`.\n- To switch an already ignored issue between `untilEscalating`, `forever`, and condition-based ignore modes, first set `status='unresolved'`, then ignore it again with the new rule.\n- `reason` is optional. When provided, it will be posted as a comment on the issue's activity feed explaining why the action was taken.\n", + "description": "Update a Sentry issue.\n\nUse this to resolve, reopen, assign, ignore, comment on, or link an existing external issue.\n\n\n```\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='resolved')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', assignedTo='user:123456')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='untilOccurrenceCount', ignoreCount=100, ignoreWindowMinutes=60)\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', externalIssueUrl='https://github.com/getsentry/sentry/issues/123')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', externalIssueUrl='https://linear.app/acme/issue/ENG-123/test')\n```\n\n\n\n- Provide `issueUrl` or `organizationSlug` + `issueId`.\n- At least one of `status`, `assignedTo`, `externalIssueUrl`, or `reason` is required.\n- `assignedTo`: `user:ID`, `team:ID_OR_SLUG`, or `me`.\n- `externalIssueUrl` links an existing external issue; it does not create a new ticket.\n- Use `execute_sentry_tool(name='whoami', arguments={})` to find your user ID for self-assignment.\n- Status values: `resolved`, `resolvedInNextRelease`, `unresolved`, `ignored`.\n- Ignore modes: `untilEscalating` (default), `forever`, `forDuration`, `untilOccurrenceCount`, `untilUserCount`.\n- Ignore inputs: `ignoreDurationMinutes`, `ignoreCount` + optional `ignoreWindowMinutes`, or `ignoreUserCount` + optional `ignoreUserWindowMinutes`.\n- To switch ignore families on an already ignored issue, first set `status='unresolved'`, then ignore it again.\n", "requiredScopes": ["event:write"] }, { diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 77d4f63c..dc7eb86a 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -2130,7 +2130,7 @@ }, { "name": "update_issue", - "description": "Update a Sentry issue's status or assignment.\n\nUse this to resolve, reopen, assign, or ignore an issue.\n\n\n```\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='resolved')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', assignedTo='user:123456')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='forever')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='untilOccurrenceCount', ignoreCount=100, ignoreWindowMinutes=60)\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', reason='Ignoring because this is expected noise from the staging deploy')\n```\n\n\n\n- Provide `issueUrl` or `organizationSlug` + `issueId`.\n- At least one of `status` or `assignedTo` is required.\n- `assignedTo` format: `user:ID` or `team:ID_OR_SLUG`.\n- Use `execute_sentry_tool(name='whoami', arguments={})` to find your user ID for self-assignment.\n- Status values: `resolved`, `resolvedInNextRelease`, `unresolved`, `ignored`.\n- `status='ignored'` defaults to `ignoreMode='untilEscalating'`.\n- Ignore modes: `untilEscalating`, `forever`, `forDuration`, `untilOccurrenceCount`, `untilUserCount`.\n- Matching ignore inputs are `ignoreDurationMinutes`, `ignoreCount` + optional `ignoreWindowMinutes`, or `ignoreUserCount` + optional `ignoreUserWindowMinutes`.\n- To switch an already ignored issue between `untilEscalating`, `forever`, and condition-based ignore modes, first set `status='unresolved'`, then ignore it again with the new rule.\n- `reason` is optional. When provided, it will be posted as a comment on the issue's activity feed explaining why the action was taken.\n", + "description": "Update a Sentry issue.\n\nUse this to resolve, reopen, assign, ignore, comment on, or link an existing external issue.\n\n\n```\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='resolved')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', assignedTo='user:123456')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='untilOccurrenceCount', ignoreCount=100, ignoreWindowMinutes=60)\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', externalIssueUrl='https://github.com/getsentry/sentry/issues/123')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', externalIssueUrl='https://linear.app/acme/issue/ENG-123/test')\n```\n\n\n\n- Provide `issueUrl` or `organizationSlug` + `issueId`.\n- At least one of `status`, `assignedTo`, `externalIssueUrl`, or `reason` is required.\n- `assignedTo`: `user:ID`, `team:ID_OR_SLUG`, or `me`.\n- `externalIssueUrl` links an existing external issue; it does not create a new ticket.\n- Use `execute_sentry_tool(name='whoami', arguments={})` to find your user ID for self-assignment.\n- Status values: `resolved`, `resolvedInNextRelease`, `unresolved`, `ignored`.\n- Ignore modes: `untilEscalating` (default), `forever`, `forDuration`, `untilOccurrenceCount`, `untilUserCount`.\n- Ignore inputs: `ignoreDurationMinutes`, `ignoreCount` + optional `ignoreWindowMinutes`, or `ignoreUserCount` + optional `ignoreUserWindowMinutes`.\n- To switch ignore families on an already ignored issue, first set `status='unresolved'`, then ignore it again.\n", "inputSchema": { "type": "object", "properties": { @@ -2174,6 +2174,11 @@ "type": "string", "description": "The assignee in format 'user:ID' or 'team:ID_OR_SLUG' where ID is numeric. Example: 'user:123456', 'team:789', or 'team:my-team-slug'. Use `execute_sentry_tool(name='whoami', arguments={})` to find your user ID." }, + "externalIssueUrl": { + "type": "string", + "format": "uri", + "description": "The URL of an existing external issue to link to this Sentry issue. This links an existing ticket; it does not create a new ticket. Supported URL shapes include Jira, GitHub, GitLab, Bitbucket, Azure DevOps, Linear, and Shortcut issue URLs." + }, "ignoreMode": { "type": "string", "enum": [ diff --git a/packages/mcp-core/src/tools/catalog/issue-linking.test.ts b/packages/mcp-core/src/tools/catalog/issue-linking.test.ts new file mode 100644 index 00000000..3c8fb985 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/issue-linking.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from "vitest"; +import { UserInputError } from "../../errors"; +import { + parseExternalIssueUrl, + resolveExternalIssueLinkTarget, +} from "./issue-linking"; +import type { + IssueIntegration, + IssueIntegrationLinkConfig, + SentryAppInstallation, +} from "../../api-client/types"; + +function integration( + overrides: Partial = {}, +): IssueIntegration { + return { + id: "1", + name: "GitHub", + domainName: "github.com/getsentry", + provider: { key: "github", slug: "github", name: "GitHub" }, + externalIssues: [], + ...overrides, + }; +} + +function linkConfig( + overrides: Partial = {}, +): IssueIntegrationLinkConfig { + return { + id: "1", + name: "GitHub", + domainName: "github.com/getsentry", + provider: { key: "github", slug: "github", name: "GitHub" }, + linkIssueConfig: [ + { + name: "repo", + required: true, + choices: [["getsentry/sentry", "getsentry/sentry"]], + }, + { name: "externalIssue", required: true }, + { name: "comment", required: false, default: "Sentry Issue" }, + ], + ...overrides, + }; +} + +function installation( + overrides: Partial = {}, +): SentryAppInstallation { + return { + uuid: "linear-installation", + status: "installed", + app: { slug: "linear", uuid: "linear-app", sentryAppId: 1 }, + ...overrides, + }; +} + +describe("parseExternalIssueUrl", () => { + it("parses supported native provider URLs", () => { + expect( + parseExternalIssueUrl("https://acme.atlassian.net/browse/ENG-123"), + ).toMatchObject({ + kind: "native", + provider: "jira", + issueId: "ENG-123", + }); + expect( + parseExternalIssueUrl("https://github.com/getsentry/sentry/issues/123"), + ).toMatchObject({ + kind: "native", + provider: "github", + repo: "getsentry/sentry", + issueId: "123", + }); + expect( + parseExternalIssueUrl( + "https://gitlab.com/getsentry/backend/service/-/issues/456", + ), + ).toMatchObject({ + kind: "native", + provider: "gitlab", + project: "getsentry/backend/service", + issueId: "456", + }); + expect( + parseExternalIssueUrl( + "https://bitbucket.org/getsentry/sentry/issues/789/test", + ), + ).toMatchObject({ + kind: "native", + provider: "bitbucket", + repo: "getsentry/sentry", + issueId: "789", + }); + expect( + parseExternalIssueUrl( + "https://dev.azure.com/acme/project/_workitems/edit/42", + ), + ).toMatchObject({ + kind: "native", + provider: "vsts", + issueId: "42", + }); + }); + + it("parses supported Sentry App provider URLs", () => { + expect( + parseExternalIssueUrl("https://linear.app/acme/issue/ENG-123/test"), + ).toMatchObject({ + kind: "sentryApp", + appSlug: "linear", + project: "ENG", + identifier: "ENG-123", + }); + expect( + parseExternalIssueUrl("https://app.shortcut.com/acme/story/123/test"), + ).toMatchObject({ + kind: "sentryApp", + appSlug: "shortcut", + project: "shortcut", + identifier: "123", + }); + }); + + it("rejects unsupported URLs", () => { + expect(() => + parseExternalIssueUrl("https://tickets.example.com/work/ABC-1"), + ).toThrow(UserInputError); + }); +}); + +describe("resolveExternalIssueLinkTarget", () => { + it("resolves native integrations and builds payloads independently", async () => { + const target = await resolveExternalIssueLinkTarget({ + apiService: { + listIssueIntegrations: async () => [ + integration({ id: "1", domainName: "github.com/getsentry" }), + ], + getIssueIntegrationLinkConfig: async () => linkConfig({ id: "1" }), + listSentryAppInstallations: async () => [], + }, + organizationSlug: "sentry", + issueId: "PROJ-1", + externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", + }); + + expect(target).toMatchObject({ + kind: "native", + integration: { id: "1" }, + payload: { + repo: "getsentry/sentry", + externalIssue: "123", + comment: "Sentry Issue", + }, + }); + }); + + it("uses longest domain match for nested GitLab groups", async () => { + const target = await resolveExternalIssueLinkTarget({ + apiService: { + listIssueIntegrations: async () => [ + integration({ + id: "1", + name: "GitLab Root", + domainName: "gitlab.com/getsentry", + provider: { key: "gitlab" }, + }), + integration({ + id: "2", + name: "GitLab Backend", + domainName: "gitlab.com/getsentry/backend", + provider: { key: "gitlab" }, + }), + ], + getIssueIntegrationLinkConfig: async ({ integrationId }) => + linkConfig({ + id: integrationId, + provider: { key: "gitlab" }, + linkIssueConfig: [ + { + name: "project", + required: true, + choices: [ + ["getsentry/backend/service", "getsentry/backend/service"], + ], + }, + { name: "externalIssue", required: true }, + ], + }), + listSentryAppInstallations: async () => [], + }, + organizationSlug: "sentry", + issueId: "PROJ-1", + externalIssueUrl: + "https://gitlab.com/getsentry/backend/service/-/issues/123", + }); + + expect(target).toMatchObject({ + kind: "native", + integration: { id: "2" }, + payload: { + project: "getsentry/backend/service", + externalIssue: "getsentry/backend/service#123", + }, + }); + }); + + it("reports ambiguous native integrations", async () => { + await expect( + resolveExternalIssueLinkTarget({ + apiService: { + listIssueIntegrations: async () => [ + integration({ id: "1", name: "GitHub A", domainName: null }), + integration({ id: "2", name: "GitHub B", domainName: null }), + ], + getIssueIntegrationLinkConfig: async () => linkConfig(), + listSentryAppInstallations: async () => [], + }, + organizationSlug: "sentry", + issueId: "PROJ-1", + externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", + }), + ).rejects.toThrow(/Multiple installed integrations/); + }); + + it("resolves Sentry App installations without exposing UUIDs", async () => { + const target = await resolveExternalIssueLinkTarget({ + apiService: { + listIssueIntegrations: async () => [], + getIssueIntegrationLinkConfig: async () => { + throw new Error("not used"); + }, + listSentryAppInstallations: async () => [installation()], + }, + organizationSlug: "sentry", + issueId: "PROJ-1", + externalIssueUrl: "https://linear.app/acme/issue/ENG-123/test", + }); + + expect(target).toMatchObject({ + kind: "sentryApp", + installation: { uuid: "linear-installation" }, + payload: { + webUrl: "https://linear.app/acme/issue/ENG-123/test", + project: "ENG", + identifier: "ENG-123", + }, + }); + }); + + it("does not leak Sentry App installation UUIDs in ambiguity errors", async () => { + await expect( + resolveExternalIssueLinkTarget({ + apiService: { + listIssueIntegrations: async () => [], + getIssueIntegrationLinkConfig: async () => { + throw new Error("not used"); + }, + listSentryAppInstallations: async () => [ + installation({ uuid: "secret-1" }), + installation({ uuid: "secret-2" }), + ], + }, + organizationSlug: "sentry", + issueId: "PROJ-1", + externalIssueUrl: "https://linear.app/acme/issue/ENG-123/test", + }), + ).rejects.toThrow("Multiple installed Sentry Apps"); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/issue-linking.ts b/packages/mcp-core/src/tools/catalog/issue-linking.ts new file mode 100644 index 00000000..44e8e103 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/issue-linking.ts @@ -0,0 +1,571 @@ +import { UserInputError } from "../../errors"; +import type { + ExternalIssue, + IssueIntegration, + IssueIntegrationLinkConfig, + NativeExternalIssue, + SentryAppInstallation, +} from "../../api-client/types"; + +type NativeProvider = "jira" | "github" | "gitlab" | "bitbucket" | "vsts"; +type AppProvider = "linear" | "shortcut"; + +type ParsedNativeIssueUrl = { + kind: "native"; + provider: NativeProvider; + url: string; + host: string; + domainPath?: string; + issueId: string; + repo?: string; + project?: string; +}; + +type ParsedAppIssueUrl = { + kind: "sentryApp"; + provider: AppProvider; + url: string; + host: string; + appSlug: string; + project: string; + identifier: string; +}; + +export type ParsedExternalIssueUrl = ParsedNativeIssueUrl | ParsedAppIssueUrl; + +type LinkConfigField = IssueIntegrationLinkConfig["linkIssueConfig"][number]; + +export type NativeExternalIssueLinkTarget = { + kind: "native"; + integration: IssueIntegration; + config: IssueIntegrationLinkConfig; + payload: Record; + parsed: ParsedNativeIssueUrl; +}; + +export type SentryAppExternalIssueLinkTarget = { + kind: "sentryApp"; + installation: SentryAppInstallation; + payload: { + webUrl: string; + project: string; + identifier: string; + }; + parsed: ParsedAppIssueUrl; +}; + +export type ExternalIssueLinkTarget = + | NativeExternalIssueLinkTarget + | SentryAppExternalIssueLinkTarget; + +export type LinkedExternalIssue = + | { kind: "native"; issue: NativeExternalIssue; provider: string } + | { kind: "sentryApp"; issue: ExternalIssue; provider: string }; + +export type ExternalIssueLinkApi = { + listIssueIntegrations(params: { + organizationSlug: string; + issueId: string; + }): Promise; + getIssueIntegrationLinkConfig(params: { + organizationSlug: string; + issueId: string; + integrationId: string; + }): Promise; + listSentryAppInstallations(params: { + organizationSlug: string; + }): Promise; +}; + +function trimSlashes(value: string): string { + return value.replace(/^\/+|\/+$/g, ""); +} + +function normalizeHost(value: string): string { + return value.toLowerCase().replace(/^www\./, ""); +} + +function normalizeDomain(value?: string | null): string | null { + if (!value) { + return null; + } + const withoutProtocol = value.replace(/^https?:\/\//i, ""); + return trimSlashes(withoutProtocol).toLowerCase(); +} + +function pathSegments(url: URL): string[] { + return url.pathname.split("/").filter(Boolean).map(decodeURIComponent); +} + +function parseIssueNumber(value: string): string | null { + return /^\d+$/.test(value) ? value : null; +} + +function parseJiraUrl(url: URL): ParsedNativeIssueUrl | null { + const segments = pathSegments(url); + const browseIndex = segments.findIndex((segment) => segment === "browse"); + const issueId = browseIndex >= 0 ? segments[browseIndex + 1] : undefined; + if (!issueId || !/^[A-Z][A-Z0-9]+-\d+$/i.test(issueId)) { + return null; + } + return { + kind: "native", + provider: "jira", + url: url.toString(), + host: normalizeHost(url.hostname), + domainPath: normalizeHost(url.hostname), + issueId, + }; +} + +function parseGithubUrl(url: URL): ParsedNativeIssueUrl | null { + const host = normalizeHost(url.hostname); + if (host === "bitbucket.org" || host === "gitlab.com") { + return null; + } + const segments = pathSegments(url); + if (segments.length < 4 || segments[2] !== "issues") { + return null; + } + const issueId = parseIssueNumber(segments[3] ?? ""); + if (!issueId) { + return null; + } + const repo = `${segments[0]}/${segments[1]}`; + return { + kind: "native", + provider: "github", + url: url.toString(), + host, + domainPath: `${host}/${segments[0]}`, + repo, + issueId, + }; +} + +function parseGitlabUrl(url: URL): ParsedNativeIssueUrl | null { + const segments = pathSegments(url); + const markerIndex = segments.findIndex((segment) => segment === "-"); + if ( + markerIndex < 1 || + segments[markerIndex + 1] !== "issues" || + !segments[markerIndex + 2] + ) { + return null; + } + const issueId = parseIssueNumber(segments[markerIndex + 2]); + if (!issueId) { + return null; + } + const project = segments.slice(0, markerIndex).join("/"); + return { + kind: "native", + provider: "gitlab", + url: url.toString(), + host: normalizeHost(url.hostname), + domainPath: `${normalizeHost(url.hostname)}/${project}`, + project, + issueId, + }; +} + +function parseBitbucketUrl(url: URL): ParsedNativeIssueUrl | null { + const segments = pathSegments(url); + if (segments.length < 4 || segments[2] !== "issues") { + return null; + } + const issueId = parseIssueNumber(segments[3] ?? ""); + if (!issueId) { + return null; + } + const repo = `${segments[0]}/${segments[1]}`; + return { + kind: "native", + provider: "bitbucket", + url: url.toString(), + host: normalizeHost(url.hostname), + domainPath: `${normalizeHost(url.hostname)}/${segments[0]}`, + repo, + issueId, + }; +} + +function parseVstsUrl(url: URL): ParsedNativeIssueUrl | null { + const segments = pathSegments(url); + const editIndex = segments.findIndex((segment) => segment === "edit"); + if ( + editIndex < 1 || + segments[editIndex - 1] !== "_workitems" || + !segments[editIndex + 1] + ) { + return null; + } + const issueId = parseIssueNumber(segments[editIndex + 1]); + if (!issueId) { + return null; + } + return { + kind: "native", + provider: "vsts", + url: url.toString(), + host: normalizeHost(url.hostname), + domainPath: normalizeHost(url.hostname), + issueId, + }; +} + +function parseLinearUrl(url: URL): ParsedAppIssueUrl | null { + if (normalizeHost(url.hostname) !== "linear.app") { + return null; + } + const segments = pathSegments(url); + const issueIndex = segments.findIndex((segment) => segment === "issue"); + const identifier = issueIndex >= 0 ? segments[issueIndex + 1] : undefined; + if (!identifier) { + return null; + } + const project = identifier.split("-")[0] || "linear"; + return { + kind: "sentryApp", + provider: "linear", + appSlug: "linear", + url: url.toString(), + host: normalizeHost(url.hostname), + project, + identifier, + }; +} + +function parseShortcutUrl(url: URL): ParsedAppIssueUrl | null { + const host = normalizeHost(url.hostname); + if (host !== "app.shortcut.com" && host !== "shortcut.com") { + return null; + } + const segments = pathSegments(url); + const storyIndex = segments.findIndex((segment) => segment === "story"); + const identifier = storyIndex >= 0 ? segments[storyIndex + 1] : undefined; + if (!identifier) { + return null; + } + return { + kind: "sentryApp", + provider: "shortcut", + appSlug: "shortcut", + url: url.toString(), + host, + project: "shortcut", + identifier, + }; +} + +export function parseExternalIssueUrl( + externalIssueUrl: string, +): ParsedExternalIssueUrl { + let url: URL; + try { + url = new URL(externalIssueUrl); + } catch { + throw new UserInputError("`externalIssueUrl` must be a valid URL."); + } + + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new UserInputError( + "`externalIssueUrl` must use the http or https protocol.", + ); + } + + const host = normalizeHost(url.hostname); + + const nativeParsers: Array<() => ParsedNativeIssueUrl | null> = [ + parseJiraUrl.bind(null, url), + parseGithubUrl.bind(null, url), + parseGitlabUrl.bind(null, url), + parseBitbucketUrl.bind(null, url), + parseVstsUrl.bind(null, url), + ]; + for (const parse of nativeParsers) { + const parsed = parse(); + if (parsed) { + return parsed; + } + } + + const appParsers: Array<() => ParsedAppIssueUrl | null> = [ + parseLinearUrl.bind(null, url), + parseShortcutUrl.bind(null, url), + ]; + for (const parse of appParsers) { + const parsed = parse(); + if (parsed) { + return parsed; + } + } + + throw new UserInputError( + `Unsupported external issue URL host \`${host}\`. Provide a supported Jira, GitHub, GitLab, Bitbucket, Azure DevOps, Linear, or Shortcut issue URL.`, + ); +} + +function providerKeys(provider: NativeProvider): string[] { + switch (provider) { + case "github": + return ["github", "github_enterprise"]; + case "jira": + return ["jira", "jira_server"]; + case "vsts": + return ["vsts"]; + default: + return [provider]; + } +} + +function integrationProviderKey(integration: IssueIntegration): string { + return integration.provider.key.toLowerCase(); +} + +function domainMatchScore( + integration: IssueIntegration, + parsed: ParsedNativeIssueUrl, +): number { + const domain = normalizeDomain(integration.domainName); + if (!domain || !parsed.domainPath) { + return 0; + } + const parsedDomain = normalizeDomain(parsed.domainPath); + if (!parsedDomain) { + return 0; + } + if (parsedDomain === domain || parsedDomain.startsWith(`${domain}/`)) { + return domain.length; + } + return 0; +} + +function hasChoice(field: LinkConfigField, value: string): boolean { + if (!field.choices || field.choices.length === 0) { + return true; + } + return field.choices.some(([choiceValue]) => choiceValue === value); +} + +function fieldByName( + config: IssueIntegrationLinkConfig, + name: string, +): LinkConfigField | undefined { + return config.linkIssueConfig.find((field) => field.name === name); +} + +function configMatchesParsedUrl( + config: IssueIntegrationLinkConfig, + parsed: ParsedNativeIssueUrl, +): boolean { + if (parsed.repo) { + const repoField = fieldByName(config, "repo"); + if (repoField && !hasChoice(repoField, parsed.repo)) { + return false; + } + } + if (parsed.project) { + const projectField = fieldByName(config, "project"); + if (projectField && !hasChoice(projectField, parsed.project)) { + return false; + } + } + return true; +} + +function describeNativeCandidates(candidates: IssueIntegration[]): string { + return candidates + .map( + (candidate) => + `${candidate.name} (${candidate.provider.key}${candidate.domainName ? `, ${candidate.domainName}` : ""})`, + ) + .join(", "); +} + +function buildNativeLinkPayload( + parsed: ParsedNativeIssueUrl, + config: IssueIntegrationLinkConfig, +): Record { + const payload: Record = {}; + for (const field of config.linkIssueConfig) { + if (field.default !== undefined && field.default !== "") { + payload[field.name] = field.default; + } + } + + switch (parsed.provider) { + case "jira": + case "vsts": + payload.externalIssue = parsed.issueId; + break; + case "github": + case "bitbucket": + payload.repo = parsed.repo; + payload.externalIssue = parsed.issueId; + break; + case "gitlab": + payload.project = parsed.project; + payload.externalIssue = `${parsed.project}#${parsed.issueId}`; + break; + } + + const missingFields = config.linkIssueConfig + .filter((field) => field.required) + .filter((field) => { + const value = payload[field.name]; + return value === undefined || value === null || value === ""; + }) + .map((field) => field.name); + + if (missingFields.length > 0) { + throw new UserInputError( + `Unsupported external issue URL for ${config.provider.name ?? config.provider.key}. Missing required link fields: ${missingFields.join(", ")}.`, + ); + } + + return payload; +} + +async function resolveNativeTarget(params: { + apiService: ExternalIssueLinkApi; + organizationSlug: string; + issueId: string; + parsed: ParsedNativeIssueUrl; +}): Promise { + const { apiService, organizationSlug, issueId, parsed } = params; + const integrations = await apiService.listIssueIntegrations({ + organizationSlug, + issueId, + }); + let candidates = integrations.filter((integration) => + providerKeys(parsed.provider).includes(integrationProviderKey(integration)), + ); + + if (candidates.length === 0) { + throw new UserInputError( + `No installed ${parsed.provider} issue integration can link ${parsed.url}.`, + ); + } + + const bestDomainScore = Math.max( + ...candidates.map((candidate) => domainMatchScore(candidate, parsed)), + ); + if (bestDomainScore > 0) { + candidates = candidates.filter( + (candidate) => domainMatchScore(candidate, parsed) === bestDomainScore, + ); + } + + const candidatesWithConfig = await Promise.all( + candidates.map(async (integration) => { + const config = await apiService.getIssueIntegrationLinkConfig({ + organizationSlug, + issueId, + integrationId: String(integration.id), + }); + return { integration, config }; + }), + ); + const matchingCandidates = candidatesWithConfig.filter(({ config }) => + configMatchesParsedUrl(config, parsed), + ); + + if (matchingCandidates.length === 0) { + throw new UserInputError( + `No installed ${parsed.provider} issue integration can access the project or repository in ${parsed.url}.`, + ); + } + if (matchingCandidates.length > 1) { + throw new UserInputError( + `Multiple installed integrations can link ${parsed.url}: ${describeNativeCandidates(matchingCandidates.map(({ integration }) => integration))}. Unlink duplicate integration access in Sentry or use a URL that maps to a single installed integration.`, + ); + } + + const [{ integration, config }] = matchingCandidates; + return { + kind: "native", + integration, + config, + parsed, + payload: buildNativeLinkPayload(parsed, config), + }; +} + +function describeSentryAppCandidates( + candidates: SentryAppInstallation[], +): string { + return candidates.map((candidate) => candidate.app.slug).join(", "); +} + +async function resolveSentryAppTarget(params: { + apiService: ExternalIssueLinkApi; + organizationSlug: string; + parsed: ParsedAppIssueUrl; +}): Promise { + const { apiService, organizationSlug, parsed } = params; + const appSlug = parsed.appSlug; + const installations = await apiService.listSentryAppInstallations({ + organizationSlug, + }); + const candidates = installations.filter( + (installation) => + installation.status?.toLowerCase() !== "pending" && + installation.app.slug.toLowerCase() === appSlug, + ); + + if (candidates.length === 0) { + throw new UserInputError( + `No installed Sentry App with slug \`${appSlug}\` can link ${parsed.url}.`, + ); + } + if (candidates.length > 1) { + throw new UserInputError( + `Multiple installed Sentry Apps match ${parsed.url}: ${describeSentryAppCandidates(candidates)}.`, + ); + } + + return { + kind: "sentryApp", + installation: candidates[0], + parsed, + payload: { + webUrl: parsed.url, + project: parsed.project, + identifier: parsed.identifier, + }, + }; +} + +export async function resolveExternalIssueLinkTarget(params: { + apiService: ExternalIssueLinkApi; + organizationSlug: string; + issueId: string; + externalIssueUrl: string; +}): Promise { + const parsed = parseExternalIssueUrl(params.externalIssueUrl); + + if (parsed.kind === "native") { + return resolveNativeTarget({ + apiService: params.apiService, + organizationSlug: params.organizationSlug, + issueId: params.issueId, + parsed, + }); + } + + return resolveSentryAppTarget({ + apiService: params.apiService, + organizationSlug: params.organizationSlug, + parsed, + }); +} + +export function formatLinkedExternalIssue(linked: LinkedExternalIssue): string { + if (linked.kind === "sentryApp") { + return `${linked.issue.displayName || linked.issue.issueId} (${linked.issue.serviceType || linked.provider}) → ${linked.issue.webUrl}`; + } + const displayName = linked.issue.displayName || linked.issue.key; + const url = linked.issue.url ? ` → ${linked.issue.url}` : ""; + return `${displayName} (${linked.provider})${url}`; +} diff --git a/packages/mcp-core/src/tools/catalog/update-issue.test.ts b/packages/mcp-core/src/tools/catalog/update-issue.test.ts index bb47b06d..48c8c267 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.test.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.test.ts @@ -755,10 +755,378 @@ describe("update_issue", () => { serverContext, ), ).rejects.toThrow( - "At least one of `status` or `assignedTo` must be provided to update the issue", + "At least one of `status`, `assignedTo`, `externalIssueUrl`, or `reason` must be provided to update the issue", ); }); + it("links a native external issue without updating status or assignment", async () => { + const result = await updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", + status: undefined, + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + }, + serverContext, + ); + + expect(result).toContain( + "**Linked External Issue**: getsentry/sentry#123 (github) → https://github.com/getsentry/sentry/issues/123", + ); + expect(result).toContain("- The external issue has been linked in Sentry."); + }); + + it("updates status and links a native external issue", async () => { + const result = await updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status: "resolved", + externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + }, + serverContext, + ); + + expect(result).toContain("**Status**: unresolved → **resolved**"); + expect(result).toContain( + "**Linked External Issue**: getsentry/sentry#123 (github) → https://github.com/getsentry/sentry/issues/123", + ); + }); + + it("links a Linear issue through Sentry Apps", async () => { + const result = await updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + externalIssueUrl: "https://linear.app/acme/issue/ENG-123/test", + status: undefined, + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + }, + serverContext, + ); + + expect(result).toContain( + "**Linked External Issue**: ENG-123 (linear) → https://linear.app/acme/issue/ENG-123/test", + ); + }); + + it.each([ + { + provider: "jira", + url: "https://acme.atlassian.net/browse/ENG-123", + integration: { + id: "jira-1", + name: "Jira", + domainName: "acme.atlassian.net", + provider: { key: "jira", slug: "jira", name: "Jira" }, + }, + config: [ + { name: "externalIssue", required: true }, + { name: "comment", required: false, default: "Sentry Issue" }, + ], + response: { + id: "external-issue-jira", + key: "ENG-123", + url: "https://acme.atlassian.net/browse/ENG-123", + integrationId: "jira-1", + displayName: "ENG-123", + }, + expected: "ENG-123 (jira)", + }, + { + provider: "gitlab", + url: "https://gitlab.com/getsentry/backend/-/issues/456", + integration: { + id: "gitlab-1", + name: "GitLab", + domainName: "gitlab.com/getsentry", + provider: { key: "gitlab", slug: "gitlab", name: "GitLab" }, + }, + config: [ + { + name: "project", + required: true, + choices: [["getsentry/backend", "getsentry/backend"]], + }, + { name: "externalIssue", required: true }, + ], + response: { + id: "external-issue-gitlab", + key: "getsentry/backend#456", + url: "https://gitlab.com/getsentry/backend/-/issues/456", + integrationId: "gitlab-1", + displayName: "getsentry/backend#456", + }, + expected: "getsentry/backend#456 (gitlab)", + }, + { + provider: "bitbucket", + url: "https://bitbucket.org/getsentry/sentry/issues/789/test", + integration: { + id: "bitbucket-1", + name: "Bitbucket", + domainName: "bitbucket.org/getsentry", + provider: { key: "bitbucket", slug: "bitbucket", name: "Bitbucket" }, + }, + config: [ + { + name: "repo", + required: true, + choices: [["getsentry/sentry", "getsentry/sentry"]], + }, + { name: "externalIssue", required: true }, + ], + response: { + id: "external-issue-bitbucket", + key: "getsentry/sentry#789", + url: "https://bitbucket.org/getsentry/sentry/issues/789/test", + integrationId: "bitbucket-1", + displayName: "getsentry/sentry#789", + }, + expected: "getsentry/sentry#789 (bitbucket)", + }, + { + provider: "vsts", + url: "https://dev.azure.com/acme/project/_workitems/edit/42", + integration: { + id: "vsts-1", + name: "Azure DevOps", + domainName: "dev.azure.com/acme", + provider: { key: "vsts", slug: "vsts", name: "Azure DevOps" }, + }, + config: [{ name: "externalIssue", required: true }], + response: { + id: "external-issue-vsts", + key: "42", + url: "https://dev.azure.com/acme/project/_workitems/edit/42", + integrationId: "vsts-1", + displayName: "42", + }, + expected: "42 (vsts)", + }, + ])( + "links $provider issue URLs through native integrations", + async (testCase) => { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/integrations/", + () => + HttpResponse.json([ + { ...testCase.integration, externalIssues: [] }, + ]), + ), + http.get( + `https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/integrations/${testCase.integration.id}/`, + ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.get("action") !== "link") { + return HttpResponse.json( + { detail: "bad action" }, + { status: 400 }, + ); + } + return HttpResponse.json({ + ...testCase.integration, + linkIssueConfig: testCase.config, + }); + }, + ), + http.put( + `https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/integrations/${testCase.integration.id}/`, + () => HttpResponse.json(testCase.response), + ), + ); + + const result = await updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + externalIssueUrl: testCase.url, + status: undefined, + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + }, + serverContext, + ); + + expect(result).toContain( + `**Linked External Issue**: ${testCase.expected}`, + ); + }, + ); + + it("links a Shortcut issue through Sentry Apps", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/sentry-app-installations/", + () => + HttpResponse.json([ + { + uuid: "shortcut-installation-uuid", + status: "installed", + app: { + uuid: "shortcut-app-uuid", + slug: "shortcut", + sentryAppId: 2, + }, + }, + ]), + ), + http.post( + "https://sentry.io/api/0/sentry-app-installations/shortcut-installation-uuid/external-issues/", + async ({ request }) => { + const body = (await request.json()) as { + issueId: number; + webUrl: string; + identifier: string; + }; + return HttpResponse.json({ + id: "platform-external-issue-shortcut", + issueId: String(body.issueId), + serviceType: "shortcut", + displayName: body.identifier, + webUrl: body.webUrl, + }); + }, + ), + ); + + const result = await updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + externalIssueUrl: "https://app.shortcut.com/acme/story/123/test", + status: undefined, + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + }, + serverContext, + ); + + expect(result).toContain( + "**Linked External Issue**: 123 (shortcut) → https://app.shortcut.com/acme/story/123/test", + ); + }); + + it("does not update the issue when external issue URL validation fails", async () => { + let updateCalled = false; + mswServer.use( + http.put( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/", + () => { + updateCalled = true; + return HttpResponse.json(issueFixture); + }, + ), + ); + + await expect( + updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status: "resolved", + externalIssueUrl: "https://tickets.example.com/work/ABC-1", + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + }, + serverContext, + ), + ).rejects.toThrow("Unsupported external issue URL host"); + expect(updateCalled).toBe(false); + }); + + it("reports ambiguous native integrations before updating", async () => { + let updateCalled = false; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/integrations/", + () => + HttpResponse.json([ + { + id: "github-1", + name: "GitHub A", + domainName: null, + provider: { key: "github", slug: "github", name: "GitHub" }, + externalIssues: [], + }, + { + id: "github-2", + name: "GitHub B", + domainName: null, + provider: { key: "github", slug: "github", name: "GitHub" }, + externalIssues: [], + }, + ]), + ), + http.put( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/", + () => { + updateCalled = true; + return HttpResponse.json(issueFixture); + }, + ), + ); + + await expect( + updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status: "resolved", + externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + }, + serverContext, + ), + ).rejects.toThrow("Multiple installed integrations"); + expect(updateCalled).toBe(false); + }); + + it("reports partial success when link write fails after issue update", async () => { + mswServer.use( + http.put( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/integrations/github-integration-1/", + () => + HttpResponse.json( + { detail: "GitHub rejected the issue link" }, + { status: 400 }, + ), + ), + ); + + const result = await updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status: "resolved", + externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + }, + serverContext, + ); + + expect(result).toContain("Partially Updated"); + expect(result).toContain("The Sentry issue update succeeded."); + expect(result).toContain("External issue linking failed"); + expect(result).toContain("GitHub rejected the issue link"); + }); + it("validates ignore options require ignored status", async () => { await expect( updateIssue.handler( diff --git a/packages/mcp-core/src/tools/catalog/update-issue.ts b/packages/mcp-core/src/tools/catalog/update-issue.ts index 2ed9d149..f4604355 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.ts @@ -10,11 +10,18 @@ import { logIssue } from "../../telem/logging"; import { UserInputError } from "../../errors"; import type { Issue } from "../../api-client/types"; import type { ServerContext } from "../../types"; +import { + formatLinkedExternalIssue, + resolveExternalIssueLinkTarget, + type ExternalIssueLinkTarget, + type LinkedExternalIssue, +} from "./issue-linking"; import { ParamOrganizationSlug, ParamRegionUrl, ParamIssueShortId, ParamIssueUrl, + ParamExternalIssueUrl, ParamIssueStatus, ParamIssueIgnoreMode, ParamAssignedTo, @@ -568,6 +575,62 @@ type ReasonCommentResult = | { posted: false; skipped: true } | { posted: false; error: string }; +async function executeExternalIssueLink( + apiService: { + linkNativeExternalIssue: (params: { + organizationSlug: string; + issueId: string; + integrationId: string; + data: Record; + }) => Promise; + createSentryAppExternalIssueLink: (params: { + installationUuid: string; + issueId: number; + webUrl: string; + project: string; + identifier: string; + }) => Promise; + }, + params: { + organizationSlug: string; + issueId: string; + currentIssue: Issue; + target: ExternalIssueLinkTarget; + }, +): Promise { + if (params.target.kind === "native") { + const issue = await apiService.linkNativeExternalIssue({ + organizationSlug: params.organizationSlug, + issueId: params.issueId, + integrationId: String(params.target.integration.id), + data: params.target.payload, + }); + return { + kind: "native", + issue, + provider: params.target.integration.provider.key, + } as LinkedExternalIssue; + } + + const numericIssueId = Number(params.currentIssue.id); + if (!Number.isFinite(numericIssueId)) { + throw new UserInputError( + "Cannot link Sentry App external issue because the Sentry issue id is not numeric.", + ); + } + + const issue = await apiService.createSentryAppExternalIssueLink({ + installationUuid: params.target.installation.uuid, + issueId: numericIssueId, + ...params.target.payload, + }); + return { + kind: "sentryApp", + issue, + provider: params.target.installation.app.slug, + } as LinkedExternalIssue; +} + async function tryPostReasonComment( apiService: { createIssueComment: (params: { @@ -620,32 +683,31 @@ export default defineTool({ skills: ["triage"], // Only available in triage skill requiredScopes: ["event:write"], description: [ - "Update a Sentry issue's status or assignment.", + "Update a Sentry issue.", "", - "Use this to resolve, reopen, assign, or ignore an issue.", + "Use this to resolve, reopen, assign, ignore, comment on, or link an existing external issue.", "", "", "```", "update_issue(organizationSlug='my-org', issueId='PROJECT-123', status='resolved')", "update_issue(organizationSlug='my-org', issueId='PROJECT-123', assignedTo='user:123456')", "update_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored')", - "update_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='forever')", "update_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='untilOccurrenceCount', ignoreCount=100, ignoreWindowMinutes=60)", - "update_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', reason='Ignoring because this is expected noise from the staging deploy')", + "update_issue(organizationSlug='my-org', issueId='PROJECT-123', externalIssueUrl='https://github.com/getsentry/sentry/issues/123')", + "update_issue(organizationSlug='my-org', issueId='PROJECT-123', externalIssueUrl='https://linear.app/acme/issue/ENG-123/test')", "```", "", "", "", "- Provide `issueUrl` or `organizationSlug` + `issueId`.", - "- At least one of `status` or `assignedTo` is required.", - "- `assignedTo` format: `user:ID` or `team:ID_OR_SLUG`.", + "- At least one of `status`, `assignedTo`, `externalIssueUrl`, or `reason` is required.", + "- `assignedTo`: `user:ID`, `team:ID_OR_SLUG`, or `me`.", + "- `externalIssueUrl` links an existing external issue; it does not create a new ticket.", "- Use `execute_sentry_tool(name='whoami', arguments={})` to find your user ID for self-assignment.", "- Status values: `resolved`, `resolvedInNextRelease`, `unresolved`, `ignored`.", - "- `status='ignored'` defaults to `ignoreMode='untilEscalating'`.", - "- Ignore modes: `untilEscalating`, `forever`, `forDuration`, `untilOccurrenceCount`, `untilUserCount`.", - "- Matching ignore inputs are `ignoreDurationMinutes`, `ignoreCount` + optional `ignoreWindowMinutes`, or `ignoreUserCount` + optional `ignoreUserWindowMinutes`.", - "- To switch an already ignored issue between `untilEscalating`, `forever`, and condition-based ignore modes, first set `status='unresolved'`, then ignore it again with the new rule.", - "- `reason` is optional. When provided, it will be posted as a comment on the issue's activity feed explaining why the action was taken.", + "- Ignore modes: `untilEscalating` (default), `forever`, `forDuration`, `untilOccurrenceCount`, `untilUserCount`.", + "- Ignore inputs: `ignoreDurationMinutes`, `ignoreCount` + optional `ignoreWindowMinutes`, or `ignoreUserCount` + optional `ignoreUserWindowMinutes`.", + "- To switch ignore families on an already ignored issue, first set `status='unresolved'`, then ignore it again.", "", ].join("\n"), inputSchema: { @@ -655,6 +717,7 @@ export default defineTool({ issueUrl: ParamIssueUrl.optional(), status: ParamIssueStatus.optional(), assignedTo: ParamAssignedTo.optional(), + externalIssueUrl: ParamExternalIssueUrl.optional(), ignoreMode: ParamIssueIgnoreMode.optional(), ignoreDurationMinutes: ParamIgnoreDurationMinutes.optional(), ignoreCount: ParamIgnoreCount.optional(), @@ -688,9 +751,14 @@ export default defineTool({ } // Validate that at least one update parameter is provided - if (!params.status && !params.assignedTo) { + if ( + !params.status && + !params.assignedTo && + !params.externalIssueUrl && + !params.reason + ) { throw new UserInputError( - "At least one of `status` or `assignedTo` must be provided to update the issue", + "At least one of `status`, `assignedTo`, `externalIssueUrl`, or `reason` must be provided to update the issue", ); } @@ -713,6 +781,15 @@ export default defineTool({ projectSlug: context.constraints.projectSlug, }); + const linkTarget = params.externalIssueUrl + ? await resolveExternalIssueLinkTarget({ + apiService, + organizationSlug: orgSlug, + issueId: parsedIssueId!, + externalIssueUrl: params.externalIssueUrl, + }) + : null; + const currentIgnoreState = getIgnoreState(currentIssue); const assignmentAlreadySet = isAssigneeAlreadySet( currentIssue, @@ -762,7 +839,7 @@ export default defineTool({ currentIssue.shortId, ); - if (!updateStatus && !updateAssignedTo && !updateIgnore) { + if (!updateStatus && !updateAssignedTo && !updateIgnore && !linkTarget) { const commentResult = await tryPostReasonComment( apiService, orgSlug, @@ -780,19 +857,50 @@ export default defineTool({ ); } - // Update the issue - const updatedIssue = await apiService.updateIssue({ - organizationSlug: orgSlug, - issueId: parsedIssueId!, - status: updateStatus, - assignedTo: updateAssignedTo, - substatus: updateIgnore?.substatus, - ignoreDuration: updateIgnore?.ignoreDuration, - ignoreCount: updateIgnore?.ignoreCount, - ignoreWindow: updateIgnore?.ignoreWindow, - ignoreUserCount: updateIgnore?.ignoreUserCount, - ignoreUserWindow: updateIgnore?.ignoreUserWindow, - }); + const hasIssueUpdate = Boolean( + updateStatus || updateAssignedTo || updateIgnore, + ); + let updatedIssue = currentIssue; + if (hasIssueUpdate) { + updatedIssue = await apiService.updateIssue({ + organizationSlug: orgSlug, + issueId: parsedIssueId!, + status: updateStatus, + assignedTo: updateAssignedTo, + substatus: updateIgnore?.substatus, + ignoreDuration: updateIgnore?.ignoreDuration, + ignoreCount: updateIgnore?.ignoreCount, + ignoreWindow: updateIgnore?.ignoreWindow, + ignoreUserCount: updateIgnore?.ignoreUserCount, + ignoreUserWindow: updateIgnore?.ignoreUserWindow, + }); + } + + let linkedExternalIssue: LinkedExternalIssue | null = null; + if (linkTarget) { + try { + linkedExternalIssue = await executeExternalIssueLink(apiService, { + organizationSlug: orgSlug, + issueId: parsedIssueId!, + currentIssue, + target: linkTarget, + }); + } catch (error) { + if (!hasIssueUpdate) { + throw error; + } + logIssue(error); + let output = `# Issue ${updatedIssue.shortId} Partially Updated in **${orgSlug}**\n\n`; + output += `**Issue**: ${updatedIssue.title}\n`; + output += `**URL**: ${apiService.getIssueUrl(orgSlug, updatedIssue.shortId)}\n\n`; + output += "## Changes Made\n\n"; + output += "- The Sentry issue update succeeded.\n"; + output += `- External issue linking failed: ${error instanceof Error ? error.message : String(error)}\n`; + output += "\n## Response Notes\n\n"; + output += `- Full issue details: \`get_sentry_resource(resourceType="issue", organizationSlug="${orgSlug}", resourceId="${updatedIssue.shortId}")\`\n`; + return output; + } + } const commentResult = await tryPostReasonComment( apiService, @@ -843,6 +951,10 @@ export default defineTool({ output += `**Assigned To**: ${oldAssignee} → **${newAssignee}**\n`; } + if (linkedExternalIssue) { + output += `**Linked External Issue**: ${formatLinkedExternalIssue(linkedExternalIssue)}\n`; + } + output += "\n## Current Status\n\n"; output += `**Status**: ${updatedStatusDisplay}\n`; if (updatedIgnoreState) { @@ -852,7 +964,12 @@ export default defineTool({ output += `**Assigned To**: ${currentAssignee}\n`; output += "\n## Response Notes\n\n"; - output += `- The issue has been updated in Sentry.\n`; + if (hasIssueUpdate) { + output += `- The issue has been updated in Sentry.\n`; + } + if (linkedExternalIssue) { + output += `- The external issue has been linked in Sentry.\n`; + } output += `- Full issue details: \`get_sentry_resource(resourceType="issue", organizationSlug="${orgSlug}", resourceId="${updatedIssue.shortId}")\`\n`; if (statusChanged && updatedStatusDisplay === "resolved") { diff --git a/packages/mcp-server-mocks/src/index.ts b/packages/mcp-server-mocks/src/index.ts index 113c23f1..e841f4d2 100644 --- a/packages/mcp-server-mocks/src/index.ts +++ b/packages/mcp-server-mocks/src/index.ts @@ -1160,6 +1160,129 @@ export const restHandlers = buildHandlers([ path: "/api/0/organizations/:org/issues/:issueId/external-issues/", fetch: () => HttpResponse.json([]), }, + { + method: "get", + path: "/api/0/organizations/:org/issues/:issueId/integrations/", + fetch: () => + HttpResponse.json([ + { + id: "github-integration-1", + name: "GitHub", + domainName: "github.com/getsentry", + status: "active", + provider: { + key: "github", + slug: "github", + name: "GitHub", + }, + externalIssues: [], + }, + ]), + }, + { + method: "get", + path: "/api/0/organizations/:org/issues/:issueId/integrations/:integrationId/", + fetch: ({ params, request }) => { + const url = new URL(request.url); + if (url.searchParams.get("action") !== "link") { + return HttpResponse.json( + { detail: "Action is required and should be either link or create" }, + { status: 400 }, + ); + } + const integrationId = String(params.integrationId); + return HttpResponse.json({ + id: integrationId, + name: "GitHub", + domainName: "github.com/getsentry", + provider: { + key: "github", + slug: "github", + name: "GitHub", + }, + linkIssueConfig: [ + { + name: "repo", + label: "GitHub Repository", + type: "select", + default: "getsentry/sentry", + required: true, + choices: [["getsentry/sentry", "getsentry/sentry"]], + }, + { + name: "externalIssue", + label: "Issue Number or Title", + type: "select", + required: true, + }, + { + name: "comment", + label: "Comment", + type: "textarea", + required: false, + default: "Sentry Issue: [CLOUDFLARE-MCP-41](https://sentry.io)", + }, + ], + }); + }, + }, + { + method: "put", + path: "/api/0/organizations/:org/issues/:issueId/integrations/:integrationId/", + fetch: async ({ request }) => { + const body = (await request.json()) as { + repo?: string; + externalIssue?: string; + }; + const key = `${body.repo ?? "getsentry/sentry"}#${body.externalIssue ?? "123"}`; + return HttpResponse.json({ + id: "external-issue-1", + key, + url: `https://github.com/${body.repo ?? "getsentry/sentry"}/issues/${body.externalIssue ?? "123"}`, + integrationId: "github-integration-1", + displayName: key, + }); + }, + }, + { + method: "get", + path: "/api/0/organizations/:org/sentry-app-installations/", + fetch: () => + HttpResponse.json([ + { + uuid: "linear-installation-uuid", + status: "installed", + app: { + uuid: "linear-app-uuid", + slug: "linear", + sentryAppId: 1, + }, + organization: { + slug: "sentry-mcp-evals", + id: 1, + }, + }, + ]), + }, + { + method: "post", + path: "/api/0/sentry-app-installations/:uuid/external-issues/", + fetch: async ({ request }) => { + const body = (await request.json()) as { + issueId: number; + webUrl: string; + project: string; + identifier: string; + }; + return HttpResponse.json({ + id: "platform-external-issue-1", + issueId: String(body.issueId), + serviceType: "linear", + displayName: body.identifier, + webUrl: body.webUrl, + }); + }, + }, // Issue tag values endpoints { method: "get", From 24a48f8b566c5f65e8c76b3f70aa1f6518aa4363 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 17:05:13 +0000 Subject: [PATCH 02/17] fix(update-issue): post reason comment in partial-success link failure path When a combined update+link request succeeds on the issue update but fails on the external link write, the catch block now calls tryPostReasonComment before returning so the reason comment is not silently dropped. Co-Authored-By: Junior (claude-sonnet-4-5) --- [View Session in Sentry](https://sentry.sentry.io/traces/?project=4510944073809921&query=gen_ai.conversation.id%3A%22slack%3AC08J1NSPU6S%3A1780067705.499339%22) --- .../src/tools/catalog/update-issue.test.ts | 33 +++++++++++++++++++ .../src/tools/catalog/update-issue.ts | 7 ++++ 2 files changed, 40 insertions(+) diff --git a/packages/mcp-core/src/tools/catalog/update-issue.test.ts b/packages/mcp-core/src/tools/catalog/update-issue.test.ts index 48c8c267..121e7936 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.test.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.test.ts @@ -1127,6 +1127,39 @@ describe("update_issue", () => { expect(result).toContain("GitHub rejected the issue link"); }); + it("posts reason comment in partial success path when link write fails", async () => { + mswServer.use( + http.put( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/integrations/github-integration-1/", + () => + HttpResponse.json( + { detail: "GitHub rejected the issue link" }, + { status: 400 }, + ), + ), + ); + + const result = await updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status: "resolved", + externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", + reason: "Fixing in linked ticket", + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + }, + serverContext, + ); + + expect(result).toContain("Partially Updated"); + expect(result).toContain("The Sentry issue update succeeded."); + expect(result).toContain("External issue linking failed"); + expect(result).toContain("Comment posted"); + expect(result).toContain("Fixing in linked ticket"); + }); + it("validates ignore options require ignored status", async () => { await expect( updateIssue.handler( diff --git a/packages/mcp-core/src/tools/catalog/update-issue.ts b/packages/mcp-core/src/tools/catalog/update-issue.ts index f4604355..51afc348 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.ts @@ -890,12 +890,19 @@ export default defineTool({ throw error; } logIssue(error); + const partialCommentResult = await tryPostReasonComment( + apiService, + orgSlug, + parsedIssueId!, + params.reason, + ); let output = `# Issue ${updatedIssue.shortId} Partially Updated in **${orgSlug}**\n\n`; output += `**Issue**: ${updatedIssue.title}\n`; output += `**URL**: ${apiService.getIssueUrl(orgSlug, updatedIssue.shortId)}\n\n`; output += "## Changes Made\n\n"; output += "- The Sentry issue update succeeded.\n"; output += `- External issue linking failed: ${error instanceof Error ? error.message : String(error)}\n`; + output += formatReasonCommentLine(params.reason, partialCommentResult); output += "\n## Response Notes\n\n"; output += `- Full issue details: \`get_sentry_resource(resourceType="issue", organizationSlug="${orgSlug}", resourceId="${updatedIssue.shortId}")\`\n`; return output; From c4f83f5565efe235e897c7d8a27fc485be5e3afc Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 29 May 2026 10:14:11 -0700 Subject: [PATCH 03/17] fix(issue): Match Azure DevOps organization domains Include the Azure DevOps organization segment when resolving dev.azure.com issue URLs so multiple VSTS integrations can be disambiguated by Sentry domainName metadata. Co-Authored-By: GPT-5 Codex --- .../src/tools/catalog/issue-linking.test.ts | 39 +++++++++++++++++++ .../src/tools/catalog/issue-linking.ts | 7 +++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/mcp-core/src/tools/catalog/issue-linking.test.ts b/packages/mcp-core/src/tools/catalog/issue-linking.test.ts index 3c8fb985..7f71c79b 100644 --- a/packages/mcp-core/src/tools/catalog/issue-linking.test.ts +++ b/packages/mcp-core/src/tools/catalog/issue-linking.test.ts @@ -205,6 +205,45 @@ describe("resolveExternalIssueLinkTarget", () => { }); }); + it("uses the Azure DevOps organization segment for domain matching", async () => { + const target = await resolveExternalIssueLinkTarget({ + apiService: { + listIssueIntegrations: async () => [ + integration({ + id: "1", + name: "Azure Other", + domainName: "dev.azure.com/other", + provider: { key: "vsts" }, + }), + integration({ + id: "2", + name: "Azure Acme", + domainName: "dev.azure.com/acme", + provider: { key: "vsts" }, + }), + ], + getIssueIntegrationLinkConfig: async ({ integrationId }) => + linkConfig({ + id: integrationId, + provider: { key: "vsts" }, + linkIssueConfig: [{ name: "externalIssue", required: true }], + }), + listSentryAppInstallations: async () => [], + }, + organizationSlug: "sentry", + issueId: "PROJ-1", + externalIssueUrl: "https://dev.azure.com/acme/project/_workitems/edit/42", + }); + + expect(target).toMatchObject({ + kind: "native", + integration: { id: "2" }, + payload: { + externalIssue: "42", + }, + }); + }); + it("reports ambiguous native integrations", async () => { await expect( resolveExternalIssueLinkTarget({ diff --git a/packages/mcp-core/src/tools/catalog/issue-linking.ts b/packages/mcp-core/src/tools/catalog/issue-linking.ts index 44e8e103..29da7456 100644 --- a/packages/mcp-core/src/tools/catalog/issue-linking.ts +++ b/packages/mcp-core/src/tools/catalog/issue-linking.ts @@ -204,12 +204,15 @@ function parseVstsUrl(url: URL): ParsedNativeIssueUrl | null { if (!issueId) { return null; } + const host = normalizeHost(url.hostname); + const domainPath = + host === "dev.azure.com" && segments[0] ? `${host}/${segments[0]}` : host; return { kind: "native", provider: "vsts", url: url.toString(), - host: normalizeHost(url.hostname), - domainPath: normalizeHost(url.hostname), + host, + domainPath, issueId, }; } From 02f29d6e8755481cd53803ab788970151aa49897 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 29 May 2026 10:29:20 -0700 Subject: [PATCH 04/17] fix(issue): Normalize www integration domains Strip www prefixes from integration domain metadata before comparing against parsed external issue URLs. This keeps domain-based integration disambiguation consistent with host normalization. Co-Authored-By: GPT-5 Codex --- .../src/tools/catalog/issue-linking.test.ts | 26 +++++++++++++++++++ .../src/tools/catalog/issue-linking.ts | 3 ++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/mcp-core/src/tools/catalog/issue-linking.test.ts b/packages/mcp-core/src/tools/catalog/issue-linking.test.ts index 7f71c79b..ac7b2566 100644 --- a/packages/mcp-core/src/tools/catalog/issue-linking.test.ts +++ b/packages/mcp-core/src/tools/catalog/issue-linking.test.ts @@ -205,6 +205,32 @@ describe("resolveExternalIssueLinkTarget", () => { }); }); + it("normalizes www-prefixed integration domains for matching", async () => { + const target = await resolveExternalIssueLinkTarget({ + apiService: { + listIssueIntegrations: async () => [ + integration({ id: "1", name: "GitHub Other", domainName: null }), + integration({ + id: "2", + name: "GitHub Getsentry", + domainName: "www.github.com/getsentry", + }), + ], + getIssueIntegrationLinkConfig: async ({ integrationId }) => + linkConfig({ id: integrationId }), + listSentryAppInstallations: async () => [], + }, + organizationSlug: "sentry", + issueId: "PROJ-1", + externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", + }); + + expect(target).toMatchObject({ + kind: "native", + integration: { id: "2" }, + }); + }); + it("uses the Azure DevOps organization segment for domain matching", async () => { const target = await resolveExternalIssueLinkTarget({ apiService: { diff --git a/packages/mcp-core/src/tools/catalog/issue-linking.ts b/packages/mcp-core/src/tools/catalog/issue-linking.ts index 29da7456..6dbe5231 100644 --- a/packages/mcp-core/src/tools/catalog/issue-linking.ts +++ b/packages/mcp-core/src/tools/catalog/issue-linking.ts @@ -90,7 +90,8 @@ function normalizeDomain(value?: string | null): string | null { return null; } const withoutProtocol = value.replace(/^https?:\/\//i, ""); - return trimSlashes(withoutProtocol).toLowerCase(); + const withoutWww = withoutProtocol.replace(/^www\./i, ""); + return trimSlashes(withoutWww).toLowerCase(); } function pathSegments(url: URL): string[] { From 6b4514016f541e9074ef9f76cd6b81238a184702 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 29 May 2026 10:37:40 -0700 Subject: [PATCH 05/17] fix(issue): Mark partial link failures as errors Return MCP tool result metadata when an issue update succeeds but external issue linking fails, so clients see the partial failure as an errored tool call instead of a silent success. Refs GH-228 Co-Authored-By: GPT-5 Codex --- packages/mcp-core/src/server.test.ts | 45 ++++++++++++ .../catalog/get-event-attachment.test.ts | 3 + .../src/tools/catalog/update-issue.test.ts | 69 ++++++++++++------- .../src/tools/catalog/update-issue.ts | 10 ++- packages/mcp-core/src/tools/types.ts | 3 + 5 files changed, 106 insertions(+), 24 deletions(-) diff --git a/packages/mcp-core/src/server.test.ts b/packages/mcp-core/src/server.test.ts index 5e65a1f4..145353ae 100644 --- a/packages/mcp-core/src/server.test.ts +++ b/packages/mcp-core/src/server.test.ts @@ -192,6 +192,51 @@ describe("buildServer", () => { ip_address: "192.0.2.1", }); }); + + it("preserves tool result error state", async () => { + const server = buildServer({ + context: baseContext, + tools: { + example_tool: createMockTool("example_tool", { + annotations: { readOnlyHint: true }, + handler: async () => ({ + content: [ + { + type: "text", + text: "partial failure", + }, + ], + isError: true, + }), + }), + }, + }); + const registeredTools = ( + server as unknown as { + _registeredTools: Record< + string, + { + handler: ( + params: Record, + extra: unknown, + ) => Promise; + } + >; + } + )._registeredTools; + + await expect( + registeredTools.example_tool?.handler({}, {}), + ).resolves.toEqual({ + content: [ + { + type: "text", + text: "partial failure", + }, + ], + isError: true, + }); + }); }); describe("experimental tool filtering", () => { diff --git a/packages/mcp-core/src/tools/catalog/get-event-attachment.test.ts b/packages/mcp-core/src/tools/catalog/get-event-attachment.test.ts index 18485a46..d5969b88 100644 --- a/packages/mcp-core/src/tools/catalog/get-event-attachment.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-event-attachment.test.ts @@ -66,6 +66,9 @@ describe("get_event_attachment", () => { // Should return an array with both text description and image content expect(Array.isArray(result)).toBe(true); + if (!Array.isArray(result)) { + throw new Error("Expected attachment handler to return content array"); + } expect(result).toHaveLength(2); // First item should be the image content diff --git a/packages/mcp-core/src/tools/catalog/update-issue.test.ts b/packages/mcp-core/src/tools/catalog/update-issue.test.ts index 121e7936..76e2672b 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.test.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { http, HttpResponse } from "msw"; import { issueFixture, mswServer } from "@sentry/mcp-server-mocks"; import updateIssue from "./update-issue.js"; +import type { ToolHandlerResult, ToolResult } from "../types"; type MockIssue = typeof issueFixture; @@ -20,6 +21,24 @@ function createIssue(overrides: Partial = {}): MockIssue { }; } +function getErroredTextResult(result: ToolHandlerResult): string { + expect(typeof result).toBe("object"); + expect(result).not.toBeNull(); + expect(Array.isArray(result)).toBe(false); + + const toolResult = result as ToolResult; + expect(toolResult.isError).toBe(true); + expect(toolResult.content).toHaveLength(1); + + const content = toolResult.content[0]; + expect(content.type).toBe("text"); + if (content.type !== "text") { + throw new Error(`Expected text content, got ${content.type}`); + } + + return content.text; +} + afterEach(() => { mswServer.resetHandlers(); }); @@ -1108,17 +1127,19 @@ describe("update_issue", () => { ), ); - const result = await updateIssue.handler( - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-41", - status: "resolved", - externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", - assignedTo: undefined, - issueUrl: undefined, - regionUrl: null, - }, - serverContext, + const result = getErroredTextResult( + await updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status: "resolved", + externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + }, + serverContext, + ), ); expect(result).toContain("Partially Updated"); @@ -1139,18 +1160,20 @@ describe("update_issue", () => { ), ); - const result = await updateIssue.handler( - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-41", - status: "resolved", - externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", - reason: "Fixing in linked ticket", - assignedTo: undefined, - issueUrl: undefined, - regionUrl: null, - }, - serverContext, + const result = getErroredTextResult( + await updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status: "resolved", + externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", + reason: "Fixing in linked ticket", + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + }, + serverContext, + ), ); expect(result).toContain("Partially Updated"); diff --git a/packages/mcp-core/src/tools/catalog/update-issue.ts b/packages/mcp-core/src/tools/catalog/update-issue.ts index 51afc348..6cb158b0 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.ts @@ -905,7 +905,15 @@ export default defineTool({ output += formatReasonCommentLine(params.reason, partialCommentResult); output += "\n## Response Notes\n\n"; output += `- Full issue details: \`get_sentry_resource(resourceType="issue", organizationSlug="${orgSlug}", resourceId="${updatedIssue.shortId}")\`\n`; - return output; + return { + content: [ + { + type: "text", + text: output, + }, + ], + isError: true, + }; } } diff --git a/packages/mcp-core/src/tools/types.ts b/packages/mcp-core/src/tools/types.ts index c1650606..111ac99d 100644 --- a/packages/mcp-core/src/tools/types.ts +++ b/packages/mcp-core/src/tools/types.ts @@ -11,6 +11,9 @@ import type { ProjectCapabilities, ServerContext } from "../types"; export type ToolContent = TextContent | ImageContent | EmbeddedResource; export type ToolOutput = string | ToolContent[] | CallToolResult; +export type ToolResult = Pick; +export type ToolHandlerResult = ToolOutput; + /** * Keeps schema-inferred handler params at tool definition sites while allowing * heterogeneous tool registries to store many concrete handler signatures. From a6f4e1617dbe7656f471491649595428506f61b9 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 29 May 2026 12:37:04 -0700 Subject: [PATCH 06/17] fix(issue): Support Jira Server domains with ports Match native issue URLs using the full URL host so self-hosted Jira Server installations with non-default ports resolve against Sentry's domainName value. Refs GH-228 Co-Authored-By: GPT-5 Codex --- .../src/tools/catalog/issue-linking.test.ts | 48 +++++++++++++++++++ .../src/tools/catalog/issue-linking.ts | 24 ++++++---- 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/packages/mcp-core/src/tools/catalog/issue-linking.test.ts b/packages/mcp-core/src/tools/catalog/issue-linking.test.ts index ac7b2566..ec1d9120 100644 --- a/packages/mcp-core/src/tools/catalog/issue-linking.test.ts +++ b/packages/mcp-core/src/tools/catalog/issue-linking.test.ts @@ -64,6 +64,15 @@ describe("parseExternalIssueUrl", () => { provider: "jira", issueId: "ENG-123", }); + expect( + parseExternalIssueUrl("https://jira.example.org:8443/browse/OPS-456"), + ).toMatchObject({ + kind: "native", + provider: "jira", + host: "jira.example.org:8443", + domainPath: "jira.example.org:8443", + issueId: "OPS-456", + }); expect( parseExternalIssueUrl("https://github.com/getsentry/sentry/issues/123"), ).toMatchObject({ @@ -231,6 +240,45 @@ describe("resolveExternalIssueLinkTarget", () => { }); }); + it("resolves self-hosted Jira Server integrations by domain", async () => { + const target = await resolveExternalIssueLinkTarget({ + apiService: { + listIssueIntegrations: async () => [ + integration({ + id: "1", + name: "Jira Cloud", + domainName: "acme.atlassian.net", + provider: { key: "jira" }, + }), + integration({ + id: "2", + name: "Example Jira", + domainName: "jira.example.org:8443", + provider: { key: "jira_server" }, + }), + ], + getIssueIntegrationLinkConfig: async ({ integrationId }) => + linkConfig({ + id: integrationId, + provider: { key: "jira_server" }, + linkIssueConfig: [{ name: "externalIssue", required: true }], + }), + listSentryAppInstallations: async () => [], + }, + organizationSlug: "sentry", + issueId: "PROJ-1", + externalIssueUrl: "https://jira.example.org:8443/browse/OPS-456", + }); + + expect(target).toMatchObject({ + kind: "native", + integration: { id: "2" }, + payload: { + externalIssue: "OPS-456", + }, + }); + }); + it("uses the Azure DevOps organization segment for domain matching", async () => { const target = await resolveExternalIssueLinkTarget({ apiService: { diff --git a/packages/mcp-core/src/tools/catalog/issue-linking.ts b/packages/mcp-core/src/tools/catalog/issue-linking.ts index 6dbe5231..8aa99a1e 100644 --- a/packages/mcp-core/src/tools/catalog/issue-linking.ts +++ b/packages/mcp-core/src/tools/catalog/issue-linking.ts @@ -85,6 +85,10 @@ function normalizeHost(value: string): string { return value.toLowerCase().replace(/^www\./, ""); } +function normalizeUrlHost(url: URL): string { + return normalizeHost(url.host); +} + function normalizeDomain(value?: string | null): string | null { if (!value) { return null; @@ -113,14 +117,14 @@ function parseJiraUrl(url: URL): ParsedNativeIssueUrl | null { kind: "native", provider: "jira", url: url.toString(), - host: normalizeHost(url.hostname), - domainPath: normalizeHost(url.hostname), + host: normalizeUrlHost(url), + domainPath: normalizeUrlHost(url), issueId, }; } function parseGithubUrl(url: URL): ParsedNativeIssueUrl | null { - const host = normalizeHost(url.hostname); + const host = normalizeUrlHost(url); if (host === "bitbucket.org" || host === "gitlab.com") { return null; } @@ -163,8 +167,8 @@ function parseGitlabUrl(url: URL): ParsedNativeIssueUrl | null { kind: "native", provider: "gitlab", url: url.toString(), - host: normalizeHost(url.hostname), - domainPath: `${normalizeHost(url.hostname)}/${project}`, + host: normalizeUrlHost(url), + domainPath: `${normalizeUrlHost(url)}/${project}`, project, issueId, }; @@ -184,8 +188,8 @@ function parseBitbucketUrl(url: URL): ParsedNativeIssueUrl | null { kind: "native", provider: "bitbucket", url: url.toString(), - host: normalizeHost(url.hostname), - domainPath: `${normalizeHost(url.hostname)}/${segments[0]}`, + host: normalizeUrlHost(url), + domainPath: `${normalizeUrlHost(url)}/${segments[0]}`, repo, issueId, }; @@ -205,9 +209,11 @@ function parseVstsUrl(url: URL): ParsedNativeIssueUrl | null { if (!issueId) { return null; } - const host = normalizeHost(url.hostname); + const host = normalizeUrlHost(url); const domainPath = - host === "dev.azure.com" && segments[0] ? `${host}/${segments[0]}` : host; + normalizeHost(url.hostname) === "dev.azure.com" && segments[0] + ? `${host}/${segments[0]}` + : host; return { kind: "native", provider: "vsts", From 43b8684dbc1d4b060f6ef87c433a0d3bdb4b5f6c Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 29 May 2026 12:55:32 -0700 Subject: [PATCH 07/17] fix(issue): Mark error tool result spans as failed Reflect MCP tool results with isError=true in tracing span status so partial failures are not recorded as successful tool calls. Refs GH-228 Co-Authored-By: GPT-5 Codex --- packages/mcp-core/src/server.test.ts | 10 +++++++++- packages/mcp-core/src/server.ts | 27 +++++++++++++++++++-------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/mcp-core/src/server.test.ts b/packages/mcp-core/src/server.test.ts index 145353ae..8b43247c 100644 --- a/packages/mcp-core/src/server.test.ts +++ b/packages/mcp-core/src/server.test.ts @@ -1,6 +1,6 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { type Span, setUser, startSpan } from "@sentry/core"; +import { type Span, getActiveSpan, setUser, startSpan } from "@sentry/core"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { buildServer } from "./server"; @@ -194,6 +194,13 @@ describe("buildServer", () => { }); it("preserves tool result error state", async () => { + const setStatus = vi.fn(); + const span = { + setAttribute: vi.fn(), + setStatus, + recordException: vi.fn(), + } as unknown as Span; + vi.mocked(getActiveSpan).mockReturnValue(span); const server = buildServer({ context: baseContext, tools: { @@ -236,6 +243,7 @@ describe("buildServer", () => { ], isError: true, }); + expect(setStatus).toHaveBeenCalledWith({ code: 2 }); }); }); diff --git a/packages/mcp-core/src/server.ts b/packages/mcp-core/src/server.ts index fb77a17e..d8223e87 100644 --- a/packages/mcp-core/src/server.ts +++ b/packages/mcp-core/src/server.ts @@ -325,15 +325,9 @@ function configureServer({ context: contextWithToolAvailability, }); - if (activeSpan) { - activeSpan.setStatus({ - code: 1, // ok - }); - } - // if the tool returns a string, assume it's a message if (typeof output === "string") { - return { + const result = { content: [ { type: "text" as const, @@ -341,16 +335,33 @@ function configureServer({ }, ], }; + if (activeSpan) { + activeSpan.setStatus({ + code: 1, // ok + }); + } + return result; } // if the tool returns a list, assume it's a content list if (Array.isArray(output)) { - return { + const result = { content: output, }; + if (activeSpan) { + activeSpan.setStatus({ + code: 1, // ok + }); + } + return result; } // Some tools return a full MCP CallToolResult so they can expose // structuredContent alongside a text fallback. if (isCallToolResult(output)) { + if (activeSpan) { + activeSpan.setStatus({ + code: output.isError ? 2 : 1, + }); + } return output; } throw new Error(`Invalid tool output: ${output}`); From 45d036d4bf6c42581f233ba541b858a33a908961 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 20:24:56 +0000 Subject: [PATCH 08/17] fix(issue-linking): guard against non-GitHub hosts and stray Bitbucket matches - parseBitbucketUrl: add explicit bitbucket.org host guard so GitLab URLs missing the /-/ path marker no longer fall through as provider:bitbucket - resolveNativeTarget: for GitHub Enterprise URLs (host !== github.com), require a positive integration domain match; prevents arbitrary GitHub-shaped paths on unrelated hostnames from silently linking to null-domain GitHub integrations - Add regression tests covering both cases Co-Authored-By: junior (AI) --- [View Session in Sentry](https://sentry.sentry.io/traces/?project=4510944073809921&query=gen_ai.conversation.id%3A%22slack%3AC0B595QDZLL%3A1780085671.316309%22) --- .../src/tools/catalog/issue-linking.test.ts | 58 +++++++++++++++++++ .../src/tools/catalog/issue-linking.ts | 23 +++++++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/packages/mcp-core/src/tools/catalog/issue-linking.test.ts b/packages/mcp-core/src/tools/catalog/issue-linking.test.ts index ec1d9120..11c870a1 100644 --- a/packages/mcp-core/src/tools/catalog/issue-linking.test.ts +++ b/packages/mcp-core/src/tools/catalog/issue-linking.test.ts @@ -135,6 +135,10 @@ describe("parseExternalIssueUrl", () => { expect(() => parseExternalIssueUrl("https://tickets.example.com/work/ABC-1"), ).toThrow(UserInputError); + // GitLab URL missing the /-/ marker must not fall through to the Bitbucket parser + expect(() => + parseExternalIssueUrl("https://gitlab.com/getsentry/sentry/issues/123"), + ).toThrow(UserInputError); }); }); @@ -318,6 +322,60 @@ describe("resolveExternalIssueLinkTarget", () => { }); }); + it("rejects non-github.com hosts with no matching GitHub Enterprise integration domain", async () => { + await expect( + resolveExternalIssueLinkTarget({ + apiService: { + listIssueIntegrations: async () => [ + integration({ id: "1", domainName: null }), + ], + getIssueIntegrationLinkConfig: async () => linkConfig({ id: "1" }), + listSentryAppInstallations: async () => [], + }, + organizationSlug: "sentry", + issueId: "PROJ-1", + externalIssueUrl: + "https://internal.company.com/getsentry/sentry/issues/123", + }), + ).rejects.toThrow( + /Configure a GitHub Enterprise integration with a matching domain/, + ); + }); + + it("resolves GitHub Enterprise URLs with a configured integration domain", async () => { + const target = await resolveExternalIssueLinkTarget({ + apiService: { + listIssueIntegrations: async () => [ + integration({ + id: "1", + name: "GitHub Enterprise", + domainName: "internal.company.com/getsentry", + provider: { key: "github_enterprise" }, + }), + ], + getIssueIntegrationLinkConfig: async () => + linkConfig({ + id: "1", + provider: { key: "github_enterprise" }, + }), + listSentryAppInstallations: async () => [], + }, + organizationSlug: "sentry", + issueId: "PROJ-1", + externalIssueUrl: + "https://internal.company.com/getsentry/sentry/issues/123", + }); + + expect(target).toMatchObject({ + kind: "native", + integration: { id: "1" }, + payload: { + repo: "getsentry/sentry", + externalIssue: "123", + }, + }); + }); + it("reports ambiguous native integrations", async () => { await expect( resolveExternalIssueLinkTarget({ diff --git a/packages/mcp-core/src/tools/catalog/issue-linking.ts b/packages/mcp-core/src/tools/catalog/issue-linking.ts index 8aa99a1e..1aa788ca 100644 --- a/packages/mcp-core/src/tools/catalog/issue-linking.ts +++ b/packages/mcp-core/src/tools/catalog/issue-linking.ts @@ -175,6 +175,10 @@ function parseGitlabUrl(url: URL): ParsedNativeIssueUrl | null { } function parseBitbucketUrl(url: URL): ParsedNativeIssueUrl | null { + const host = normalizeUrlHost(url); + if (host !== "bitbucket.org") { + return null; + } const segments = pathSegments(url); if (segments.length < 4 || segments[2] !== "issues") { return null; @@ -188,8 +192,8 @@ function parseBitbucketUrl(url: URL): ParsedNativeIssueUrl | null { kind: "native", provider: "bitbucket", url: url.toString(), - host: normalizeUrlHost(url), - domainPath: `${normalizeUrlHost(url)}/${segments[0]}`, + host, + domainPath: `${host}/${segments[0]}`, repo, issueId, }; @@ -459,8 +463,23 @@ async function resolveNativeTarget(params: { } const bestDomainScore = Math.max( + 0, ...candidates.map((candidate) => domainMatchScore(candidate, parsed)), ); + + // For non-public GitHub hosts (GitHub Enterprise), require a positive domain + // match so that arbitrary URLs with GitHub-shaped paths cannot silently link + // to a null-domain GitHub integration. + if ( + parsed.provider === "github" && + parsed.host !== "github.com" && + bestDomainScore === 0 + ) { + throw new UserInputError( + `Unsupported GitHub Enterprise URL host \`${parsed.host}\`. Configure a GitHub Enterprise integration with a matching domain name before linking this URL.`, + ); + } + if (bestDomainScore > 0) { candidates = candidates.filter( (candidate) => domainMatchScore(candidate, parsed) === bestDomainScore, From 1133c026b9974fad1881d0bce31c027375e7b4ff Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 29 May 2026 13:51:55 -0700 Subject: [PATCH 09/17] fix(issue): Avoid retry signal for partial update success Treat issue updates that succeed before external linking fails as partial success instead of a failed tool call. This prevents MCP clients from retrying already-applied mutations while preserving the failure details in the response text. Refs GH-228 Co-Authored-By: GPT-5 Codex --- .../src/tools/catalog/update-issue.test.ts | 22 +++++++++++++++++-- .../src/tools/catalog/update-issue.ts | 1 - 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/mcp-core/src/tools/catalog/update-issue.test.ts b/packages/mcp-core/src/tools/catalog/update-issue.test.ts index 76e2672b..7d29c15d 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.test.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.test.ts @@ -39,6 +39,24 @@ function getErroredTextResult(result: ToolHandlerResult): string { return content.text; } +function getTextToolResult(result: ToolHandlerResult): string { + expect(typeof result).toBe("object"); + expect(result).not.toBeNull(); + expect(Array.isArray(result)).toBe(false); + + const toolResult = result as ToolResult; + expect(toolResult.isError).not.toBe(true); + expect(toolResult.content).toHaveLength(1); + + const content = toolResult.content[0]; + expect(content.type).toBe("text"); + if (content.type !== "text") { + throw new Error(`Expected text content, got ${content.type}`); + } + + return content.text; +} + afterEach(() => { mswServer.resetHandlers(); }); @@ -1127,7 +1145,7 @@ describe("update_issue", () => { ), ); - const result = getErroredTextResult( + const result = getTextToolResult( await updateIssue.handler( { organizationSlug: "sentry-mcp-evals", @@ -1160,7 +1178,7 @@ describe("update_issue", () => { ), ); - const result = getErroredTextResult( + const result = getTextToolResult( await updateIssue.handler( { organizationSlug: "sentry-mcp-evals", diff --git a/packages/mcp-core/src/tools/catalog/update-issue.ts b/packages/mcp-core/src/tools/catalog/update-issue.ts index 6cb158b0..67dd35c1 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.ts @@ -912,7 +912,6 @@ export default defineTool({ text: output, }, ], - isError: true, }; } } From fc496663fb042d00d813cda5060556ed9db4937f Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 29 May 2026 14:02:30 -0700 Subject: [PATCH 10/17] test(issue): Remove unused error result helper Drop the obsolete update_issue test helper after the partial-success tests switched to asserting normal text results. Refs GH-228 Co-Authored-By: GPT-5 Codex --- .../src/tools/catalog/update-issue.test.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/packages/mcp-core/src/tools/catalog/update-issue.test.ts b/packages/mcp-core/src/tools/catalog/update-issue.test.ts index 7d29c15d..1e69b469 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.test.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.test.ts @@ -21,24 +21,6 @@ function createIssue(overrides: Partial = {}): MockIssue { }; } -function getErroredTextResult(result: ToolHandlerResult): string { - expect(typeof result).toBe("object"); - expect(result).not.toBeNull(); - expect(Array.isArray(result)).toBe(false); - - const toolResult = result as ToolResult; - expect(toolResult.isError).toBe(true); - expect(toolResult.content).toHaveLength(1); - - const content = toolResult.content[0]; - expect(content.type).toBe("text"); - if (content.type !== "text") { - throw new Error(`Expected text content, got ${content.type}`); - } - - return content.text; -} - function getTextToolResult(result: ToolHandlerResult): string { expect(typeof result).toBe("object"); expect(result).not.toBeNull(); From 68972914850983ba509984461f9d51778efaface Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 29 May 2026 14:12:19 -0700 Subject: [PATCH 11/17] fix(issue): Report comment-only updates clearly Return a comment-specific response when update_issue is called with only a reason. This avoids telling agents that no changes were needed after a comment was posted. Refs GH-228 Co-Authored-By: GPT-5 Codex --- .../src/tools/catalog/update-issue.test.ts | 50 +++++++++++++++++ .../src/tools/catalog/update-issue.ts | 55 +++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/packages/mcp-core/src/tools/catalog/update-issue.test.ts b/packages/mcp-core/src/tools/catalog/update-issue.test.ts index 1e69b469..dd33f3ef 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.test.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.test.ts @@ -1366,6 +1366,56 @@ describe("update_issue", () => { ); }); + it("reports comment-only calls as commented", async () => { + let commentPosted: { text: string } | undefined; + const currentIssue = createIssue({ + status: "unresolved", + statusDetails: {}, + }); + + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/", + () => HttpResponse.json(currentIssue), + ), + http.post( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/notes/", + async ({ request }) => { + commentPosted = (await request.json()) as { text: string }; + return HttpResponse.json({ + id: "12345", + text: commentPosted.text, + type: "note", + dateCreated: new Date().toISOString(), + }); + }, + ), + ); + + const result = await updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + reason: "Adding investigation notes for the on-call handoff", + }, + serverContext, + ); + + expect(commentPosted).toEqual({ + text: "Adding investigation notes for the on-call handoff", + }); + expect(result).toContain( + "# Issue CLOUDFLARE-MCP-41 Commented in **sentry-mcp-evals**", + ); + expect(result).not.toContain("No changes were needed."); + expect(result).toContain( + '**Comment posted**: "Adding investigation notes for the on-call handoff"', + ); + }); + it("does not throw when comment posting fails after a successful update", async () => { const currentIssue = createIssue({ status: "unresolved", diff --git a/packages/mcp-core/src/tools/catalog/update-issue.ts b/packages/mcp-core/src/tools/catalog/update-issue.ts index 67dd35c1..7cdf37e8 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.ts @@ -344,6 +344,45 @@ function buildNoChangesOutput(params: { return output; } +function buildCommentOnlyOutput(params: { + issue: Issue; + organizationSlug: string; + ignoreState: IgnoreState | null; + issueUrl: string; + reason: string; + commentResult: ReasonCommentResult; +}): string { + const { + issue, + organizationSlug, + ignoreState, + issueUrl, + reason, + commentResult, + } = params; + const title = commentResult.posted ? "Commented" : "Comment Not Posted"; + let output = `# Issue ${issue.shortId} ${title} in **${organizationSlug}**\n\n`; + output += `**Issue**: ${issue.title}\n`; + output += `**URL**: ${issueUrl}\n\n`; + + output += "## Changes Made\n\n"; + output += formatReasonCommentLine(reason, commentResult); + output += "- No issue fields were changed.\n"; + + output += "\n## Current Status\n\n"; + output += `**Status**: ${getIssueStatusDisplay(issue)}\n`; + if (ignoreState) { + output += `**Ignore Behavior**: ${ignoreState.behavior}\n`; + } + output += `**Assigned To**: ${formatAssignedTo(issue.assignedTo ?? null)}\n`; + + output += "\n## Response Notes\n\n"; + output += "- The request only attempted to add a comment to the issue.\n"; + output += `- Full issue details: \`get_sentry_resource(resourceType="issue", organizationSlug="${organizationSlug}", resourceId="${issue.shortId}")\`\n`; + + return output; +} + function isAssigneeAlreadySet( issue: Issue, requestedAssignee: string | undefined, @@ -847,6 +886,22 @@ export default defineTool({ params.reason, ); + if ( + params.reason && + !params.status && + !params.assignedTo && + !params.externalIssueUrl + ) { + return buildCommentOnlyOutput({ + issue: currentIssue, + organizationSlug: orgSlug, + ignoreState: currentIgnoreState, + issueUrl: requestedIssueUrl, + reason: params.reason, + commentResult, + }); + } + return ( buildNoChangesOutput({ issue: currentIssue, From c8051490cd72496f814153ad8b8c5123454ab4a1 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 00:10:26 +0000 Subject: [PATCH 12/17] fix(issue-linking): use first match for multiple candidates; exclude mismatched domains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the ambiguity error when multiple candidates match a URL — use the first matching candidate instead of throwing. - When bestDomainScore is 0, filter out integrations that have an explicit domainName configured but don't match the URL. A configured domain that doesn't match is a stronger exclusion signal than having no domain at all (fixes silent wrong-instance linking for Jira/GitLab/VSTS/Bitbucket). - Update tests to reflect the new first-match behavior and add a test for the mismatched-domain exclusion case. Action taken on behalf of David Cramer. --- [View Session in Sentry](https://sentry.sentry.io/traces/?project=4510944073809921&query=gen_ai.conversation.id%3A%22slack%3AC0B595QDZLL%3A1780098433.595859%22) --- .../src/tools/catalog/issue-linking.test.ts | 46 ++++++++++++++++--- .../src/tools/catalog/issue-linking.ts | 18 +++++--- .../src/tools/catalog/update-issue.test.ts | 31 ++++++------- 3 files changed, 67 insertions(+), 28 deletions(-) diff --git a/packages/mcp-core/src/tools/catalog/issue-linking.test.ts b/packages/mcp-core/src/tools/catalog/issue-linking.test.ts index 11c870a1..ebf5840d 100644 --- a/packages/mcp-core/src/tools/catalog/issue-linking.test.ts +++ b/packages/mcp-core/src/tools/catalog/issue-linking.test.ts @@ -376,22 +376,56 @@ describe("resolveExternalIssueLinkTarget", () => { }); }); - it("reports ambiguous native integrations", async () => { + it("ignores integrations whose configured domain does not match the URL", async () => { await expect( resolveExternalIssueLinkTarget({ apiService: { listIssueIntegrations: async () => [ - integration({ id: "1", name: "GitHub A", domainName: null }), - integration({ id: "2", name: "GitHub B", domainName: null }), + integration({ + id: "1", + name: "Jira Cloud", + domainName: "other.atlassian.net", + provider: { key: "jira" }, + }), ], - getIssueIntegrationLinkConfig: async () => linkConfig(), + getIssueIntegrationLinkConfig: async ({ integrationId }) => + linkConfig({ + id: integrationId, + provider: { key: "jira" }, + linkIssueConfig: [{ name: "externalIssue", required: true }], + }), listSentryAppInstallations: async () => [], }, organizationSlug: "sentry", issueId: "PROJ-1", - externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", + externalIssueUrl: "https://acme.atlassian.net/browse/PROJ-1", }), - ).rejects.toThrow(/Multiple installed integrations/); + ).rejects.toThrow(/No installed jira issue integration/); + }); + + it("uses the first matching candidate when multiple integrations match", async () => { + const target = await resolveExternalIssueLinkTarget({ + apiService: { + listIssueIntegrations: async () => [ + integration({ id: "1", name: "GitHub A", domainName: null }), + integration({ id: "2", name: "GitHub B", domainName: null }), + ], + getIssueIntegrationLinkConfig: async () => linkConfig(), + listSentryAppInstallations: async () => [], + }, + organizationSlug: "sentry", + issueId: "PROJ-1", + externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", + }); + + expect(target).toMatchObject({ + kind: "native", + integration: { id: "1" }, + payload: { + repo: "getsentry/sentry", + externalIssue: "123", + }, + }); }); it("resolves Sentry App installations without exposing UUIDs", async () => { diff --git a/packages/mcp-core/src/tools/catalog/issue-linking.ts b/packages/mcp-core/src/tools/catalog/issue-linking.ts index 1aa788ca..05ccd1b1 100644 --- a/packages/mcp-core/src/tools/catalog/issue-linking.ts +++ b/packages/mcp-core/src/tools/catalog/issue-linking.ts @@ -484,6 +484,15 @@ async function resolveNativeTarget(params: { candidates = candidates.filter( (candidate) => domainMatchScore(candidate, parsed) === bestDomainScore, ); + } else { + // No candidate matched the URL's domain. Filter out any candidate that has + // an explicit domainName configured — a domain that is set but doesn't + // match is a stronger exclusion signal than having no domain restriction. + // Candidates with no domainName are kept as fallback (e.g. github.com + // integrations that don't advertise a domainName). + candidates = candidates.filter( + (candidate) => !normalizeDomain(candidate.domainName), + ); } const candidatesWithConfig = await Promise.all( @@ -505,12 +514,9 @@ async function resolveNativeTarget(params: { `No installed ${parsed.provider} issue integration can access the project or repository in ${parsed.url}.`, ); } - if (matchingCandidates.length > 1) { - throw new UserInputError( - `Multiple installed integrations can link ${parsed.url}: ${describeNativeCandidates(matchingCandidates.map(({ integration }) => integration))}. Unlink duplicate integration access in Sentry or use a URL that maps to a single installed integration.`, - ); - } - + // When multiple integrations still match, use the first one rather than + // surfacing an ambiguity error — the caller has no way to resolve it and + // any matching candidate should produce a correct link. const [{ integration, config }] = matchingCandidates; return { kind: "native", diff --git a/packages/mcp-core/src/tools/catalog/update-issue.test.ts b/packages/mcp-core/src/tools/catalog/update-issue.test.ts index dd33f3ef..9e78241a 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.test.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.test.ts @@ -1066,7 +1066,7 @@ describe("update_issue", () => { expect(updateCalled).toBe(false); }); - it("reports ambiguous native integrations before updating", async () => { + it("uses the first matching integration when multiple candidates exist", async () => { let updateCalled = false; mswServer.use( http.get( @@ -1098,21 +1098,20 @@ describe("update_issue", () => { ), ); - await expect( - updateIssue.handler( - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-41", - status: "resolved", - externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", - assignedTo: undefined, - issueUrl: undefined, - regionUrl: null, - }, - serverContext, - ), - ).rejects.toThrow("Multiple installed integrations"); - expect(updateCalled).toBe(false); + const result = await updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status: "resolved", + externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + }, + serverContext, + ); + expect(updateCalled).toBe(true); + expect(result).toContain("CLOUDFLARE-MCP-41"); }); it("reports partial success when link write fails after issue update", async () => { From c1f5416bd87702cbec34db4a353474df5856c48e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 00:38:25 +0000 Subject: [PATCH 13/17] fix(issue-linking): remove dead code, improve domain-mismatch errors - Remove unused `describeNativeCandidates` function (dead code since the multiple-candidates throw was replaced with first-match behavior). - Add an explicit guard after the bestDomainScore-0 domain filter: when all candidates are eliminated because their configured domains don't match the URL, throw a clear domain/path-specific error instead of falling through to the misleading 'can't access project or repository' message. Also short-circuits the unnecessary link-config fetch. - Reword the GitHub Enterprise guard message from 'Unsupported host' to 'No installed GHE integration matches the URL domain/path', which is accurate when the host is known but the org/path segment differs. - Tighten Jira domain-mismatch test to assert the new error shape and verify no link-config call is made; add a test for the github.com wrong-org case that exercises the new empty-candidates guard. Action taken on behalf of David Cramer. --- [View Session in Sentry](https://sentry.sentry.io/traces/?project=4510944073809921&query=gen_ai.conversation.id%3A%22slack%3AC0B595QDZLL%3A1780098433.595859%22) Co-authored-by: David Cramer --- .../src/tools/catalog/issue-linking.test.ts | 39 +++++++++++++++++-- .../src/tools/catalog/issue-linking.ts | 16 +++----- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/packages/mcp-core/src/tools/catalog/issue-linking.test.ts b/packages/mcp-core/src/tools/catalog/issue-linking.test.ts index ebf5840d..b2878e78 100644 --- a/packages/mcp-core/src/tools/catalog/issue-linking.test.ts +++ b/packages/mcp-core/src/tools/catalog/issue-linking.test.ts @@ -377,6 +377,7 @@ describe("resolveExternalIssueLinkTarget", () => { }); it("ignores integrations whose configured domain does not match the URL", async () => { + let configCalled = false; await expect( resolveExternalIssueLinkTarget({ apiService: { @@ -388,19 +389,49 @@ describe("resolveExternalIssueLinkTarget", () => { provider: { key: "jira" }, }), ], - getIssueIntegrationLinkConfig: async ({ integrationId }) => - linkConfig({ + getIssueIntegrationLinkConfig: async ({ integrationId }) => { + configCalled = true; + return linkConfig({ id: integrationId, provider: { key: "jira" }, linkIssueConfig: [{ name: "externalIssue", required: true }], - }), + }); + }, listSentryAppInstallations: async () => [], }, organizationSlug: "sentry", issueId: "PROJ-1", externalIssueUrl: "https://acme.atlassian.net/browse/PROJ-1", }), - ).rejects.toThrow(/No installed jira issue integration/); + ).rejects.toThrow( + /No installed jira issue integration matches the URL domain\/path/, + ); + // domain filtering should short-circuit before fetching link config + expect(configCalled).toBe(false); + }); + + it("rejects github.com URLs when only a wrong-org integration is configured", async () => { + await expect( + resolveExternalIssueLinkTarget({ + apiService: { + listIssueIntegrations: async () => [ + integration({ + id: "1", + name: "GitHub Other", + domainName: "github.com/other", + provider: { key: "github" }, + }), + ], + getIssueIntegrationLinkConfig: async () => linkConfig({ id: "1" }), + listSentryAppInstallations: async () => [], + }, + organizationSlug: "sentry", + issueId: "PROJ-1", + externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", + }), + ).rejects.toThrow( + /No installed github issue integration matches the URL domain\/path/, + ); }); it("uses the first matching candidate when multiple integrations match", async () => { diff --git a/packages/mcp-core/src/tools/catalog/issue-linking.ts b/packages/mcp-core/src/tools/catalog/issue-linking.ts index 05ccd1b1..6327ee1b 100644 --- a/packages/mcp-core/src/tools/catalog/issue-linking.ts +++ b/packages/mcp-core/src/tools/catalog/issue-linking.ts @@ -388,15 +388,6 @@ function configMatchesParsedUrl( return true; } -function describeNativeCandidates(candidates: IssueIntegration[]): string { - return candidates - .map( - (candidate) => - `${candidate.name} (${candidate.provider.key}${candidate.domainName ? `, ${candidate.domainName}` : ""})`, - ) - .join(", "); -} - function buildNativeLinkPayload( parsed: ParsedNativeIssueUrl, config: IssueIntegrationLinkConfig, @@ -476,7 +467,7 @@ async function resolveNativeTarget(params: { bestDomainScore === 0 ) { throw new UserInputError( - `Unsupported GitHub Enterprise URL host \`${parsed.host}\`. Configure a GitHub Enterprise integration with a matching domain name before linking this URL.`, + `No installed GitHub Enterprise integration matches the URL domain/path \`${parsed.domainPath ?? parsed.host}\`. Configure a GitHub Enterprise integration with a matching domain name before linking this URL.`, ); } @@ -493,6 +484,11 @@ async function resolveNativeTarget(params: { candidates = candidates.filter( (candidate) => !normalizeDomain(candidate.domainName), ); + if (candidates.length === 0) { + throw new UserInputError( + `No installed ${parsed.provider} issue integration matches the URL domain/path \`${parsed.domainPath ?? parsed.host}\` for ${parsed.url}.`, + ); + } } const candidatesWithConfig = await Promise.all( From be76a1e44200297b55320de01d6e1bb4f5acfb9f Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 29 May 2026 19:50:21 -0700 Subject: [PATCH 14/17] ref: Clean up issue linking helpers Tighten external issue link result types and simplify parser dispatch without changing behavior. Co-Authored-By: OpenAI GPT-5 Codex --- .../src/tools/catalog/issue-linking.ts | 32 ++++++++++--------- .../src/tools/catalog/update-issue.ts | 14 +++++--- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/packages/mcp-core/src/tools/catalog/issue-linking.ts b/packages/mcp-core/src/tools/catalog/issue-linking.ts index 6327ee1b..849478a6 100644 --- a/packages/mcp-core/src/tools/catalog/issue-linking.ts +++ b/packages/mcp-core/src/tools/catalog/issue-linking.ts @@ -113,12 +113,13 @@ function parseJiraUrl(url: URL): ParsedNativeIssueUrl | null { if (!issueId || !/^[A-Z][A-Z0-9]+-\d+$/i.test(issueId)) { return null; } + const host = normalizeUrlHost(url); return { kind: "native", provider: "jira", url: url.toString(), - host: normalizeUrlHost(url), - domainPath: normalizeUrlHost(url), + host, + domainPath: host, issueId, }; } @@ -149,6 +150,7 @@ function parseGithubUrl(url: URL): ParsedNativeIssueUrl | null { } function parseGitlabUrl(url: URL): ParsedNativeIssueUrl | null { + const host = normalizeUrlHost(url); const segments = pathSegments(url); const markerIndex = segments.findIndex((segment) => segment === "-"); if ( @@ -167,8 +169,8 @@ function parseGitlabUrl(url: URL): ParsedNativeIssueUrl | null { kind: "native", provider: "gitlab", url: url.toString(), - host: normalizeUrlHost(url), - domainPath: `${normalizeUrlHost(url)}/${project}`, + host, + domainPath: `${host}/${project}`, project, issueId, }; @@ -290,26 +292,26 @@ export function parseExternalIssueUrl( const host = normalizeHost(url.hostname); - const nativeParsers: Array<() => ParsedNativeIssueUrl | null> = [ - parseJiraUrl.bind(null, url), - parseGithubUrl.bind(null, url), - parseGitlabUrl.bind(null, url), - parseBitbucketUrl.bind(null, url), - parseVstsUrl.bind(null, url), + const nativeParsers: Array<(url: URL) => ParsedNativeIssueUrl | null> = [ + parseJiraUrl, + parseGithubUrl, + parseGitlabUrl, + parseBitbucketUrl, + parseVstsUrl, ]; for (const parse of nativeParsers) { - const parsed = parse(); + const parsed = parse(url); if (parsed) { return parsed; } } - const appParsers: Array<() => ParsedAppIssueUrl | null> = [ - parseLinearUrl.bind(null, url), - parseShortcutUrl.bind(null, url), + const appParsers: Array<(url: URL) => ParsedAppIssueUrl | null> = [ + parseLinearUrl, + parseShortcutUrl, ]; for (const parse of appParsers) { - const parsed = parse(); + const parsed = parse(url); if (parsed) { return parsed; } diff --git a/packages/mcp-core/src/tools/catalog/update-issue.ts b/packages/mcp-core/src/tools/catalog/update-issue.ts index 7cdf37e8..7f44c2a9 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.ts @@ -8,7 +8,11 @@ import { import { formatAssignedTo } from "../../internal/tool-helpers/formatting"; import { logIssue } from "../../telem/logging"; import { UserInputError } from "../../errors"; -import type { Issue } from "../../api-client/types"; +import type { + ExternalIssue, + Issue, + NativeExternalIssue, +} from "../../api-client/types"; import type { ServerContext } from "../../types"; import { formatLinkedExternalIssue, @@ -621,14 +625,14 @@ async function executeExternalIssueLink( issueId: string; integrationId: string; data: Record; - }) => Promise; + }) => Promise; createSentryAppExternalIssueLink: (params: { installationUuid: string; issueId: number; webUrl: string; project: string; identifier: string; - }) => Promise; + }) => Promise; }, params: { organizationSlug: string; @@ -648,7 +652,7 @@ async function executeExternalIssueLink( kind: "native", issue, provider: params.target.integration.provider.key, - } as LinkedExternalIssue; + }; } const numericIssueId = Number(params.currentIssue.id); @@ -667,7 +671,7 @@ async function executeExternalIssueLink( kind: "sentryApp", issue, provider: params.target.installation.app.slug, - } as LinkedExternalIssue; + }; } async function tryPostReasonComment( From 3851b4373100a14c460d2d7faf8b16a39535aeca Mon Sep 17 00:00:00 2001 From: David Cramer Date: Fri, 29 May 2026 20:40:09 -0700 Subject: [PATCH 15/17] fix(issue): Mark partial link failures as errors Return an error-shaped tool result when update_issue succeeds on the issue update but fails to link the external issue. This lets MCP clients detect the partial failure programmatically while preserving the partial-success message. Co-Authored-By: OpenAI GPT-5 Codex --- .../src/tools/catalog/update-issue.test.ts | 17 ++++++++++++++--- .../mcp-core/src/tools/catalog/update-issue.ts | 1 + 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/mcp-core/src/tools/catalog/update-issue.test.ts b/packages/mcp-core/src/tools/catalog/update-issue.test.ts index 9e78241a..24954082 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.test.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.test.ts @@ -22,12 +22,23 @@ function createIssue(overrides: Partial = {}): MockIssue { } function getTextToolResult(result: ToolHandlerResult): string { + return getToolResultText(result, false); +} + +function getErrorToolResult(result: ToolHandlerResult): string { + return getToolResultText(result, true); +} + +function getToolResultText( + result: ToolHandlerResult, + expectedIsError: boolean, +): string { expect(typeof result).toBe("object"); expect(result).not.toBeNull(); expect(Array.isArray(result)).toBe(false); const toolResult = result as ToolResult; - expect(toolResult.isError).not.toBe(true); + expect(toolResult.isError === true).toBe(expectedIsError); expect(toolResult.content).toHaveLength(1); const content = toolResult.content[0]; @@ -1126,7 +1137,7 @@ describe("update_issue", () => { ), ); - const result = getTextToolResult( + const result = getErrorToolResult( await updateIssue.handler( { organizationSlug: "sentry-mcp-evals", @@ -1159,7 +1170,7 @@ describe("update_issue", () => { ), ); - const result = getTextToolResult( + const result = getErrorToolResult( await updateIssue.handler( { organizationSlug: "sentry-mcp-evals", diff --git a/packages/mcp-core/src/tools/catalog/update-issue.ts b/packages/mcp-core/src/tools/catalog/update-issue.ts index 7f44c2a9..ac7b7cfd 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.ts @@ -965,6 +965,7 @@ export default defineTool({ output += "\n## Response Notes\n\n"; output += `- Full issue details: \`get_sentry_resource(resourceType="issue", organizationSlug="${orgSlug}", resourceId="${updatedIssue.shortId}")\`\n`; return { + isError: true, content: [ { type: "text", From d6fd8843eace56581e3deeb27d0c083422d101ef Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:41:27 +0000 Subject: [PATCH 16/17] fix(update-issue): drop isError from partial-success link failure path Returning isError: true when the Sentry issue update already succeeded signals a full tool failure to MCP clients, which may retry the call and re-apply the already-committed status/assignment change. Drop isError from the partial-success return; the text content clearly describes the partial failure. Update the two affected tests to assert isError is absent and use getTextToolResult instead of getErrorToolResult. --- .../src/tools/catalog/update-issue.test.ts | 54 +++++++++---------- .../src/tools/catalog/update-issue.ts | 1 - 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/packages/mcp-core/src/tools/catalog/update-issue.test.ts b/packages/mcp-core/src/tools/catalog/update-issue.test.ts index 24954082..fe5dd86c 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.test.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.test.ts @@ -1137,20 +1137,20 @@ describe("update_issue", () => { ), ); - const result = getErrorToolResult( - await updateIssue.handler( - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-41", - status: "resolved", - externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", - assignedTo: undefined, - issueUrl: undefined, - regionUrl: null, - }, - serverContext, - ), + const raw = await updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status: "resolved", + externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + }, + serverContext, ); + expect(raw).not.toHaveProperty("isError"); + const result = getTextToolResult(raw); expect(result).toContain("Partially Updated"); expect(result).toContain("The Sentry issue update succeeded."); @@ -1170,21 +1170,21 @@ describe("update_issue", () => { ), ); - const result = getErrorToolResult( - await updateIssue.handler( - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-41", - status: "resolved", - externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", - reason: "Fixing in linked ticket", - assignedTo: undefined, - issueUrl: undefined, - regionUrl: null, - }, - serverContext, - ), + const raw = await updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status: "resolved", + externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", + reason: "Fixing in linked ticket", + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + }, + serverContext, ); + expect(raw).not.toHaveProperty("isError"); + const result = getTextToolResult(raw); expect(result).toContain("Partially Updated"); expect(result).toContain("The Sentry issue update succeeded."); diff --git a/packages/mcp-core/src/tools/catalog/update-issue.ts b/packages/mcp-core/src/tools/catalog/update-issue.ts index ac7b7cfd..7f44c2a9 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.ts @@ -965,7 +965,6 @@ export default defineTool({ output += "\n## Response Notes\n\n"; output += `- Full issue details: \`get_sentry_resource(resourceType="issue", organizationSlug="${orgSlug}", resourceId="${updatedIssue.shortId}")\`\n`; return { - isError: true, content: [ { type: "text", From 3f877235f4b3a3a1249d9a77c5edc3237076e368 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 16 Jun 2026 09:51:41 -0700 Subject: [PATCH 17/17] fix(issue-linking): Address rebase review findings Harden external issue linking after rebasing onto main. Reject ambiguous native integrations before mutating issues, keep Sentry App issue ids as validated strings, and avoid logging expected user/API link failures to Sentry. Move issue-linking support code out of the tool catalog, tighten mocks and tests, and remove forward-looking OpenSpec wording that is not part of the current linking behavior. Co-Authored-By: GPT-5 Codex --- openspec/changes/issue-linking/design.md | 15 ------ openspec/changes/issue-linking/proposal.md | 5 +- .../issue-linking/specs/issue-linking/spec.md | 11 ---- openspec/changes/issue-linking/tasks.md | 1 - .../mcp-core/src/api-client/client.test.ts | 10 +++- packages/mcp-core/src/api-client/client.ts | 2 +- packages/mcp-core/src/skillDefinitions.json | 2 +- packages/mcp-core/src/toolDefinitions.json | 2 +- .../src/tools/catalog/update-issue.test.ts | 35 ++++++------ .../src/tools/catalog/update-issue.ts | 30 +++++++---- .../issue-linking/index.test.ts} | 50 +++++++++++------ .../issue-linking/index.ts} | 54 ++++++++++++------- packages/mcp-server-mocks/src/index.ts | 10 +++- 13 files changed, 127 insertions(+), 100 deletions(-) rename packages/mcp-core/src/tools/{catalog/issue-linking.test.ts => support/issue-linking/index.test.ts} (91%) rename packages/mcp-core/src/tools/{catalog/issue-linking.ts => support/issue-linking/index.ts} (93%) diff --git a/openspec/changes/issue-linking/design.md b/openspec/changes/issue-linking/design.md index 894c7302..afcec867 100644 --- a/openspec/changes/issue-linking/design.md +++ b/openspec/changes/issue-linking/design.md @@ -18,7 +18,6 @@ The implementation must not use the Sentry App endpoint as a universal Jira/GitH - Prefer native integration linking when the target provider is a native issue-tracking integration. - Support Sentry App/platform links when the target is an installed Sentry App, including Linear and Shortcut-style URLs. - Keep the tool API minimal: link by URL only. -- Keep provider discovery, URL parsing, and payload construction reusable for future external issue creation. - Validate all link preconditions before mutating the Sentry issue. - Keep user-facing errors actionable and avoid exposing native integration ids, installation UUIDs, or provider form fields unless needed for diagnostics. @@ -43,20 +42,6 @@ Rationale: the expected user workflow is "link this Sentry issue to this externa Alternative considered: exposing `externalIssueIntegrationId`, `externalIssueIdentifier`, `externalIssueProject`, `externalIssueFields`, and `externalIssueKind`. This is more flexible, but it leaks Sentry internals into the MCP API and makes common linking harder for agents. The implementation can still use these concepts internally. -### Separate Link Semantics From Future Create Semantics - -Name helper types and API client methods around external issue operations rather than around one provider form. Suggested internal boundaries: - -- `parseExternalIssueUrl(url)` returns provider, host/account context, and issue identity for linking. -- `resolveExternalIssueTarget(...)` resolves native integration id or Sentry App installation UUID. -- `buildExternalIssueLinkPayload(...)` creates the internal native or platform link payload. - -Do not add creation parameters in this change. Future creation should be a separate action with its own explicit inputs, likely `externalIssueProvider`, optional `externalIssueProject`, optional `externalIssueTitle`, and optional `externalIssueDescription`, because creation does not have an existing URL to parse. - -Rationale: linking and creating share provider discovery, but they do not share the same user input model. Linking starts from a canonical external URL. Creating starts from desired ticket metadata and provider defaults. - -Alternative considered: add an `externalIssueAction` parameter now with `link` as the only supported value. That is unnecessary API surface until creation exists. - ### Resolve Native Integrations Through Sentry's Group Integration List For native linking, call `GET /organizations/{org}/issues/{issue}/integrations/` to list issue-capable integrations and existing links. Resolve to one integration by: diff --git a/openspec/changes/issue-linking/proposal.md b/openspec/changes/issue-linking/proposal.md index 24ef19f8..cbe62a01 100644 --- a/openspec/changes/issue-linking/proposal.md +++ b/openspec/changes/issue-linking/proposal.md @@ -9,7 +9,6 @@ Users can inspect linked external issues through MCP today, but they cannot link - Support Sentry App/platform external links, including Linear and Shortcut-style app links, through the Sentry App external issue endpoint. - Keep the user-facing API minimal for linking: require only `externalIssueUrl`. - Resolve the target integration from URL shape, installed native integrations, Sentry App installations, and Sentry's own link configuration. Do not expose native integration ids, Sentry App installation UUIDs, provider hints, or provider form fields as normal user-facing parameters. -- Structure the implementation around an internal external-issue action resolver so future ticket creation can reuse provider discovery without expanding the link API. - Fail before mutating the Sentry issue when the requested external link target is ambiguous, unavailable, or missing required fields. - Report partial success clearly when a Sentry status/assignment update succeeds but the subsequent external link operation fails. - Keep this in `update_issue`; do not add a new MCP tool. @@ -27,9 +26,9 @@ Users can inspect linked external issues through MCP today, but they cannot link ## Impact - `packages/mcp-core/src/tools/update-issue.ts`: new minimal link input parameters, provider URL parsing, validation, execution ordering, and response formatting. -- `packages/mcp-core/src/api-client/client.ts`, `schema.ts`, and `types.ts`: client methods and schemas for integration issue config/linking and Sentry App installation/linking APIs, named so create-ticket APIs can be added alongside them later. +- `packages/mcp-core/src/api-client/client.ts`, `schema.ts`, and `types.ts`: client methods and schemas for integration issue config/linking and Sentry App installation/linking APIs. - `packages/mcp-core/src/tools/update-issue.test.ts` and API client tests: coverage for link-only, combined update-and-link, ambiguity, validation, and partial-failure behavior. -- `packages/mcp-server-mocks/src/index.ts`: mock responses for native integration linking and Sentry App external issue creation. +- `packages/mcp-server-mocks/src/index.ts`: mock responses for native integration linking and Sentry App external issue links. - Generated tool definitions after schema/description changes. - Upstream Sentry APIs used by this change: - `GET /api/0/organizations/{org}/issues/{issue}/integrations/{integration_id}/?action=link` diff --git a/openspec/changes/issue-linking/specs/issue-linking/spec.md b/openspec/changes/issue-linking/specs/issue-linking/spec.md index 9f849009..b0aade6e 100644 --- a/openspec/changes/issue-linking/specs/issue-linking/spec.md +++ b/openspec/changes/issue-linking/specs/issue-linking/spec.md @@ -107,14 +107,3 @@ The `update_issue` tool SHALL report linked external issue changes consistently #### Scenario: Link result lacks display fields - **WHEN** the external link API omits optional display fields - **THEN** the response falls back to the requested external issue identifier and URL - -### Requirement: Future External Issue Creation Compatibility -The issue-linking implementation SHALL keep provider resolution and payload construction separate from the public link input schema so future external issue creation can reuse provider discovery without changing link behavior. - -#### Scenario: Link API remains URL-based -- **WHEN** future ticket creation support is added -- **THEN** existing link calls using `externalIssueUrl` continue to link existing external issues without requiring creation-specific parameters - -#### Scenario: Provider resolution is reusable -- **WHEN** future ticket creation support needs to choose a native integration or Sentry App installation -- **THEN** it can reuse the provider/app discovery helpers introduced for issue linking without depending on link-only URL payload construction diff --git a/openspec/changes/issue-linking/tasks.md b/openspec/changes/issue-linking/tasks.md index e56d0dd8..6c08ea75 100644 --- a/openspec/changes/issue-linking/tasks.md +++ b/openspec/changes/issue-linking/tasks.md @@ -12,7 +12,6 @@ - [x] 2.3 Implement Sentry App installation resolution from parsed URL without exposing UUIDs in errors. - [x] 2.4 Implement native link payload construction from URL parser output and Sentry link config defaults. - [x] 2.5 Implement Sentry App payload construction from URL parser output. -- [x] 2.6 Keep provider/app resolution helpers independent from link payload construction so future ticket creation can reuse them. - [x] 2.7 Add focused helper tests for provider parsing, native resolution, Sentry App resolution, ambiguity, unsupported URL shapes, and helper separation. ## 3. `update_issue` Tool diff --git a/packages/mcp-core/src/api-client/client.test.ts b/packages/mcp-core/src/api-client/client.test.ts index 1616ae7f..e8456cd3 100644 --- a/packages/mcp-core/src/api-client/client.test.ts +++ b/packages/mcp-core/src/api-client/client.test.ts @@ -140,6 +140,12 @@ describe("getTraceUrl", () => { }); describe("external issue linking API methods", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + function mockJsonResponse(body: unknown) { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, @@ -307,7 +313,7 @@ describe("external issue linking API methods", () => { const result = await apiService.createSentryAppExternalIssueLink({ installationUuid: "install-uuid", - issueId: 123, + issueId: "123", webUrl: "https://linear.app/acme/issue/ENG-123/test", project: "ENG", identifier: "ENG-123", @@ -318,7 +324,7 @@ describe("external issue linking API methods", () => { expect.objectContaining({ method: "POST", body: JSON.stringify({ - issueId: 123, + issueId: "123", webUrl: "https://linear.app/acme/issue/ENG-123/test", project: "ENG", identifier: "ENG-123", diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index e1c92714..498a0c4c 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -2962,7 +2962,7 @@ export class SentryApiService { identifier, }: { installationUuid: string; - issueId: number; + issueId: string; webUrl: string; project: string; identifier: string; diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 8044094c..876aff0a 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -316,7 +316,7 @@ { "name": "update_issue", "description": "Update a Sentry issue.\n\nUse this to resolve, reopen, assign, ignore, comment on, or link an existing external issue.\n\n\n```\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='resolved')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', assignedTo='user:123456')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='untilOccurrenceCount', ignoreCount=100, ignoreWindowMinutes=60)\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', externalIssueUrl='https://github.com/getsentry/sentry/issues/123')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', externalIssueUrl='https://linear.app/acme/issue/ENG-123/test')\n```\n\n\n\n- Provide `issueUrl` or `organizationSlug` + `issueId`.\n- At least one of `status`, `assignedTo`, `externalIssueUrl`, or `reason` is required.\n- `assignedTo`: `user:ID`, `team:ID_OR_SLUG`, or `me`.\n- `externalIssueUrl` links an existing external issue; it does not create a new ticket.\n- Use `execute_sentry_tool(name='whoami', arguments={})` to find your user ID for self-assignment.\n- Status values: `resolved`, `resolvedInNextRelease`, `unresolved`, `ignored`.\n- Ignore modes: `untilEscalating` (default), `forever`, `forDuration`, `untilOccurrenceCount`, `untilUserCount`.\n- Ignore inputs: `ignoreDurationMinutes`, `ignoreCount` + optional `ignoreWindowMinutes`, or `ignoreUserCount` + optional `ignoreUserWindowMinutes`.\n- To switch ignore families on an already ignored issue, first set `status='unresolved'`, then ignore it again.\n", - "requiredScopes": ["event:write"] + "requiredScopes": ["event:write", "org:read"] }, { "name": "whoami", diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index dc7eb86a..4817e6dd 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -2226,7 +2226,7 @@ "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#" }, - "requiredScopes": ["event:write"], + "requiredScopes": ["event:write", "org:read"], "skills": ["triage"], "surface": "direct" }, diff --git a/packages/mcp-core/src/tools/catalog/update-issue.test.ts b/packages/mcp-core/src/tools/catalog/update-issue.test.ts index fe5dd86c..9d1096b5 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.test.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.test.ts @@ -1015,13 +1015,13 @@ describe("update_issue", () => { "https://sentry.io/api/0/sentry-app-installations/shortcut-installation-uuid/external-issues/", async ({ request }) => { const body = (await request.json()) as { - issueId: number; + issueId: string; webUrl: string; identifier: string; }; return HttpResponse.json({ id: "platform-external-issue-shortcut", - issueId: String(body.issueId), + issueId: body.issueId, serviceType: "shortcut", displayName: body.identifier, webUrl: body.webUrl, @@ -1077,7 +1077,7 @@ describe("update_issue", () => { expect(updateCalled).toBe(false); }); - it("uses the first matching integration when multiple candidates exist", async () => { + it("rejects ambiguous native integrations before updating the issue", async () => { let updateCalled = false; mswServer.use( http.get( @@ -1109,20 +1109,21 @@ describe("update_issue", () => { ), ); - const result = await updateIssue.handler( - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-41", - status: "resolved", - externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", - assignedTo: undefined, - issueUrl: undefined, - regionUrl: null, - }, - serverContext, - ); - expect(updateCalled).toBe(true); - expect(result).toContain("CLOUDFLARE-MCP-41"); + await expect( + updateIssue.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status: "resolved", + externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", + assignedTo: undefined, + issueUrl: undefined, + regionUrl: null, + }, + serverContext, + ), + ).rejects.toThrow("Multiple installed github issue integrations"); + expect(updateCalled).toBe(false); }); it("reports partial success when link write fails after issue update", async () => { diff --git a/packages/mcp-core/src/tools/catalog/update-issue.ts b/packages/mcp-core/src/tools/catalog/update-issue.ts index 7f44c2a9..74911462 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.ts @@ -8,6 +8,7 @@ import { import { formatAssignedTo } from "../../internal/tool-helpers/formatting"; import { logIssue } from "../../telem/logging"; import { UserInputError } from "../../errors"; +import { ApiClientError } from "../../api-client"; import type { ExternalIssue, Issue, @@ -19,7 +20,7 @@ import { resolveExternalIssueLinkTarget, type ExternalIssueLinkTarget, type LinkedExternalIssue, -} from "./issue-linking"; +} from "../support/issue-linking"; import { ParamOrganizationSlug, ParamRegionUrl, @@ -628,7 +629,7 @@ async function executeExternalIssueLink( }) => Promise; createSentryAppExternalIssueLink: (params: { installationUuid: string; - issueId: number; + issueId: string; webUrl: string; project: string; identifier: string; @@ -645,32 +646,34 @@ async function executeExternalIssueLink( const issue = await apiService.linkNativeExternalIssue({ organizationSlug: params.organizationSlug, issueId: params.issueId, - integrationId: String(params.target.integration.id), + integrationId: params.target.integrationId, data: params.target.payload, }); return { kind: "native", issue, - provider: params.target.integration.provider.key, + provider: params.target.provider, + fallbackDisplayName: params.target.fallbackDisplayName, + fallbackUrl: params.target.fallbackUrl, }; } - const numericIssueId = Number(params.currentIssue.id); - if (!Number.isFinite(numericIssueId)) { + const sentryIssueId = String(params.currentIssue.id); + if (!/^\d+$/.test(sentryIssueId)) { throw new UserInputError( "Cannot link Sentry App external issue because the Sentry issue id is not numeric.", ); } const issue = await apiService.createSentryAppExternalIssueLink({ - installationUuid: params.target.installation.uuid, - issueId: numericIssueId, + installationUuid: params.target.installationUuid, + issueId: sentryIssueId, ...params.target.payload, }); return { kind: "sentryApp", issue, - provider: params.target.installation.app.slug, + provider: params.target.provider, }; } @@ -724,7 +727,7 @@ function formatReasonCommentLine( export default defineTool({ name: "update_issue", skills: ["triage"], // Only available in triage skill - requiredScopes: ["event:write"], + requiredScopes: ["event:write", "org:read"], description: [ "Update a Sentry issue.", "", @@ -948,7 +951,12 @@ export default defineTool({ if (!hasIssueUpdate) { throw error; } - logIssue(error); + if ( + !(error instanceof UserInputError) && + !(error instanceof ApiClientError) + ) { + logIssue(error); + } const partialCommentResult = await tryPostReasonComment( apiService, orgSlug, diff --git a/packages/mcp-core/src/tools/catalog/issue-linking.test.ts b/packages/mcp-core/src/tools/support/issue-linking/index.test.ts similarity index 91% rename from packages/mcp-core/src/tools/catalog/issue-linking.test.ts rename to packages/mcp-core/src/tools/support/issue-linking/index.test.ts index b2878e78..35a5ab7c 100644 --- a/packages/mcp-core/src/tools/catalog/issue-linking.test.ts +++ b/packages/mcp-core/src/tools/support/issue-linking/index.test.ts @@ -1,14 +1,11 @@ import { describe, expect, it } from "vitest"; -import { UserInputError } from "../../errors"; -import { - parseExternalIssueUrl, - resolveExternalIssueLinkTarget, -} from "./issue-linking"; +import { UserInputError } from "../../../errors"; +import { parseExternalIssueUrl, resolveExternalIssueLinkTarget } from "."; import type { IssueIntegration, IssueIntegrationLinkConfig, SentryAppInstallation, -} from "../../api-client/types"; +} from "../../../api-client/types"; function integration( overrides: Partial = {}, @@ -159,7 +156,7 @@ describe("resolveExternalIssueLinkTarget", () => { expect(target).toMatchObject({ kind: "native", - integration: { id: "1" }, + integrationId: "1", payload: { repo: "getsentry/sentry", externalIssue: "123", @@ -210,7 +207,7 @@ describe("resolveExternalIssueLinkTarget", () => { expect(target).toMatchObject({ kind: "native", - integration: { id: "2" }, + integrationId: "2", payload: { project: "getsentry/backend/service", externalIssue: "getsentry/backend/service#123", @@ -240,7 +237,7 @@ describe("resolveExternalIssueLinkTarget", () => { expect(target).toMatchObject({ kind: "native", - integration: { id: "2" }, + integrationId: "2", }); }); @@ -276,7 +273,7 @@ describe("resolveExternalIssueLinkTarget", () => { expect(target).toMatchObject({ kind: "native", - integration: { id: "2" }, + integrationId: "2", payload: { externalIssue: "OPS-456", }, @@ -315,7 +312,7 @@ describe("resolveExternalIssueLinkTarget", () => { expect(target).toMatchObject({ kind: "native", - integration: { id: "2" }, + integrationId: "2", payload: { externalIssue: "42", }, @@ -368,7 +365,7 @@ describe("resolveExternalIssueLinkTarget", () => { expect(target).toMatchObject({ kind: "native", - integration: { id: "1" }, + integrationId: "1", payload: { repo: "getsentry/sentry", externalIssue: "123", @@ -434,12 +431,29 @@ describe("resolveExternalIssueLinkTarget", () => { ); }); - it("uses the first matching candidate when multiple integrations match", async () => { + it("rejects ambiguous native integrations", async () => { + await expect( + resolveExternalIssueLinkTarget({ + apiService: { + listIssueIntegrations: async () => [ + integration({ id: "1", name: "GitHub A", domainName: null }), + integration({ id: "2", name: "GitHub B", domainName: null }), + ], + getIssueIntegrationLinkConfig: async () => linkConfig(), + listSentryAppInstallations: async () => [], + }, + organizationSlug: "sentry", + issueId: "PROJ-1", + externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", + }), + ).rejects.toThrow("Multiple installed github issue integrations"); + }); + + it("resolves native integrations to the projected link target", async () => { const target = await resolveExternalIssueLinkTarget({ apiService: { listIssueIntegrations: async () => [ integration({ id: "1", name: "GitHub A", domainName: null }), - integration({ id: "2", name: "GitHub B", domainName: null }), ], getIssueIntegrationLinkConfig: async () => linkConfig(), listSentryAppInstallations: async () => [], @@ -451,7 +465,10 @@ describe("resolveExternalIssueLinkTarget", () => { expect(target).toMatchObject({ kind: "native", - integration: { id: "1" }, + integrationId: "1", + provider: "github", + fallbackDisplayName: "123", + fallbackUrl: "https://github.com/getsentry/sentry/issues/123", payload: { repo: "getsentry/sentry", externalIssue: "123", @@ -475,7 +492,8 @@ describe("resolveExternalIssueLinkTarget", () => { expect(target).toMatchObject({ kind: "sentryApp", - installation: { uuid: "linear-installation" }, + installationUuid: "linear-installation", + provider: "linear", payload: { webUrl: "https://linear.app/acme/issue/ENG-123/test", project: "ENG", diff --git a/packages/mcp-core/src/tools/catalog/issue-linking.ts b/packages/mcp-core/src/tools/support/issue-linking/index.ts similarity index 93% rename from packages/mcp-core/src/tools/catalog/issue-linking.ts rename to packages/mcp-core/src/tools/support/issue-linking/index.ts index 849478a6..18c752f9 100644 --- a/packages/mcp-core/src/tools/catalog/issue-linking.ts +++ b/packages/mcp-core/src/tools/support/issue-linking/index.ts @@ -1,11 +1,11 @@ -import { UserInputError } from "../../errors"; +import { UserInputError } from "../../../errors"; import type { ExternalIssue, IssueIntegration, IssueIntegrationLinkConfig, NativeExternalIssue, SentryAppInstallation, -} from "../../api-client/types"; +} from "../../../api-client/types"; type NativeProvider = "jira" | "github" | "gitlab" | "bitbucket" | "vsts"; type AppProvider = "linear" | "shortcut"; @@ -37,21 +37,22 @@ type LinkConfigField = IssueIntegrationLinkConfig["linkIssueConfig"][number]; export type NativeExternalIssueLinkTarget = { kind: "native"; - integration: IssueIntegration; - config: IssueIntegrationLinkConfig; + integrationId: string; + provider: string; + fallbackDisplayName: string; + fallbackUrl: string; payload: Record; - parsed: ParsedNativeIssueUrl; }; export type SentryAppExternalIssueLinkTarget = { kind: "sentryApp"; - installation: SentryAppInstallation; + installationUuid: string; + provider: string; payload: { webUrl: string; project: string; identifier: string; }; - parsed: ParsedAppIssueUrl; }; export type ExternalIssueLinkTarget = @@ -59,7 +60,13 @@ export type ExternalIssueLinkTarget = | SentryAppExternalIssueLinkTarget; export type LinkedExternalIssue = - | { kind: "native"; issue: NativeExternalIssue; provider: string } + | { + kind: "native"; + issue: NativeExternalIssue; + provider: string; + fallbackDisplayName: string; + fallbackUrl: string; + } | { kind: "sentryApp"; issue: ExternalIssue; provider: string }; export type ExternalIssueLinkApi = { @@ -512,15 +519,23 @@ async function resolveNativeTarget(params: { `No installed ${parsed.provider} issue integration can access the project or repository in ${parsed.url}.`, ); } - // When multiple integrations still match, use the first one rather than - // surfacing an ambiguity error — the caller has no way to resolve it and - // any matching candidate should produce a correct link. + if (matchingCandidates.length > 1) { + throw new UserInputError( + `Multiple installed ${parsed.provider} issue integrations match ${parsed.url}: ${matchingCandidates + .map( + ({ integration }) => + `${integration.name} (${integration.provider.key})`, + ) + .join(", ")}.`, + ); + } const [{ integration, config }] = matchingCandidates; return { kind: "native", - integration, - config, - parsed, + integrationId: String(integration.id), + provider: integration.provider.key, + fallbackDisplayName: parsed.issueId, + fallbackUrl: parsed.url, payload: buildNativeLinkPayload(parsed, config), }; } @@ -560,8 +575,8 @@ async function resolveSentryAppTarget(params: { return { kind: "sentryApp", - installation: candidates[0], - parsed, + installationUuid: candidates[0].uuid, + provider: candidates[0].app.slug, payload: { webUrl: parsed.url, project: parsed.project, @@ -598,7 +613,8 @@ export function formatLinkedExternalIssue(linked: LinkedExternalIssue): string { if (linked.kind === "sentryApp") { return `${linked.issue.displayName || linked.issue.issueId} (${linked.issue.serviceType || linked.provider}) → ${linked.issue.webUrl}`; } - const displayName = linked.issue.displayName || linked.issue.key; - const url = linked.issue.url ? ` → ${linked.issue.url}` : ""; - return `${displayName} (${linked.provider})${url}`; + const displayName = + linked.issue.displayName || linked.issue.key || linked.fallbackDisplayName; + const url = linked.issue.url || linked.fallbackUrl; + return `${displayName} (${linked.provider}) → ${url}`; } diff --git a/packages/mcp-server-mocks/src/index.ts b/packages/mcp-server-mocks/src/index.ts index e841f4d2..5b45865a 100644 --- a/packages/mcp-server-mocks/src/index.ts +++ b/packages/mcp-server-mocks/src/index.ts @@ -1234,11 +1234,17 @@ export const restHandlers = buildHandlers([ repo?: string; externalIssue?: string; }; - const key = `${body.repo ?? "getsentry/sentry"}#${body.externalIssue ?? "123"}`; + if (!body.repo || !body.externalIssue) { + return HttpResponse.json( + { detail: "repo and externalIssue are required" }, + { status: 400 }, + ); + } + const key = `${body.repo}#${body.externalIssue}`; return HttpResponse.json({ id: "external-issue-1", key, - url: `https://github.com/${body.repo ?? "getsentry/sentry"}/issues/${body.externalIssue ?? "123"}`, + url: `https://github.com/${body.repo}/issues/${body.externalIssue}`, integrationId: "github-integration-1", displayName: key, });