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..afcec867 --- /dev/null +++ b/openspec/changes/issue-linking/design.md @@ -0,0 +1,121 @@ +## 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. +- 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. + +### 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..cbe62a01 --- /dev/null +++ b/openspec/changes/issue-linking/proposal.md @@ -0,0 +1,37 @@ +## 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. +- 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. +- `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 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` + - `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..b0aade6e --- /dev/null +++ b/openspec/changes/issue-linking/specs/issue-linking/spec.md @@ -0,0 +1,109 @@ +## 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 diff --git a/openspec/changes/issue-linking/tasks.md b/openspec/changes/issue-linking/tasks.md new file mode 100644 index 00000000..6c08ea75 --- /dev/null +++ b/openspec/changes/issue-linking/tasks.md @@ -0,0 +1,41 @@ +## 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.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..e8456cd3 100644 --- a/packages/mcp-core/src/api-client/client.test.ts +++ b/packages/mcp-core/src/api-client/client.test.ts @@ -139,6 +139,202 @@ 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, + 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..498a0c4c 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: string; + 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/server.test.ts b/packages/mcp-core/src/server.test.ts index 5e65a1f4..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"; @@ -192,6 +192,59 @@ describe("buildServer", () => { ip_address: "192.0.2.1", }); }); + + 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: { + 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, + }); + expect(setStatus).toHaveBeenCalledWith({ code: 2 }); + }); }); describe("experimental tool filtering", () => { 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}`); diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index f64da3ad..876aff0a 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -315,8 +315,8 @@ }, { "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", - "requiredScopes": ["event:write"] + "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", "org:read"] }, { "name": "whoami", diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 77d4f63c..4817e6dd 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": [ @@ -2221,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/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 bb47b06d..9d1096b5 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,35 @@ 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 === true).toBe(expectedIsError); + 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(); }); @@ -755,10 +785,415 @@ 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: string; + webUrl: string; + identifier: string; + }; + return HttpResponse.json({ + id: "platform-external-issue-shortcut", + issueId: 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("rejects ambiguous native integrations before updating the issue", 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 github issue 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 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."); + expect(result).toContain("External issue linking failed"); + 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 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."); + 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( @@ -942,6 +1377,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 2ed9d149..74911462 100644 --- a/packages/mcp-core/src/tools/catalog/update-issue.ts +++ b/packages/mcp-core/src/tools/catalog/update-issue.ts @@ -8,13 +8,25 @@ 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 { ApiClientError } from "../../api-client"; +import type { + ExternalIssue, + Issue, + NativeExternalIssue, +} from "../../api-client/types"; import type { ServerContext } from "../../types"; +import { + formatLinkedExternalIssue, + resolveExternalIssueLinkTarget, + type ExternalIssueLinkTarget, + type LinkedExternalIssue, +} from "../support/issue-linking"; import { ParamOrganizationSlug, ParamRegionUrl, ParamIssueShortId, ParamIssueUrl, + ParamExternalIssueUrl, ParamIssueStatus, ParamIssueIgnoreMode, ParamAssignedTo, @@ -337,6 +349,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, @@ -568,6 +619,64 @@ 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: string; + 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: params.target.integrationId, + data: params.target.payload, + }); + return { + kind: "native", + issue, + provider: params.target.provider, + fallbackDisplayName: params.target.fallbackDisplayName, + fallbackUrl: params.target.fallbackUrl, + }; + } + + 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.installationUuid, + issueId: sentryIssueId, + ...params.target.payload, + }); + return { + kind: "sentryApp", + issue, + provider: params.target.provider, + }; +} + async function tryPostReasonComment( apiService: { createIssueComment: (params: { @@ -618,34 +727,33 @@ 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'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 +763,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 +797,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 +827,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 +885,7 @@ export default defineTool({ currentIssue.shortId, ); - if (!updateStatus && !updateAssignedTo && !updateIgnore) { + if (!updateStatus && !updateAssignedTo && !updateIgnore && !linkTarget) { const commentResult = await tryPostReasonComment( apiService, orgSlug, @@ -770,6 +893,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, @@ -780,19 +919,69 @@ 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; + } + if ( + !(error instanceof UserInputError) && + !(error instanceof ApiClientError) + ) { + 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 { + content: [ + { + type: "text", + text: output, + }, + ], + }; + } + } const commentResult = await tryPostReasonComment( apiService, @@ -843,6 +1032,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 +1045,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-core/src/tools/support/issue-linking/index.test.ts b/packages/mcp-core/src/tools/support/issue-linking/index.test.ts new file mode 100644 index 00000000..35a5ab7c --- /dev/null +++ b/packages/mcp-core/src/tools/support/issue-linking/index.test.ts @@ -0,0 +1,524 @@ +import { describe, expect, it } from "vitest"; +import { UserInputError } from "../../../errors"; +import { parseExternalIssueUrl, resolveExternalIssueLinkTarget } from "."; +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://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({ + 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); + // GitLab URL missing the /-/ marker must not fall through to the Bitbucket parser + expect(() => + parseExternalIssueUrl("https://gitlab.com/getsentry/sentry/issues/123"), + ).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", + integrationId: "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", + integrationId: "2", + payload: { + project: "getsentry/backend/service", + externalIssue: "getsentry/backend/service#123", + }, + }); + }); + + 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", + integrationId: "2", + }); + }); + + 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", + integrationId: "2", + payload: { + externalIssue: "OPS-456", + }, + }); + }); + + 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", + integrationId: "2", + payload: { + externalIssue: "42", + }, + }); + }); + + 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", + integrationId: "1", + payload: { + repo: "getsentry/sentry", + externalIssue: "123", + }, + }); + }); + + it("ignores integrations whose configured domain does not match the URL", async () => { + let configCalled = false; + await expect( + resolveExternalIssueLinkTarget({ + apiService: { + listIssueIntegrations: async () => [ + integration({ + id: "1", + name: "Jira Cloud", + domainName: "other.atlassian.net", + provider: { key: "jira" }, + }), + ], + 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 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("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 }), + ], + getIssueIntegrationLinkConfig: async () => linkConfig(), + listSentryAppInstallations: async () => [], + }, + organizationSlug: "sentry", + issueId: "PROJ-1", + externalIssueUrl: "https://github.com/getsentry/sentry/issues/123", + }); + + expect(target).toMatchObject({ + kind: "native", + integrationId: "1", + provider: "github", + fallbackDisplayName: "123", + fallbackUrl: "https://github.com/getsentry/sentry/issues/123", + payload: { + repo: "getsentry/sentry", + externalIssue: "123", + }, + }); + }); + + 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", + installationUuid: "linear-installation", + provider: "linear", + 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/support/issue-linking/index.ts b/packages/mcp-core/src/tools/support/issue-linking/index.ts new file mode 100644 index 00000000..18c752f9 --- /dev/null +++ b/packages/mcp-core/src/tools/support/issue-linking/index.ts @@ -0,0 +1,620 @@ +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"; + integrationId: string; + provider: string; + fallbackDisplayName: string; + fallbackUrl: string; + payload: Record; +}; + +export type SentryAppExternalIssueLinkTarget = { + kind: "sentryApp"; + installationUuid: string; + provider: string; + payload: { + webUrl: string; + project: string; + identifier: string; + }; +}; + +export type ExternalIssueLinkTarget = + | NativeExternalIssueLinkTarget + | SentryAppExternalIssueLinkTarget; + +export type LinkedExternalIssue = + | { + kind: "native"; + issue: NativeExternalIssue; + provider: string; + fallbackDisplayName: string; + fallbackUrl: 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 normalizeUrlHost(url: URL): string { + return normalizeHost(url.host); +} + +function normalizeDomain(value?: string | null): string | null { + if (!value) { + return null; + } + const withoutProtocol = value.replace(/^https?:\/\//i, ""); + const withoutWww = withoutProtocol.replace(/^www\./i, ""); + return trimSlashes(withoutWww).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; + } + const host = normalizeUrlHost(url); + return { + kind: "native", + provider: "jira", + url: url.toString(), + host, + domainPath: host, + issueId, + }; +} + +function parseGithubUrl(url: URL): ParsedNativeIssueUrl | null { + const host = normalizeUrlHost(url); + 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 host = normalizeUrlHost(url); + 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, + domainPath: `${host}/${project}`, + project, + issueId, + }; +} + +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; + } + const issueId = parseIssueNumber(segments[3] ?? ""); + if (!issueId) { + return null; + } + const repo = `${segments[0]}/${segments[1]}`; + return { + kind: "native", + provider: "bitbucket", + url: url.toString(), + host, + domainPath: `${host}/${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; + } + const host = normalizeUrlHost(url); + const domainPath = + normalizeHost(url.hostname) === "dev.azure.com" && segments[0] + ? `${host}/${segments[0]}` + : host; + return { + kind: "native", + provider: "vsts", + url: url.toString(), + host, + domainPath, + 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<(url: URL) => ParsedNativeIssueUrl | null> = [ + parseJiraUrl, + parseGithubUrl, + parseGitlabUrl, + parseBitbucketUrl, + parseVstsUrl, + ]; + for (const parse of nativeParsers) { + const parsed = parse(url); + if (parsed) { + return parsed; + } + } + + const appParsers: Array<(url: URL) => ParsedAppIssueUrl | null> = [ + parseLinearUrl, + parseShortcutUrl, + ]; + for (const parse of appParsers) { + const parsed = parse(url); + 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 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( + 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( + `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.`, + ); + } + + if (bestDomainScore > 0) { + 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), + ); + 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( + 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 ${parsed.provider} issue integrations match ${parsed.url}: ${matchingCandidates + .map( + ({ integration }) => + `${integration.name} (${integration.provider.key})`, + ) + .join(", ")}.`, + ); + } + const [{ integration, config }] = matchingCandidates; + return { + kind: "native", + integrationId: String(integration.id), + provider: integration.provider.key, + fallbackDisplayName: parsed.issueId, + fallbackUrl: parsed.url, + 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", + installationUuid: candidates[0].uuid, + provider: candidates[0].app.slug, + 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 || linked.fallbackDisplayName; + const url = linked.issue.url || linked.fallbackUrl; + return `${displayName} (${linked.provider}) → ${url}`; +} 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. diff --git a/packages/mcp-server-mocks/src/index.ts b/packages/mcp-server-mocks/src/index.ts index 113c23f1..5b45865a 100644 --- a/packages/mcp-server-mocks/src/index.ts +++ b/packages/mcp-server-mocks/src/index.ts @@ -1160,6 +1160,135 @@ 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; + }; + 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}/issues/${body.externalIssue}`, + 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",