diff --git a/docs/specs/README.md b/docs/specs/README.md index df96b20be..469af5633 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -8,6 +8,7 @@ server. Each spec should live in a single Markdown file under `docs/specs/`. - [Agent Conversations](ai-conversations.md) - [Alert Inspection and Editing](alert-rules.md) - [Embedded Agent OpenAI Routing](embedded-agent-openai-routing.md) +- [External Issue Linking](issue-linking.md) - [Project Management Tools](project-management.md) - [Remembered OAuth Skill Defaults](remembered-oauth-skills.md) - [Search Events](search-events.md) diff --git a/docs/specs/issue-linking.md b/docs/specs/issue-linking.md new file mode 100644 index 000000000..2a1bbc649 --- /dev/null +++ b/docs/specs/issue-linking.md @@ -0,0 +1,75 @@ +# External Issue Linking + +`link_issue` and `unlink_issue` manage references between an existing Sentry issue +and an existing external ticket or GitHub pull request. They are catalog-only +tools, discovered through `search_sentry_tools` and called through +`execute_sentry_tool`. Both require the `triage` skill and `event:write` and +`org:read` scopes. No `event:admin` grant is added. + +## Interface + +```ts +execute_sentry_tool({ + name: "link_issue", + arguments: { + organizationSlug: "my-org", + issueId: "PROJECT-123", + externalIssueUrl: "https://github.com/example/repo/pull/42", + }, +}); +``` + +Use `unlink_issue` with the same arguments to remove the association. Either +tool accepts `issueUrl` instead of `organizationSlug` and `issueId`, and an +optional `regionUrl`. Session organization, project, and region constraints +still apply. The source issue is resolved before any mutation; subsequent API +calls use its numeric ID. + +Native integrations are selected by the URL and installed integration metadata. +An optional `integrationId` disambiguates multiple matching installations. Sentry +receives the complete URL and performs provider-specific parsing and validation +for Jira, GitHub/GitHub Enterprise, GitLab, Bitbucket, and Azure DevOps. + +For Sentry Apps, `appSlug` selects the installed App; Linear and Shortcut are +inferred from their URLs. `link_issue` accepts optional `fields` for additional +values required by the App's issue-link form. The client reads the installed +component and resolves its form choices before invoking its link callback. +Missing or ambiguous fields are reported without submitting the callback. +Supported fields are single-value selects, text, and textarea. Other field types +and multi-select fields are reported as unsupported. + +## Outcomes and retries + +Results contain the Sentry issue ID and URL, the external reference, and a status: + +- `linked`: the backend created the association (HTTP 201). +- `already_linked`: the backend returned the existing association (HTTP 200). +- `not_linked`: the association is absent after unlink. This does not claim + which concurrent request removed it. + +App requests include `expectedExternalIssueUrl` as a query parameter. The URL +must exactly match the canonical `webUrl` returned by the App. Copy the URL from +the provider; a different title suffix or URL alias is not necessarily accepted. +An existing equivalent reference uses its stored URL for the guard. A different +App association must be explicitly unlinked first; no direct-registration or +unguarded fallback is used. HTTP 409 errors propagate without automatic retries. +If an App callback returns a conflicting URL, its external effects cannot be +rolled back, even though Sentry rejects the association. + +Unlink first finds the association by URL, then deletes using its internal Sentry +ID. It never deletes the external ticket or the Sentry issue. Repeating unlink +when no association exists returns `not_linked`. +App deletion is conditional on the association ID, not its URL. A legacy App +writer can replace the URL while retaining that ID between lookup and deletion; +preventing that race would require a conditional-delete API in Sentry. + +## Boundaries + +GitHub pull requests are external references here. These tools do not create +tickets, link commits, resolve issues, or change assignment. `update_issue` +continues to handle status and assignment separately. + +The implementation uses the existing MCP API client, without `@sentry/api`. +It requires the backend's URL linking and guarded App action behavior, including +the HTTP 200/201 contract from getsentry/sentry#124069. Older self-hosted releases +may lack these capabilities; the client does not emulate the missing guarantees. diff --git a/packages/mcp-core/src/api-client/client.test.ts b/packages/mcp-core/src/api-client/client.test.ts index 4924514aa..3c9aa9b54 100644 --- a/packages/mcp-core/src/api-client/client.test.ts +++ b/packages/mcp-core/src/api-client/client.test.ts @@ -197,6 +197,178 @@ describe("getTraceUrl", () => { }); }); +describe("external issue linking API methods", () => { + const organizationSlug = "test-org"; + const issueId = "123"; + const integrationId = "456"; + const externalIssueUrl = "https://github.com/example/project/issues/42"; + const nativeIssue = { + id: 789, + key: "example/project#42", + url: externalIssueUrl, + }; + const appIssue = { + id: "789", + issueId, + serviceType: "linear", + displayName: "ENG-42", + webUrl: "https://linear.app/example/issue/ENG-42/title", + }; + const api = new SentryApiService({ + host: "us.sentry.io", + accessToken: "test-token", + }); + + it("reads every integration page and preserves internal link IDs and provider metadata", async () => { + const integration = { + id: integrationId, + name: "example", + domainName: "github.com/example", + status: "active", + provider: { key: "github" }, + externalIssues: [nativeIssue], + }; + const pages: (string | null)[] = []; + mswServer.use( + http.get( + "https://us.sentry.io/api/0/organizations/test-org/issues/123/integrations/", + ({ request }) => { + const cursor = new URL(request.url).searchParams.get("cursor"); + pages.push(cursor); + return HttpResponse.json( + [{ ...integration, id: cursor ? 457 : integrationId }], + { + headers: cursor + ? {} + : { + Link: '; rel="next"; results="true"; cursor="next-page"', + }, + }, + ); + }, + ), + ); + expect( + await api.listIssueIntegrations({ organizationSlug, issueId }), + ).toEqual([integration, { ...integration, id: 457 }]); + expect(pages).toEqual([null, "next-page"]); + }); + + it("finds App associations beyond the first page", async () => { + mswServer.use( + http.get( + "https://us.sentry.io/api/0/organizations/test-org/issues/123/external-issues/", + ({ request }) => { + const cursor = new URL(request.url).searchParams.get("cursor"); + return HttpResponse.json(cursor ? [appIssue] : [], { + headers: cursor + ? {} + : { + Link: '; rel="next"; results="true"; cursor="next-page"', + }, + }); + }, + ), + ); + expect( + await api.getIssueExternalLinks({ organizationSlug, issueId }), + ).toEqual([appIssue]); + }); + + it("loads App installations and paginated issue-link forms on the control host", async () => { + const installation = { + uuid: "install-uuid", + status: "installed", + app: { slug: "linear", uuid: "app-uuid" }, + }; + const component = { + type: "issue-link", + sentryApp: { slug: "linear", uuid: "app-uuid" }, + schema: { link: { uri: "/link" } }, + }; + const pages: (string | null)[] = []; + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/sentry-app-installations/", + () => HttpResponse.json([installation]), + ), + http.get( + "https://sentry.io/api/0/organizations/test-org/sentry-app-components/", + ({ request }) => { + const query = new URL(request.url).searchParams; + expect(query.get("filter")).toBe("issue-link"); + pages.push(query.get("cursor")); + return HttpResponse.json(query.has("cursor") ? [component] : [], { + headers: query.has("cursor") + ? {} + : { + Link: '; rel="next"; results="true"; cursor="next-page"', + }, + }); + }, + ), + ); + expect(await api.listSentryAppInstallations({ organizationSlug })).toEqual([ + installation, + ]); + expect(await api.listSentryAppComponents({ organizationSlug })).toEqual([ + component, + ]); + expect(pages).toEqual([null, "next-page"]); + }); + + it.each(["tenant.my.sentry.io", "sentry.example.com"])( + "keeps App choices on %s and encodes search dependencies", + async (host) => { + const tenantApi = new SentryApiService({ host }); + mswServer.use( + http.get( + `https://${host}/api/0/sentry-app-installations/install-uuid/external-requests/`, + ({ request }) => { + expect( + Object.fromEntries(new URL(request.url).searchParams), + ).toEqual({ + uri: "/search", + projectId: "42", + query: "ENG-42", + dependentData: JSON.stringify({ team: "ENG" }), + }); + return HttpResponse.json({ choices: [["ticket-uuid", "ENG-42"]] }); + }, + ), + ); + expect( + await tenantApi.getSentryAppExternalRequestOptions({ + installationUuid: "install-uuid", + uri: "/search", + query: "ENG-42", + projectId: "42", + dependentData: { team: "ENG" }, + }), + ).toEqual({ choices: [["ticket-uuid", "ENG-42"]] }); + }, + ); + + it("unlinks an App by internal association ID using the regional endpoint", async () => { + const requests: string[] = []; + mswServer.use( + http.delete( + "https://us.sentry.io/api/0/organizations/test-org/issues/123/external-issues/789/", + () => { + requests.push("app"); + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + await api.unlinkSentryAppExternalIssue({ + organizationSlug, + issueId, + externalIssueId: "789", + }); + expect(requests).toEqual(["app"]); + }); +}); + 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 6530a396f..3b6957e4a 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -61,12 +61,14 @@ import { EventSchema, EventsStatsResponseSchema, ExternalIssueListSchema, + ExternalIssueSchema, FlamegraphSchema, IssueActivityListResponseSchema, IssueAlertRuleListSchema, IssueAlertRuleSchema, IssueCommentListSchema, IssueCommentSchema, + IssueIntegrationListSchema, IssueListSchema, IssueSchema, IssueTagValuesSchema, @@ -76,6 +78,7 @@ import { MonitorListSchema, MonitorSchema, MonitorStatsSchema, + NativeExternalIssueSchema, OrganizationEnvironmentListSchema, OrganizationListSchema, OrganizationSchema, @@ -90,6 +93,9 @@ import { ReplayListResponseSchema, ReplayRecordingSegmentsSchema, RepositoryListSchema, + SentryAppComponentListSchema, + SentryAppExternalRequestOptionsSchema, + SentryAppInstallationListSchema, SpansSearchResponseSchema, StacktraceLinkSchema, TagListSchema, @@ -127,6 +133,7 @@ import type { Event, EventAttachment, EventAttachmentList, + ExternalIssue, ExternalIssueList, Flamegraph, Issue, @@ -135,6 +142,7 @@ import type { IssueAlertRuleList, IssueComment, IssueCommentList, + IssueIntegrationList, IssueList, IssueTagValues, MetricAlertRule, @@ -145,6 +153,7 @@ import type { MonitorCheckInList, MonitorList, MonitorStats, + NativeExternalIssue, OrganizationEnvironmentList, OrganizationList, ProfileChunk, @@ -155,6 +164,9 @@ import type { ReplayDetails, ReplayList, ReplayRecordingSegments, + SentryAppComponentList, + SentryAppExternalRequestOptions, + SentryAppInstallationList, StacktraceLink, TagList, Team, @@ -3901,10 +3913,7 @@ export class SentryApiService { } /** - * Retrieves external issue links for a specific issue. - * - * Returns links to external issue tracking systems (Jira, GitHub Issues, - * GitLab, etc.) that have been associated with this Sentry issue. + * Retrieves Sentry App issue associations. Native links come from listIssueIntegrations. * * @param params Query parameters * @param params.organizationSlug Organization identifier @@ -3922,12 +3931,212 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( + return this.listIssueLinkPages( apiPath`/organizations/${organizationSlug}/issues/${issueId}/external-issues/`, + ExternalIssueListSchema, + opts, + ); + } + + private async listIssueLinkPages( + path: string, + schema: z.ZodType, + opts?: RequestOptions, + ): Promise { + const items: T[] = []; + let cursor: string | null = null; + do { + const query = new URLSearchParams({ per_page: "100" }); + if (cursor) query.set("cursor", cursor); + const response = await this.request( + `${path}${path.includes("?") ? "&" : "?"}${query.toString()}`, + undefined, + opts, + ); + items.push(...schema.parse(await this.parseJsonResponse(response))); + cursor = getNextCursor(response.headers.get("link")); + } while (cursor); + return items; + } + + async listIssueIntegrations( + { + organizationSlug, + issueId, + }: { organizationSlug: string; issueId: string }, + opts?: RequestOptions, + ): Promise { + return this.listIssueLinkPages( + apiPath`/organizations/${organizationSlug}/issues/${issueId}/integrations/`, + IssueIntegrationListSchema, + opts, + ); + } + + /** Send a complete provider URL; HTTP 201 distinguishes a new association from a retry. */ + async linkNativeExternalIssue( + { + organizationSlug, + issueId, + integrationId, + externalIssueUrl, + }: { + organizationSlug: string; + issueId: string; + integrationId: string; + externalIssueUrl: string; + }, + opts?: RequestOptions, + ): Promise<{ issue: NativeExternalIssue; changed: boolean }> { + const response = await this.request( + apiPath`/organizations/${organizationSlug}/issues/${issueId}/integrations/${integrationId}/`, + { + method: "PUT", + body: JSON.stringify({ externalIssue: externalIssueUrl }), + }, + opts, + ); + return { + issue: NativeExternalIssueSchema.parse( + await this.parseJsonResponse(response), + ), + changed: response.status === 201, + }; + } + + /** Delete using Sentry's ExternalIssue ID, not the provider's issue number. */ + async unlinkNativeExternalIssue( + { + organizationSlug, + issueId, + integrationId, + externalIssueId, + }: { + organizationSlug: string; + issueId: string; + integrationId: string; + externalIssueId: string; + }, + opts?: RequestOptions, + ): Promise { + const query = new URLSearchParams({ externalIssue: externalIssueId }); + await this.request( + apiPath`/organizations/${organizationSlug}/issues/${issueId}/integrations/${integrationId}/` + + `?${query.toString()}`, + { method: "DELETE" }, + opts, + ); + } + + async listSentryAppInstallations( + { organizationSlug }: { organizationSlug: string }, + opts?: RequestOptions, + ): Promise { + return this.listIssueLinkPages( + apiPath`/organizations/${organizationSlug}/sentry-app-installations/`, + SentryAppInstallationListSchema, + { ...opts, host: this.isPublicSaas() ? "sentry.io" : opts?.host }, + ); + } + + async listSentryAppComponents( + { organizationSlug }: { organizationSlug: string }, + opts?: RequestOptions, + ): Promise { + return this.listIssueLinkPages( + apiPath`/organizations/${organizationSlug}/sentry-app-components/` + + "?filter=issue-link", + SentryAppComponentListSchema, + { ...opts, host: this.isPublicSaas() ? "sentry.io" : opts?.host }, + ); + } + + async getSentryAppExternalRequestOptions( + { + installationUuid, + uri, + query, + projectId, + dependentData, + }: { + installationUuid: string; + uri: string; + query?: string; + projectId?: string; + dependentData?: Record; + }, + opts?: RequestOptions, + ): Promise { + const params = new URLSearchParams({ uri }); + if (query !== undefined) params.set("query", query); + if (projectId !== undefined) params.set("projectId", projectId); + if (dependentData !== undefined) + params.set("dependentData", JSON.stringify(dependentData)); + const body = await this.requestJSON( + apiPath`/sentry-app-installations/${installationUuid}/external-requests/` + + `?${params.toString()}`, undefined, + { ...opts, host: this.isPublicSaas() ? "sentry.io" : opts?.host }, + ); + return SentryAppExternalRequestOptionsSchema.parse(body); + } + + /** Invoke the App callback with an exact canonical-URL guard, preserving HTTP 200/201. */ + async linkSentryAppExternalIssue( + { + installationUuid, + issueId, + uri, + fields, + expectedExternalIssueUrl, + }: { + installationUuid: string; + issueId: string; + uri: string; + fields: Record; + expectedExternalIssueUrl: string; + }, + opts?: RequestOptions, + ): Promise<{ issue: ExternalIssue; changed: boolean }> { + const query = new URLSearchParams({ expectedExternalIssueUrl }); + const response = await this.request( + apiPath`/sentry-app-installations/${installationUuid}/external-issue-actions/` + + `?${query.toString()}`, + { + method: "POST", + body: JSON.stringify({ + ...fields, + groupId: issueId, + action: "link", + uri, + }), + }, + { ...opts, host: this.isPublicSaas() ? "sentry.io" : opts?.host }, + ); + return { + issue: ExternalIssueSchema.parse(await this.parseJsonResponse(response)), + changed: response.status === 201, + }; + } + + /** Delete a group-scoped App association with event:write permissions. */ + async unlinkSentryAppExternalIssue( + { + organizationSlug, + issueId, + externalIssueId, + }: { + organizationSlug: string; + issueId: string; + externalIssueId: string; + }, + opts?: RequestOptions, + ): Promise { + await this.request( + apiPath`/organizations/${organizationSlug}/issues/${issueId}/external-issues/${externalIssueId}/`, + { method: "DELETE" }, opts, ); - return ExternalIssueListSchema.parse(body); } /** diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index 8c49b1e0a..3aa086d2d 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -1511,6 +1511,84 @@ export const UserReportListSchema = z.array(UserReportSchema); 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 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, +); + +export const SentryAppComponentSchema = z.object({ + type: z.string(), + sentryApp: z.object({ uuid: z.string(), slug: z.string() }), + schema: z.record(z.string(), z.unknown()), + error: z.unknown().optional(), +}); +export const SentryAppComponentListSchema = z.array(SentryAppComponentSchema); + +export const SentryAppExternalRequestOptionsSchema = z.object({ + choices: z.array( + z.tuple([ + z.union([z.string(), z.number()]), + z.union([z.string(), z.number()]), + ]), + ), + defaultValue: z.union([z.string(), z.number()]).optional(), +}); + /** * 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 49a652c7a..7a999df29 100644 --- a/packages/mcp-core/src/api-client/types.ts +++ b/packages/mcp-core/src/api-client/types.ts @@ -83,6 +83,8 @@ import type { IssueAlertRuleSchema, IssueCommentListSchema, IssueCommentSchema, + IssueIntegrationListSchema, + IssueIntegrationSchema, IssueListSchema, IssueSchema, IssueTagValuesSchema, @@ -94,6 +96,7 @@ import type { MonitorSchema, MonitorStatSchema, MonitorStatsSchema, + NativeExternalIssueSchema, OrganizationEnvironmentListSchema, OrganizationListSchema, OrganizationSchema, @@ -111,6 +114,9 @@ import type { ReplayListResponseSchema, ReplayRecordingEventSchema, ReplayRecordingSegmentsSchema, + SentryAppComponentListSchema, + SentryAppExternalRequestOptionsSchema, + SentryAppInstallationListSchema, StacktraceLinkSchema, TagListSchema, TagSchema, @@ -311,6 +317,18 @@ 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 NativeExternalIssue = z.infer; +export type SentryAppInstallationList = z.infer< + typeof SentryAppInstallationListSchema +>; +export type SentryAppComponentList = z.infer< + typeof SentryAppComponentListSchema +>; +export type SentryAppExternalRequestOptions = z.infer< + typeof SentryAppExternalRequestOptionsSchema +>; // User Report export type UserReportList = z.infer; diff --git a/packages/mcp-core/src/schema.ts b/packages/mcp-core/src/schema.ts index e7bc89371..b80d28b96 100644 --- a/packages/mcp-core/src/schema.ts +++ b/packages/mcp-core/src/schema.ts @@ -64,6 +64,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( + "URL of the existing external ticket or GitHub pull request. For Sentry Apps, use the canonical issue URL shown by the provider.", + ); + 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 b0e3088c9..bc7c36c09 100644 --- a/packages/mcp-core/src/server.test.ts +++ b/packages/mcp-core/src/server.test.ts @@ -2,7 +2,11 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { McpServer as ModernMcpServer } from "@modelcontextprotocol/server"; import { type Span, setUser, startSpan } from "@sentry/core"; -import { mswServer, projectFixture } from "@sentry/mcp-server-mocks"; +import { + issueFixture, + mswServer, + projectFixture, +} from "@sentry/mcp-server-mocks"; import { HttpResponse, http } from "msw"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; @@ -1239,6 +1243,8 @@ describe("buildServer", () => { for (const [toolName, grantedSkills] of [ ["add_issue_note", ["triage"]], ["update_issue", ["triage"]], + ["link_issue", ["triage"]], + ["unlink_issue", ["triage"]], ["create_project", ["project-management"]], ["create_team", ["project-management"]], ["update_project", ["project-management"]], @@ -1292,6 +1298,20 @@ describe("buildServer", () => { }); it.each([ + { + name: "link_issue", + arguments: { + issueId: "CLOUDFLARE-MCP-41", + externalIssueUrl: "https://github.com/example/repo/issues/42", + }, + }, + { + name: "unlink_issue", + arguments: { + issueId: "CLOUDFLARE-MCP-41", + externalIssueUrl: "https://github.com/example/repo/issues/42", + }, + }, { name: "update_issue", arguments: { issueId: "CLOUDFLARE-MCP-41", status: "resolved" }, @@ -1463,6 +1483,205 @@ describe("buildServer", () => { ); }); + it("discovers and dispatches issue linking with injected constraints", async () => { + const server = buildServer({ + context: { + ...baseContext, + grantedSkills: new Set(["triage"]), + constraints: { + organizationSlug: "sentry-mcp-evals", + projectSlug: "CLOUDFLARE-MCP", + regionUrl: "https://us.sentry.io", + }, + }, + }); + const externalIssueUrl = "https://github.com/example/repo/issues/42"; + const issueEndpoint = + "https://us.sentry.io/api/0/organizations/sentry-mcp-evals/issues/"; + const endpoint = `${issueEndpoint}${issueFixture.id}/integrations/`; + const link = { + id: "72", + key: "example/repo#42", + displayName: "example/repo#42", + url: externalIssueUrl, + }; + let linked = false; + const requests = { resolve: 0, list: 0, link: 0, unlink: 0 }; + mswServer.use( + http.get(`${issueEndpoint}${issueFixture.shortId}/`, () => { + requests.resolve++; + return HttpResponse.json(issueFixture); + }), + http.get(endpoint, () => { + requests.list++; + return HttpResponse.json([ + { + id: "11", + name: "example", + domainName: "github.com/example", + status: "active", + provider: { key: "github", name: "GitHub" }, + externalIssues: linked ? [link] : [], + }, + ]); + }), + http.put(`${endpoint}11/`, async ({ request }) => { + requests.link++; + expect(await request.json()).toEqual({ + externalIssue: externalIssueUrl, + }); + const status = linked ? 200 : 201; + linked = true; + return HttpResponse.json({ ...link, integrationId: 11 }, { status }); + }), + http.delete(`${endpoint}11/`, ({ request }) => { + expect(new URL(request.url).searchParams.get("externalIssue")).toBe( + "72", + ); + requests.unlink++; + linked = false; + return new HttpResponse(null, { status: 204 }); + }), + ); + for (const name of ["link_issue", "unlink_issue"]) { + expect(getRegisteredToolNames(server)).not.toContain(name); + const found = await callRegisteredTool(server, "search_sentry_tools", { + query: name, + limit: 1, + }); + expect(getStructuredContent(found)).toMatchObject({ + results: [{ name }], + }); + } + for (const [name, status] of [ + ["link_issue", "linked"], + ["link_issue", "already_linked"], + ["unlink_issue", "not_linked"], + ["unlink_issue", "not_linked"], + ]) { + const result = await callRegisteredTool(server, "execute_sentry_tool", { + name, + arguments: { + organizationSlug: "other-org", + issueId: "CLOUDFLARE-MCP-41", + externalIssueUrl, + }, + }); + expect(result.isError).not.toBe(true); + const payload = getStructuredContent(result); + expect(payload).toMatchObject({ + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + status, + }); + expect(JSON.parse(getTextContent(result))).toEqual(payload); + } + expect(requests).toEqual({ resolve: 4, list: 4, link: 2, unlink: 1 }); + }); + + it("dispatches App linking with form fields and resolved project context", async () => { + const server = buildServer({ + context: { + ...baseContext, + grantedSkills: new Set(["triage"]), + constraints: { + organizationSlug: "sentry-mcp-evals", + regionUrl: "https://us.sentry.io", + }, + }, + }); + const control = "https://sentry.io/api/0"; + const regional = "https://us.sentry.io/api/0"; + const orgPath = "/organizations/sentry-mcp-evals"; + const externalIssueUrl = "https://tracker.example/tickets/abc"; + mswServer.use( + http.get( + `${regional}${orgPath}/issues/${issueFixture.id}/external-issues/`, + () => HttpResponse.json([]), + ), + http.get(`${control}${orgPath}/sentry-app-installations/`, () => + HttpResponse.json([ + { + uuid: "installation", + status: "installed", + app: { uuid: "app", slug: "custom-tracker" }, + }, + ]), + ), + http.get(`${control}${orgPath}/sentry-app-components/`, () => + HttpResponse.json([ + { + type: "issue-link", + sentryApp: { uuid: "app", slug: "custom-tracker" }, + schema: { + link: { + uri: "/link", + required_fields: [ + { name: "issueId", type: "select", uri: "/search" }, + ], + }, + }, + }, + ]), + ), + http.get( + `${control}/sentry-app-installations/installation/external-requests/`, + ({ request }) => { + const query = new URL(request.url).searchParams; + expect(query.get("query")).toBe("opaque-id"); + expect(query.get("projectId")).toBe( + String(issueFixture.project.id), + ); + return HttpResponse.json({ + choices: [["opaque-id", "TICKET-42 Fix the error"]], + }); + }, + ), + http.post( + `${control}/sentry-app-installations/installation/external-issue-actions/`, + async ({ request }) => { + expect( + new URL(request.url).searchParams.get("expectedExternalIssueUrl"), + ).toBe(externalIssueUrl); + expect(await request.json()).toEqual({ + groupId: issueFixture.id, + action: "link", + uri: "/link", + issueId: "opaque-id", + }); + return HttpResponse.json( + { + id: "42", + issueId: issueFixture.id, + serviceType: "custom-tracker", + displayName: "TICKET-42", + webUrl: externalIssueUrl, + }, + { status: 201 }, + ); + }, + ), + ); + const result = await callRegisteredTool(server, "execute_sentry_tool", { + name: "link_issue", + arguments: { + issueId: issueFixture.shortId, + externalIssueUrl, + appSlug: "custom-tracker", + fields: { issueId: "opaque-id" }, + }, + }); + expect(result.isError).not.toBe(true); + expect(getStructuredContent(result)).toMatchObject({ + status: "linked", + externalIssue: { + displayName: "TICKET-42", + provider: "custom-tracker", + url: externalIssueUrl, + }, + }); + }); + it("execute_sentry_tool dispatches to catalog-only update_dsn", async () => { const server = buildServer({ context: baseContext, diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 36db06f26..255cad1b5 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -331,7 +331,7 @@ "description": "Resolve, assign, and update issues", "defaultEnabled": false, "order": 4, - "toolCount": 18, + "toolCount": 20, "tools": [ { "name": "add_issue_note", @@ -393,6 +393,11 @@ "description": "Fetch a Sentry resource by URL, or by resourceType plus resourceId.\nPass a Sentry URL directly when possible; the resource type is auto-detected.\n\nSupports issues, events, traces, spans, agent conversations, replays, preprod snapshots, and snapshot images.\nTrace lookups return a condensed overview by default.\n\nAgent Conversations: A conversation is a set of spans sharing the same gen_ai.conversation.id. Use resourceType='ai_conversation' with a conversation ID, or pass a Sentry conversation URL, to fetch the transcript/details. To discover or list conversations, use search_agent_conversations. Conversations are NOT issues — do not use search_issues for conversation queries.\n\nFor preprod snapshot URLs (matching 'sentry.io/preprod/snapshots/'):\n- Without ?selectedSnapshot=: returns the snapshot diff summary (changed, added, removed images)\n- With ?selectedSnapshot=: returns the image preview and metadata. Full-resolution snapshot image bytes are not available in this session.\n\nResource IDs:\n- snapshot: \n\n\nget_sentry_resource(url='https://sentry.io/issues/PROJECT-123/')\nget_sentry_resource(resourceType='issue', organizationSlug='my-org', resourceId='PROJECT-123')\nget_sentry_resource(resourceType='ai_conversation', organizationSlug='my-org', resourceId='conversation-123')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/')\nget_sentry_resource(url='https://sentry.sentry.io/preprod/snapshots/123/?selectedSnapshot=login_screen.png')\n", "requiredScopes": ["event:read", "project:read"] }, + { + "name": "link_issue", + "description": "Link an existing external ticket or GitHub pull request to a Sentry issue by URL.\nSupports native Jira, GitHub/GitHub Enterprise, GitLab, Bitbucket, and Azure DevOps integrations, and installed Sentry Apps whose issue-link forms use single-value select or text fields.\nCreates a reference: it does not create a ticket, resolve the Sentry issue, or associate a commit. Use update_issue separately to change status or assignment.\nA repeated link returns already_linked. A different App association must be unlinked first.\nFor Apps, copy the canonical issue URL from the provider. Supply fields only when the installed App requires additional form values.\n\nlink_issue(organizationSlug='my-org', issueId='PROJECT-123', externalIssueUrl='https://github.com/example/repo/pull/42')\nlink_issue(issueUrl='https://my-org.sentry.io/issues/123/', externalIssueUrl='https://linear.app/example/issue/ENG-42/fix-crash')\n", + "requiredScopes": ["event:write", "org:read"] + }, { "name": "search_agent_conversations", "description": "Search Sentry Agent Conversations, formerly called AI Conversations, and return one summary row per conversation.\n\nUse this tool to find or list Agent Conversations. Results are conversation summaries, not raw span rows.\nEach row includes title (when available), USD cost, tokens, call counts, previews, and other list metadata.\nUse get_agent_conversation_details with a conversationId to fetch the transcript. Use get_sentry_resource for Sentry conversation URLs.\n\n\nsearch_agent_conversations(organizationSlug='my-org', query='failed conversations', period='7d')\nsearch_agent_conversations(organizationSlug='my-org', query='checkout', project='backend')\n", @@ -413,6 +418,11 @@ "description": "Search for grouped issues/problems in Sentry - returns a LIST of issues, NOT counts or aggregations.\n\nProvide `query` as natural language or Sentry issue search syntax. When an embedded agent is configured, it fixes query and sort before running while preserving explicit Sentry search syntax.\n\nReturns grouped issues with metadata like title, status, and user count.\n\nCommon Query Syntax:\n- is:unresolved / is:resolved / is:ignored / is:for_review / is:new / is:regressed / is:escalating\n- level:error / level:warning\n- firstSeen:-24h / lastSeen:-7d\n- assigned:me / assigned_or_suggested:me\n- release:latest\n- issue.category:feedback\n- issue.priority:high\n- environment:production\n- userCount:>100\n\nDO NOT USE FOR COUNTS/AGGREGATIONS → use search_events\nDO NOT USE FOR individual events with timestamps → use search_events\nDO NOT USE FOR details about a specific issue → use get_sentry_resource\n\n\nsearch_issues(organizationSlug='my-org', query='critical bugs from last week')\nsearch_issues(organizationSlug='my-org', query='is:unresolved is:unassigned', sort='freq')\nsearch_issues(organizationSlug='my-org', query='level:error firstSeen:-24h', projectSlugOrId='my-project')\n\n\n\n- If the user passes a parameter in the form of name/otherName, it's likely in the format of /.\n- Parse org/project notation directly without calling find_organizations or find_projects.\n- The projectSlugOrId parameter accepts both project slugs (e.g., 'my-project') and numeric IDs (e.g., '123456').\n", "requiredScopes": ["event:read"] }, + { + "name": "unlink_issue", + "description": "Remove an external ticket or GitHub pull request reference from a Sentry issue by URL.\nRemoves only the Sentry association. It does not delete the external ticket or the Sentry issue, or change resolution status.\nSupports native integrations and installed Sentry Apps. Repeating the request is safe: not_linked means the association is absent, whether removed by this call or already absent.\n\nunlink_issue(organizationSlug='my-org', issueId='PROJECT-123', externalIssueUrl='https://github.com/example/repo/issues/42')\n", + "requiredScopes": ["event:write", "org:read"] + }, { "name": "update_issue", "description": "Update a Sentry issue's status or assignment.\n\nUse this to resolve, reopen, assign, unassign, 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', assignedTo=null)\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- Omit `assignedTo` to leave assignment unchanged. Pass `null` to unassign; otherwise use `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", diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index d53a65fbe..ffe1766c7 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -6084,6 +6084,118 @@ "skills": ["inspect"], "surface": "catalog" }, + { + "name": "link_issue", + "description": "Link an existing external ticket or GitHub pull request to a Sentry issue by URL.\nSupports native Jira, GitHub/GitHub Enterprise, GitLab, Bitbucket, and Azure DevOps integrations, and installed Sentry Apps whose issue-link forms use single-value select or text fields.\nCreates a reference: it does not create a ticket, resolve the Sentry issue, or associate a commit. Use update_issue separately to change status or assignment.\nA repeated link returns already_linked. A different App association must be unlinked first.\nFor Apps, copy the canonical issue URL from the provider. Supply fields only when the installed App requires additional form values.\n\nlink_issue(organizationSlug='my-org', issueId='PROJECT-123', externalIssueUrl='https://github.com/example/repo/pull/42')\nlink_issue(issueUrl='https://my-org.sentry.io/issues/123/', externalIssueUrl='https://linear.app/example/issue/ENG-42/fix-crash')\n", + "inputSchema": { + "type": "object", + "properties": { + "organizationSlug": { + "type": "string", + "description": "The organization's slug. You can find a existing list of organizations you have access to using the `find_organizations()` tool." + }, + "regionUrl": { + "default": null, + "anyOf": [ + { + "type": "string", + "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool." + }, + { + "type": "null" + } + ] + }, + "issueId": { + "type": "string", + "description": "The Issue ID. e.g. `PROJECT-1Z43`" + }, + "issueUrl": { + "type": "string", + "format": "uri", + "description": "The URL of the issue. e.g. https://my-organization.sentry.io/issues/PROJECT-1Z43" + }, + "externalIssueUrl": { + "type": "string", + "format": "uri", + "description": "URL of the existing external ticket or GitHub pull request. For Sentry Apps, use the canonical issue URL shown by the provider." + }, + "integrationId": { + "description": "Native integration ID, only needed when multiple installations match the URL.", + "type": "string", + "pattern": "^\\d+$" + }, + "appSlug": { + "description": "Installed Sentry App slug. Inferred for Linear and Shortcut URLs; specify it for other Apps.", + "type": "string", + "minLength": 1 + }, + "fields": { + "description": "Additional App form values by field name; required fields are reported when missing.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + } + }, + "required": ["externalIssueUrl"] + }, + "outputSchema": { + "type": "object", + "properties": { + "organizationSlug": { + "type": "string" + }, + "issueId": { + "type": "string" + }, + "issueUrl": { + "type": "string" + }, + "externalIssue": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "provider": { + "type": "string" + } + }, + "required": ["url"], + "additionalProperties": false + }, + "status": { + "type": "string", + "enum": ["linked", "already_linked", "not_linked"] + } + }, + "required": [ + "organizationSlug", + "issueId", + "issueUrl", + "externalIssue", + "status" + ], + "additionalProperties": false + }, + "requiredScopes": ["event:write", "org:read"], + "skills": ["triage"], + "surface": "catalog" + }, { "name": "onboarding_status_update", "description": "Update the progress shown in Sentry's agentic onboarding UI.\n\nUse this tool only when the Sentry getting started skill provides a run token. Call it at the workflow boundaries described by that skill.\n\n\n- Progress updates are operational UI state, not user analytics.\n- Keep eventNote brief and limited to context useful in the progress UI.\n- Do not include source code, repository paths, credentials, error output, or other sensitive data.\n- status is required for every update.\n- Set runStatus to completed or failed only when the entire onboarding run reaches that state.\n- A failed status requires a brief eventNote explaining the failure.\n", @@ -7371,6 +7483,101 @@ "skills": ["inspect", "seer", "docs", "triage", "project-management"], "surface": "direct" }, + { + "name": "unlink_issue", + "description": "Remove an external ticket or GitHub pull request reference from a Sentry issue by URL.\nRemoves only the Sentry association. It does not delete the external ticket or the Sentry issue, or change resolution status.\nSupports native integrations and installed Sentry Apps. Repeating the request is safe: not_linked means the association is absent, whether removed by this call or already absent.\n\nunlink_issue(organizationSlug='my-org', issueId='PROJECT-123', externalIssueUrl='https://github.com/example/repo/issues/42')\n", + "inputSchema": { + "type": "object", + "properties": { + "organizationSlug": { + "type": "string", + "description": "The organization's slug. You can find a existing list of organizations you have access to using the `find_organizations()` tool." + }, + "regionUrl": { + "default": null, + "anyOf": [ + { + "type": "string", + "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool." + }, + { + "type": "null" + } + ] + }, + "issueId": { + "type": "string", + "description": "The Issue ID. e.g. `PROJECT-1Z43`" + }, + "issueUrl": { + "type": "string", + "format": "uri", + "description": "The URL of the issue. e.g. https://my-organization.sentry.io/issues/PROJECT-1Z43" + }, + "externalIssueUrl": { + "type": "string", + "format": "uri", + "description": "URL of the existing external ticket or GitHub pull request. For Sentry Apps, use the canonical issue URL shown by the provider." + }, + "integrationId": { + "description": "Native integration ID, only needed when multiple installations match the URL.", + "type": "string", + "pattern": "^\\d+$" + }, + "appSlug": { + "description": "Installed Sentry App slug. Inferred for Linear and Shortcut URLs; specify it for other Apps.", + "type": "string", + "minLength": 1 + } + }, + "required": ["externalIssueUrl"] + }, + "outputSchema": { + "type": "object", + "properties": { + "organizationSlug": { + "type": "string" + }, + "issueId": { + "type": "string" + }, + "issueUrl": { + "type": "string" + }, + "externalIssue": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "provider": { + "type": "string" + } + }, + "required": ["url"], + "additionalProperties": false + }, + "status": { + "type": "string", + "enum": ["linked", "already_linked", "not_linked"] + } + }, + "required": [ + "organizationSlug", + "issueId", + "issueUrl", + "externalIssue", + "status" + ], + "additionalProperties": false + }, + "requiredScopes": ["event:write", "org:read"], + "skills": ["triage"], + "surface": "catalog" + }, { "name": "update_alert_rule", "description": "Update a Sentry Alert (workflow), including notification actions and connections.\nUse get_alert_rule with kind='issue' first to inspect the complete triggers and actionFilters configuration.\nUse get_alert_options to discover notification actions, integrations, conditions, and available sources.\nOmit fields to leave them unchanged. Pass null to clear owner or environment.\ntriggers replaces the trigger conditions. actionFilters replaces ALL action groups: copy the complete configuration, retain existing IDs, and change only the intended values. Omitted groups, conditions, and actions are removed.\nFor Slack or Microsoft Teams, change config.targetDisplay to the channel name and use integrationId for the workspace or team. Sentry resolves the channel ID. Slack also accepts an explicit new targetIdentifier; a copied old ID is cleared when the name or workspace changes.\nOther actions use their provider's config and data. For Discord, PagerDuty, Opsgenie, and email, update targetIdentifier to the channel, service, team, or recipient ID; changing only its display name does not change the destination.\nUse addProjectSlugs/removeProjectSlugs to connect/disconnect a project's issue stream. Other monitors in that project remain connected. Use addDetectorIds/removeDetectorIds for individual monitors. Unmentioned connections are preserved.\nMetric Monitor detection queries and thresholds are separate operations.\nAll-project connections require Sentry's all-project feature and an API token with org:write in addition to alerts:write.\nA project-constrained session can only edit alerts affecting that project exclusively.\nRequires alerts:write; reconnect OAuth if the existing token lacks it.\n\nupdate_alert_rule(organizationSlug='my-org', ruleIdOrName='12345', status='disabled')\nupdate_alert_rule(organizationSlug='my-org', projectSlug='backend', ruleIdOrName='Notify backend team', frequencyMinutes=30)\n", diff --git a/packages/mcp-core/src/tools/catalog/index.ts b/packages/mcp-core/src/tools/catalog/index.ts index f0074f36d..895e7f708 100644 --- a/packages/mcp-core/src/tools/catalog/index.ts +++ b/packages/mcp-core/src/tools/catalog/index.ts @@ -36,6 +36,8 @@ import getSpanDetails from "./get-span-details"; import getReplayDetails from "./get-replay-details"; import getEventAttachment from "./get-event-attachment"; import updateIssue from "./update-issue"; +import linkIssue from "./link-issue"; +import unlinkIssue from "./unlink-issue"; import searchEvents from "./search-events"; import createTeam from "./create-team"; import createProject from "./create-project"; @@ -131,6 +133,8 @@ const catalogTools = { get_replay_details: getReplayDetails, get_event_attachment: getEventAttachment, update_issue: updateIssue, + link_issue: linkIssue, + unlink_issue: unlinkIssue, search_events: searchEvents, create_team: createTeam, create_project: createProject, diff --git a/packages/mcp-core/src/tools/catalog/link-issue.test.ts b/packages/mcp-core/src/tools/catalog/link-issue.test.ts new file mode 100644 index 000000000..c82c31353 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/link-issue.test.ts @@ -0,0 +1,94 @@ +import { issueFixture, mswServer } from "@sentry/mcp-server-mocks"; +import { HttpResponse, http } from "msw"; +import { describe, expect, it } from "vitest"; +import linkIssue from "./link-issue"; +import unlinkIssue from "./unlink-issue"; + +const params = { + organizationSlug: "sentry-mcp-evals", + issueId: issueFixture.shortId, + regionUrl: null, + externalIssueUrl: "https://github.com/example/repo/issues/42", +}; +const context = { accessToken: "test-token", constraints: {} }; +const endpoint = `https://sentry.io/api/0/organizations/${params.organizationSlug}/issues/${issueFixture.id}/integrations/`; +const externalIssue = { + id: "72", + key: "example/repo#42", + displayName: "example/repo#42", + url: params.externalIssueUrl, +}; +const integration = { + id: "11", + name: "example", + domainName: "github.com/example", + status: "active", + provider: { key: "github", name: "GitHub" }, + externalIssues: [], +}; + +describe("link_issue", () => { + it("links using the resolved numeric issue ID and projects the result", async () => { + mswServer.use( + http.get(endpoint, () => HttpResponse.json([integration])), + http.put(`${endpoint}11/`, async ({ request }) => { + expect(await request.json()).toEqual({ + externalIssue: params.externalIssueUrl, + }); + return HttpResponse.json( + { ...externalIssue, integrationId: 11, internalOnly: "hidden" }, + { status: 201 }, + ); + }), + ); + expect(await linkIssue.handler(params, context)).toMatchInlineSnapshot(` + { + "structuredContent": { + "externalIssue": { + "displayName": "example/repo#42", + "provider": "github", + "url": "https://github.com/example/repo/issues/42", + }, + "issueId": "CLOUDFLARE-MCP-41", + "issueUrl": "https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41", + "organizationSlug": "sentry-mcp-evals", + "status": "linked", + }, + } + `); + }); +}); + +describe.each([linkIssue, unlinkIssue])("$name constraints", (tool) => { + it("rejects an issue from another project before discovering or mutating links", async () => { + let requests = 0; + mswServer.use( + http.all(`${endpoint}*`, () => { + requests++; + return new HttpResponse(null, { status: 500 }); + }), + ); + await expect( + tool.handler(params, { + ...context, + constraints: { projectSlug: "other-project" }, + }), + ).rejects.toThrow("outside the active project constraint"); + expect(requests).toBe(0); + }); + + it("rejects an issue URL outside the constrained organization", async () => { + await expect( + tool.handler( + { + ...params, + issueUrl: "https://other-org.sentry.io/issues/123/", + }, + { + ...context, + constraints: { organizationSlug: params.organizationSlug }, + }, + ), + ).rejects.toThrow("outside the active organization constraint"); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/link-issue.ts b/packages/mcp-core/src/tools/catalog/link-issue.ts new file mode 100644 index 000000000..7823d60d1 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/link-issue.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; +import { defineTool } from "../../internal/tool-helpers/define"; +import { structuredResult } from "../../internal/tool-helpers/results"; +import { linkExternalIssue } from "../support/issue-linking"; +import { + issueLinkInputSchema, + issueLinkOutputSchema, + resolveIssueLinkContext, +} from "../support/issue-linking/tool"; + +export default defineTool({ + name: "link_issue", + skills: ["triage"], + requiredScopes: ["event:write", "org:read"], + description: [ + "Link an existing external ticket or GitHub pull request to a Sentry issue by URL.", + "Supports native Jira, GitHub/GitHub Enterprise, GitLab, Bitbucket, and Azure DevOps integrations, and installed Sentry Apps whose issue-link forms use single-value select or text fields.", + "Creates a reference: it does not create a ticket, resolve the Sentry issue, or associate a commit. Use update_issue separately to change status or assignment.", + "A repeated link returns already_linked. A different App association must be unlinked first.", + "For Apps, copy the canonical issue URL from the provider. Supply fields only when the installed App requires additional form values.", + "", + "link_issue(organizationSlug='my-org', issueId='PROJECT-123', externalIssueUrl='https://github.com/example/repo/pull/42')", + "link_issue(issueUrl='https://my-org.sentry.io/issues/123/', externalIssueUrl='https://linear.app/example/issue/ENG-42/fix-crash')", + "", + ].join("\n"), + inputSchema: { + ...issueLinkInputSchema, + fields: z + .record(z.string(), z.union([z.string(), z.number()])) + .optional() + .describe( + "Additional App form values by field name; required fields are reported when missing.", + ), + }, + outputSchema: issueLinkOutputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + async handler(params, context) { + const { apiService, organizationSlug, issue } = + await resolveIssueLinkContext(params, context); + const { status, ...externalIssue } = await linkExternalIssue(apiService, { + ...params, + organizationSlug, + issueId: String(issue.id), + projectId: String(issue.project.id), + }); + return structuredResult({ + organizationSlug, + issueId: issue.shortId, + issueUrl: apiService.getIssueUrl(organizationSlug, issue.shortId), + externalIssue, + status, + }); + }, +}); diff --git a/packages/mcp-core/src/tools/catalog/unlink-issue.test.ts b/packages/mcp-core/src/tools/catalog/unlink-issue.test.ts new file mode 100644 index 000000000..de7a509f2 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/unlink-issue.test.ts @@ -0,0 +1,63 @@ +import { issueFixture, mswServer } from "@sentry/mcp-server-mocks"; +import { HttpResponse, http } from "msw"; +import { describe, expect, it } from "vitest"; +import unlinkIssue from "./unlink-issue"; + +const params = { + organizationSlug: "sentry-mcp-evals", + issueId: issueFixture.shortId, + regionUrl: null, + externalIssueUrl: "https://github.com/example/repo/issues/42", +}; +const context = { accessToken: "test-token", constraints: {} }; +const endpoint = `https://sentry.io/api/0/organizations/${params.organizationSlug}/issues/${issueFixture.id}/integrations/`; +const externalIssue = { + id: "72", + key: "example/repo#42", + displayName: "example/repo#42", + url: params.externalIssueUrl, +}; +const integration = { + id: "11", + name: "example", + domainName: "github.com/example", + status: "active", + provider: { key: "github", name: "GitHub" }, + externalIssues: [], +}; + +describe("unlink_issue", () => { + it("removes only the association and returns its resulting state", async () => { + let deletes = 0; + mswServer.use( + http.get(endpoint, () => + HttpResponse.json([ + { ...integration, externalIssues: [externalIssue] }, + ]), + ), + http.delete(`${endpoint}11/`, ({ request }) => { + expect(new URL(request.url).searchParams.get("externalIssue")).toBe( + "72", + ); + deletes++; + return new HttpResponse(null, { status: 204 }); + }), + ); + expect(await unlinkIssue.handler(params, context)).toMatchInlineSnapshot(` + { + "structuredContent": { + "externalIssue": { + "displayName": "example/repo#42", + "provider": "github", + "url": "https://github.com/example/repo/issues/42", + }, + "issueId": "CLOUDFLARE-MCP-41", + "issueUrl": "https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41", + "organizationSlug": "sentry-mcp-evals", + "status": "not_linked", + }, + } + `); + expect(deletes).toBe(1); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/unlink-issue.ts b/packages/mcp-core/src/tools/catalog/unlink-issue.ts new file mode 100644 index 000000000..2310b2d4f --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/unlink-issue.ts @@ -0,0 +1,46 @@ +import { defineTool } from "../../internal/tool-helpers/define"; +import { structuredResult } from "../../internal/tool-helpers/results"; +import { unlinkExternalIssue } from "../support/issue-linking"; +import { + issueLinkInputSchema, + issueLinkOutputSchema, + resolveIssueLinkContext, +} from "../support/issue-linking/tool"; + +export default defineTool({ + name: "unlink_issue", + skills: ["triage"], + requiredScopes: ["event:write", "org:read"], + description: [ + "Remove an external ticket or GitHub pull request reference from a Sentry issue by URL.", + "Removes only the Sentry association. It does not delete the external ticket or the Sentry issue, or change resolution status.", + "Supports native integrations and installed Sentry Apps. Repeating the request is safe: not_linked means the association is absent, whether removed by this call or already absent.", + "", + "unlink_issue(organizationSlug='my-org', issueId='PROJECT-123', externalIssueUrl='https://github.com/example/repo/issues/42')", + "", + ].join("\n"), + inputSchema: issueLinkInputSchema, + outputSchema: issueLinkOutputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + async handler(params, context) { + const { apiService, organizationSlug, issue } = + await resolveIssueLinkContext(params, context); + const { status, ...externalIssue } = await unlinkExternalIssue(apiService, { + ...params, + organizationSlug, + issueId: String(issue.id), + }); + return structuredResult({ + organizationSlug, + issueId: issue.shortId, + issueUrl: apiService.getIssueUrl(organizationSlug, issue.shortId), + externalIssue, + status, + }); + }, +}); diff --git a/packages/mcp-core/src/tools/support/issue-linking/app.test.ts b/packages/mcp-core/src/tools/support/issue-linking/app.test.ts new file mode 100644 index 000000000..6276e6d62 --- /dev/null +++ b/packages/mcp-core/src/tools/support/issue-linking/app.test.ts @@ -0,0 +1,463 @@ +import { mswServer } from "@sentry/mcp-server-mocks"; +import { HttpResponse, http } from "msw"; +import { describe, expect, it } from "vitest"; +import { SentryApiService } from "../../../api-client"; +import { linkAppIssue, unlinkAppIssue } from "./app"; + +const api = new SentryApiService({ accessToken: "test-token" }); +const base = "https://sentry.io/api/0"; +const url = "https://linear.app/example/issue/ENG-123/fix-the-error"; +const params = { + organizationSlug: "example", + issueId: "123", + projectId: "7", + externalIssueUrl: url, +}; +const association = { + id: "42", + issueId: "123", + serviceType: "linear", + displayName: "ENG-123", + webUrl: url, +}; +const linkForm = { + uri: "/issues/link", + required_fields: [{ name: "issueId", type: "select", uri: "/issues/search" }], +}; + +function mockDiscovery({ + slug = "linear", + form = linkForm as Record, + links = [] as (typeof association)[], +} = {}) { + mswServer.use( + http.get(`${base}/organizations/example/sentry-app-installations/`, () => + HttpResponse.json([ + { + uuid: "installation", + status: "installed", + app: { uuid: "app", slug }, + }, + ]), + ), + http.get( + `${base}/organizations/example/sentry-app-components/`, + ({ request }) => { + expect(new URL(request.url).searchParams.get("filter")).toBe( + "issue-link", + ); + return HttpResponse.json([ + { + type: "issue-link", + sentryApp: { uuid: "app", slug }, + schema: { link: form }, + }, + ]); + }, + ), + http.get(`${base}/organizations/example/issues/123/external-issues/`, () => + HttpResponse.json(links), + ), + ); +} + +describe("linkAppIssue", () => { + it.each([ + { + slug: "linear", + targetUrl: url, + key: "ENG-123", + choice: "linear-issue", + fields: undefined, + appSlug: undefined, + }, + { + slug: "shortcut", + targetUrl: "https://app.shortcut.com/example/story/123/fix-the-error", + key: "123", + choice: 123, + fields: undefined, + appSlug: undefined, + }, + ])( + "links through the installed $slug callback with the original URL guard", + async ({ slug, targetUrl, key, choice, fields, appSlug }) => { + mockDiscovery({ slug }); + let actions = 0; + mswServer.use( + http.get( + `${base}/sentry-app-installations/installation/external-requests/`, + ({ request }) => { + const query = new URL(request.url).searchParams; + expect(query.get("uri")).toBe("/issues/search"); + expect(query.get("query")).toBe(key); + expect(query.get("projectId")).toBe("7"); + return HttpResponse.json({ + choices: [[choice, `${key} Fix the error`]], + }); + }, + ), + http.post( + `${base}/sentry-app-installations/installation/external-issue-actions/`, + async ({ request }) => { + actions++; + expect( + new URL(request.url).searchParams.get("expectedExternalIssueUrl"), + ).toBe(targetUrl); + expect(await request.json()).toEqual({ + groupId: "123", + action: "link", + uri: "/issues/link", + issueId: choice, + }); + return HttpResponse.json( + { ...association, serviceType: slug, webUrl: targetUrl }, + { status: 201 }, + ); + }, + ), + ); + await expect( + linkAppIssue(api, { + ...params, + externalIssueUrl: targetUrl, + fields, + appSlug, + }), + ).resolves.toEqual({ + url: targetUrl, + provider: slug, + displayName: "ENG-123", + status: "linked", + }); + expect(actions).toBe(1); + }, + ); + + it("resolves defaults and dependent choices before submitting the form", async () => { + mockDiscovery({ + form: { + uri: "/issues/link", + required_fields: [ + { + name: "issueId", + type: "select", + uri: "/issues/search", + depends_on: ["team"], + }, + { name: "team", type: "select", uri: "/teams" }, + ], + optional_fields: [ + { + name: "priority", + type: "select", + options: [[0, "None"]], + defaultValue: 0, + }, + ], + }, + }); + const searches: string[] = []; + mswServer.use( + http.get( + `${base}/sentry-app-installations/installation/external-requests/`, + ({ request }) => { + const query = new URL(request.url).searchParams; + const uri = query.get("uri")!; + searches.push(uri); + if (uri === "/teams") + return HttpResponse.json({ + choices: [["team-id", "Engineering"]], + defaultValue: "team-id", + }); + expect(query.get("dependentData")).toBe('{"team":"team-id"}'); + expect(query.get("query")).toBe("ENG-123"); + return HttpResponse.json({ + choices: [["issue-id", "ENG-123 Fix the error"]], + }); + }, + ), + http.post( + `${base}/sentry-app-installations/installation/external-issue-actions/`, + async ({ request }) => { + expect(await request.json()).toEqual({ + groupId: "123", + action: "link", + uri: "/issues/link", + team: "team-id", + issueId: "issue-id", + priority: 0, + }); + return HttpResponse.json(association, { status: 201 }); + }, + ), + ); + await expect(linkAppIssue(api, params)).resolves.toMatchObject({ + status: "linked", + }); + expect(searches).toEqual(["/teams", "/issues/search"]); + }); + + it.each([ + { source: "default", defaultValue: "", fields: undefined }, + { source: "supplied value", defaultValue: "preset", fields: { note: "" } }, + ])("omits an empty optional $source", async ({ defaultValue, fields }) => { + mockDiscovery({ + form: { + uri: "/issues/link", + required_fields: [{ name: "issueId", type: "text" }], + optional_fields: [{ name: "note", type: "text", defaultValue }], + }, + }); + mswServer.use( + http.post( + `${base}/sentry-app-installations/installation/external-issue-actions/`, + async ({ request }) => { + expect(await request.json()).toEqual({ + groupId: "123", + action: "link", + uri: "/issues/link", + issueId: "ENG-123", + }); + return HttpResponse.json(association, { status: 201 }); + }, + ), + ); + await expect( + linkAppIssue(api, { ...params, fields }), + ).resolves.toMatchObject({ + status: "linked", + }); + }); + + it("still requires an empty optional field when the target depends on it", async () => { + mockDiscovery({ + form: { + uri: "/issues/link", + required_fields: [ + { name: "issueId", type: "text", depends_on: ["team"] }, + ], + optional_fields: [{ name: "team", type: "text", defaultValue: "" }], + }, + }); + await expect(linkAppIssue(api, params)).rejects.toThrow( + "Provide required App link field 'team' in fields.", + ); + }); + + it("guards a repeat with its stored canonical URL and prepares fields for a concurrent unlink", async () => { + mockDiscovery({ links: [association] }); + let searches = 0; + let actions = 0; + mswServer.use( + http.get( + `${base}/sentry-app-installations/installation/external-requests/`, + () => { + searches++; + return HttpResponse.json({ + choices: [["issue-id", "ENG-123 Fix the error"]], + }); + }, + ), + http.post( + `${base}/sentry-app-installations/installation/external-issue-actions/`, + async ({ request }) => { + actions++; + expect( + new URL(request.url).searchParams.get("expectedExternalIssueUrl"), + ).toBe(url); + expect(await request.json()).toEqual({ + groupId: "123", + action: "link", + uri: "/issues/link", + issueId: "issue-id", + }); + return HttpResponse.json(association, { status: 200 }); + }, + ), + ); + await expect( + linkAppIssue(api, { + ...params, + externalIssueUrl: + "https://linear.app/example/issue/ENG-123/old-title?utm_source=test", + }), + ).resolves.toMatchObject({ status: "already_linked", url }); + expect(actions).toBe(1); + expect(searches).toBe(1); + }); + + it("requires a valid installed callback even for an existing association", async () => { + mockDiscovery({ + links: [association], + form: { ...linkForm, uri: "https://other.example/link" }, + }); + await expect(linkAppIssue(api, params)).rejects.toThrow( + "invalid callback URI", + ); + }); + + it("requires an explicit unlink before replacing an App association", async () => { + mockDiscovery({ + links: [ + { + ...association, + webUrl: "https://linear.app/example/issue/ENG-999/other", + }, + ], + }); + await expect(linkAppIssue(api, params)).rejects.toThrow( + "Unlink it explicitly", + ); + }); + + it.each(["action", "unknownField"])( + "rejects caller field %s before invoking the App", + async (name) => { + mockDiscovery(); + await expect( + linkAppIssue(api, { ...params, fields: { [name]: "override" } }), + ).rejects.toThrow("not allowed"); + }, + ); + + it.each(["select", "text"])( + "rejects a conflicting %s target before invoking the callback", + async (type) => { + mockDiscovery({ + form: { + uri: "/issues/link", + required_fields: [ + { + name: "issueId", + type, + choices: [ + ["requested-id", "ENG-123 Requested"], + ["other-id", "ENG-999 Other"], + ], + }, + ], + }, + }); + let actions = 0; + mswServer.use( + http.post( + `${base}/sentry-app-installations/installation/external-issue-actions/`, + () => { + actions++; + return HttpResponse.json(association, { status: 201 }); + }, + ), + ); + await expect( + linkAppIssue(api, { + ...params, + fields: { issueId: type === "select" ? "other-id" : "ENG-999" }, + }), + ).rejects.toThrow("conflicts with externalIssueUrl"); + expect(actions).toBe(0); + }, + ); + + it.each([123, 456])( + "matches a Shortcut numeric choice value before its title (%s)", + async (choice) => { + const targetUrl = + "https://app.shortcut.com/example/story/123/fix-the-error"; + mockDiscovery({ + slug: "shortcut", + form: { + uri: "/issues/link", + required_fields: [ + { + name: "issueId", + type: "select", + choices: [[choice, "2026 planning"]], + }, + ], + }, + }); + let actions = 0; + mswServer.use( + http.post( + `${base}/sentry-app-installations/installation/external-issue-actions/`, + () => { + actions++; + return HttpResponse.json( + { ...association, serviceType: "shortcut", webUrl: targetUrl }, + { status: 201 }, + ); + }, + ), + ); + const result = linkAppIssue(api, { + ...params, + externalIssueUrl: targetUrl, + fields: { issueId: choice }, + }); + if (choice === 123) { + await expect(result).resolves.toMatchObject({ status: "linked" }); + expect(actions).toBe(1); + } else { + await expect(result).rejects.toThrow("conflicts with externalIssueUrl"); + expect(actions).toBe(0); + } + }, + ); + + it("reports canonical URL and concurrent replacement conflicts without an unguarded retry", async () => { + mockDiscovery({ + form: { + uri: "/issues/link", + required_fields: [{ name: "issueId", type: "text" }], + }, + }); + let actions = 0; + mswServer.use( + http.post( + `${base}/sentry-app-installations/installation/external-issue-actions/`, + ({ request }) => { + actions++; + expect( + new URL(request.url).searchParams.get("expectedExternalIssueUrl"), + ).toBe(url); + return HttpResponse.json( + { detail: "External issue URL does not match expected URL" }, + { status: 409 }, + ); + }, + ), + ); + await expect(linkAppIssue(api, params)).rejects.toThrow( + "exact canonical issue URL", + ); + expect(actions).toBe(1); + }); +}); + +describe("unlinkAppIssue", () => { + it.each([true, false])( + "removes only the matching association (present: %s)", + async (present) => { + mockDiscovery({ links: present ? [association] : [] }); + let deletes = 0; + mswServer.use( + http.delete( + `${base}/organizations/example/issues/123/external-issues/42/`, + () => { + deletes++; + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + await expect( + unlinkAppIssue(api, { + ...params, + externalIssueUrl: + "https://linear.app/example/issue/ENG-123/old-title", + }), + ).resolves.toMatchObject({ status: "not_linked" }); + expect(deletes).toBe(present ? 1 : 0); + }, + ); +}); diff --git a/packages/mcp-core/src/tools/support/issue-linking/app.ts b/packages/mcp-core/src/tools/support/issue-linking/app.ts new file mode 100644 index 000000000..d69840c5f --- /dev/null +++ b/packages/mcp-core/src/tools/support/issue-linking/app.ts @@ -0,0 +1,420 @@ +/** Resolve installed App forms; every link mutation uses the backend's canonical-URL guard. */ +import { z } from "zod"; +import type { SentryApiService } from "../../../api-client"; +import { ApiClientError } from "../../../api-client/errors"; +import { SentryAppExternalRequestOptionsSchema } from "../../../api-client/schema"; +import { UserInputError } from "../../../errors"; + +type FormValues = Record; +export type AppIssueLinkParams = { + organizationSlug: string; + issueId: string; + projectId?: string; + externalIssueUrl: string; + appSlug?: string; + fields?: FormValues; +}; +type AppIssueLinkApi = Pick< + SentryApiService, + | "listSentryAppInstallations" + | "listSentryAppComponents" + | "getIssueExternalLinks" + | "getSentryAppExternalRequestOptions" + | "linkSentryAppExternalIssue" + | "unlinkSentryAppExternalIssue" +>; + +const FieldSchema = z.object({ + name: z.string().min(1), + type: z.enum(["select", "text", "textarea"]), + choices: SentryAppExternalRequestOptionsSchema.shape.choices.optional(), + options: SentryAppExternalRequestOptionsSchema.shape.choices.optional(), + defaultValue: + SentryAppExternalRequestOptionsSchema.shape.defaultValue.nullable(), + depends_on: z.array(z.string()).optional(), + multiple: z.boolean().optional(), + uri: z.string().optional(), +}); +const LinkFormSchema = z.object({ + uri: z.string(), + required_fields: z.array(FieldSchema).default([]), + optional_fields: z.array(FieldSchema).default([]), +}); +type LinkForm = z.infer; +type Field = z.infer; +const RESERVED_FIELDS = new Set([ + "groupId", + "action", + "uri", + "expectedExternalIssueUrl", + "__proto__", + "constructor", + "prototype", +]); +const TARGET_FIELD = + /^(issue_?id|issue|external_?issue|external_?id|issue_?url|url)$/i; + +function isIssueKey(value: string, key: string): boolean { + return (/^\d+$/.test(key) ? /^\d+$/ : /^[A-Z][A-Z0-9]*-\d+$/i).test(value); +} + +function choiceLabelKey(label: string | number): string { + return String(label) + .toUpperCase() + .split(/[^A-Z0-9-]+/)[0]!; +} + +function parseTarget(raw: string) { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new UserInputError("externalIssueUrl must be a valid HTTP(S) URL."); + } + if ( + !["http:", "https:"].includes(url.protocol) || + url.username || + url.password + ) { + throw new UserInputError( + "externalIssueUrl must be an HTTP(S) URL without credentials.", + ); + } + const linear = + url.hostname === "linear.app" + ? /^\/([^/]+)\/issue\/([a-z][a-z0-9]*-\d+)(?:\/|$)/i.exec(url.pathname) + : null; + const shortcut = ["app.shortcut.com", "shortcut.com"].includes(url.hostname) + ? /^\/([^/]+)\/story\/(\d+)(?:\/|$)/.exec(url.pathname) + : null; + const match = linear ?? shortcut; + return { + appSlug: linear ? "linear" : shortcut ? "shortcut" : undefined, + key: match?.[2]?.toUpperCase(), + // Titles are irrelevant to an existing association's identity, but the + // original URL is still required for the backend's exact callback guard. + identity: match + ? `${url.origin}/${match[1]}/${match[2]?.toUpperCase()}` + : url.href, + }; +} + +export function inferAppSlug(url: string): string | undefined { + return parseTarget(url).appSlug; +} + +export function areEquivalentAppIssueUrls(a: string, b: string): boolean { + try { + return parseTarget(a).identity === parseTarget(b).identity; + } catch { + return false; + } +} + +function validateUri(uri: string): string { + if (!/^\/(?!\/)[^@\\\r\n]*$/.test(uri)) { + throw new UserInputError( + "The installed App's issue-link form has an invalid callback URI.", + ); + } + return uri; +} + +function validateFields( + form: LinkForm, + supplied: FormValues, +): Map { + const fields = new Map(); + for (const field of [...form.required_fields, ...form.optional_fields]) { + if (RESERVED_FIELDS.has(field.name) || fields.has(field.name)) { + throw new UserInputError( + "The installed App's issue-link form has unsafe or duplicate fields.", + ); + } + fields.set(field.name, field); + } + for (const name of Object.keys(supplied)) { + if (!fields.has(name)) { + throw new UserInputError( + `Field '${name}' is not allowed by the installed App's link form.`, + ); + } + } + return fields; +} + +async function resolveFields( + api: AppIssueLinkApi, + params: AppIssueLinkParams, + installationUuid: string, + form: LinkForm, + fields: Map, +): Promise { + const supplied = params.fields ?? {}; + const candidates = [...fields.values()].filter((field) => + TARGET_FIELD.test(field.name), + ); + const target = + candidates.length === 1 + ? candidates[0] + : candidates.length === 0 && form.required_fields.length === 1 + ? form.required_fields[0] + : undefined; + if (!target) { + throw new UserInputError( + "Cannot identify the App's existing-issue field. Use the App's Sentry UI to link this issue.", + ); + } + const key = parseTarget(params.externalIssueUrl).key; + const values: FormValues = {}; + const pending = new Set(form.required_fields.map((field) => field.name)); + pending.add(target.name); + for (const field of fields.values()) { + const value = supplied[field.name] ?? field.defaultValue; + if (value != null && value !== "") pending.add(field.name); + } + // Resolve required dependencies too, even when the form marks them optional. + for (const name of pending) { + const field = fields.get(name); + if (!field) + throw new UserInputError( + `The App's link form references unknown field '${name}'.`, + ); + for (const dependency of field.depends_on ?? []) pending.add(dependency); + } + while (pending.size > 0) { + let progressed = false; + for (const name of pending) { + const field = fields.get(name)!; + const dependencies = field.depends_on ?? []; + if (dependencies.some((dependency) => pending.has(dependency))) continue; + if (field.multiple) + throw new UserInputError( + `The App's multiple-choice field '${name}' is not supported.`, + ); + const isTarget = name === target.name; + const targetKey = isTarget ? key : undefined; + const isTargetUrl = isTarget && /url/i.test(name); + let value: string | number | undefined = + supplied[name] ?? + (targetKey || isTargetUrl + ? undefined + : (field.defaultValue ?? undefined)); + const search = isTarget + ? isTargetUrl + ? params.externalIssueUrl + : (key ?? value ?? params.externalIssueUrl) + : value; + if (field.type === "select") { + let choices = field.choices ?? field.options ?? []; + if (field.uri) { + const options = await api.getSentryAppExternalRequestOptions({ + installationUuid, + uri: validateUri(field.uri), + query: search === undefined ? undefined : String(search), + projectId: params.projectId, + dependentData: dependencies.length + ? Object.fromEntries( + dependencies.map((dependency) => [ + dependency, + values[dependency]!, + ]), + ) + : undefined, + }); + choices = options.choices; + if (!targetKey && !isTargetUrl) value ??= options.defaultValue; + } + const wanted = value ?? search; + const matches = choices.filter( + ([choiceValue, label]) => + String(choiceValue) === String(wanted) || + String(label) === String(wanted) || + (isTarget && + key !== undefined && + choiceLabelKey(label) === key && + (value === undefined || String(choiceValue) === String(value))), + ); + if (matches.length !== 1) { + throw new UserInputError( + `Provide an unambiguous value for App link field '${name}' in fields.`, + ); + } + if (targetKey) { + const [selectedValue, selectedLabel] = matches[0]!; + const valueKey = String(selectedValue).toUpperCase(); + const labelKey = choiceLabelKey(selectedLabel); + const identifiedChoices = choices.filter( + ([choiceValue, label]) => + String(choiceValue).toUpperCase() === targetKey || + choiceLabelKey(label) === targetKey, + ); + if ( + (isIssueKey(valueKey, targetKey) && valueKey !== targetKey) || + (!isIssueKey(valueKey, targetKey) && + ((identifiedChoices.length && + !identifiedChoices.some( + ([choiceValue]) => choiceValue === selectedValue, + )) || + (isIssueKey(labelKey, targetKey) && labelKey !== targetKey))) + ) { + throw new UserInputError( + `App link field '${name}' conflicts with externalIssueUrl.`, + ); + } + } + value = matches[0]![0]; + } else { + value ??= search; + if ( + isTarget && + value !== undefined && + (((isTargetUrl || /^https?:\/\//i.test(String(value))) && + !areEquivalentAppIssueUrls( + String(value), + params.externalIssueUrl, + )) || + (targetKey && + isIssueKey(String(value), targetKey) && + String(value).toUpperCase() !== targetKey)) + ) { + throw new UserInputError( + `App link field '${name}' conflicts with externalIssueUrl.`, + ); + } + } + if (value === undefined || value === "") { + throw new UserInputError( + `Provide required App link field '${name}' in fields.`, + ); + } + values[name] = value; + pending.delete(name); + progressed = true; + } + if (!progressed) + throw new UserInputError( + "The App's link form has circular field dependencies.", + ); + } + return values; +} + +/** Prepare a complete form even on retries, since an existing association may disappear. */ +export async function linkAppIssue( + api: AppIssueLinkApi, + params: AppIssueLinkParams, +) { + const appSlug = params.appSlug ?? inferAppSlug(params.externalIssueUrl); + if (!appSlug) + throw new UserInputError("Provide appSlug for this external issue URL."); + const [installations, components, links] = await Promise.all([ + api.listSentryAppInstallations(params), + api.listSentryAppComponents(params), + api.getIssueExternalLinks(params), + ]); + const installed = installations.filter( + (installation) => + installation.app.slug === appSlug && installation.status === "installed", + ); + if (installed.length !== 1) + throw new UserInputError(`Expected one installed Sentry App '${appSlug}'.`); + const installation = installed[0]!; + const matchingComponents = components.filter( + (component) => + component.type === "issue-link" && + component.sentryApp.slug === appSlug && + (!installation.app.uuid || + component.sentryApp.uuid === installation.app.uuid), + ); + const component = + matchingComponents.length === 1 ? matchingComponents[0] : undefined; + const parsed = LinkFormSchema.safeParse(component?.schema.link); + if (!component || component.error || !parsed.success) { + throw new UserInputError( + `Sentry App '${appSlug}' has no usable existing-issue link form.`, + ); + } + const uri = validateUri(parsed.data.uri); + const fields = validateFields(parsed.data, params.fields ?? {}); + const existing = links.filter((link) => link.serviceType === appSlug); + if ( + existing.length > 1 || + (existing[0] && + !areEquivalentAppIssueUrls(existing[0].webUrl, params.externalIssueUrl)) + ) { + throw new UserInputError( + `This Sentry issue is already linked to another '${appSlug}' issue. Unlink it explicitly before linking a replacement.`, + ); + } + const expectedExternalIssueUrl = + existing[0]?.webUrl ?? params.externalIssueUrl; + // A concurrent unlink can make even a repeated action invoke the callback. + const values = await resolveFields( + api, + params, + installation.uuid, + parsed.data, + fields, + ); + try { + const result = await api.linkSentryAppExternalIssue({ + installationUuid: installation.uuid, + issueId: params.issueId, + uri, + fields: values, + expectedExternalIssueUrl, + }); + return { + url: result.issue.webUrl, + displayName: result.issue.displayName, + provider: appSlug, + status: result.changed + ? ("linked" as const) + : ("already_linked" as const), + }; + } catch (error) { + if (error instanceof ApiClientError && error.status === 409) { + throw new UserInputError( + "The App link conflicted with the current association or callback URL. Check the current link and use the provider's exact canonical issue URL, including its title path. Unlink an existing different issue explicitly before replacing it; retry if another link operation was in progress.", + { cause: error }, + ); + } + throw error; + } +} + +/** Remove the matching App association by ID without invoking the provider. */ +export async function unlinkAppIssue( + api: AppIssueLinkApi, + params: AppIssueLinkParams, +) { + const appSlug = params.appSlug ?? inferAppSlug(params.externalIssueUrl); + const links = await api.getIssueExternalLinks(params); + const matches = links.filter( + (link) => + (!appSlug || link.serviceType === appSlug) && + areEquivalentAppIssueUrls(link.webUrl, params.externalIssueUrl), + ); + if (matches.length > 1) + throw new UserInputError( + "Multiple App links match this URL. Provide appSlug to select one.", + ); + const match = matches[0]; + if (!match) + return { + url: params.externalIssueUrl, + provider: appSlug, + status: "not_linked" as const, + }; + await api.unlinkSentryAppExternalIssue({ + ...params, + externalIssueId: String(match.id), + }); + return { + url: match.webUrl, + displayName: match.displayName, + provider: match.serviceType, + status: "not_linked" as const, + }; +} 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 000000000..abfd61ac6 --- /dev/null +++ b/packages/mcp-core/src/tools/support/issue-linking/index.test.ts @@ -0,0 +1,498 @@ +import { mswServer } from "@sentry/mcp-server-mocks"; +import { HttpResponse, http } from "msw"; +import { describe, expect, it } from "vitest"; +import { SentryApiService } from "../../../api-client"; +import type { IssueIntegration } from "../../../api-client/types"; +import { UserInputError } from "../../../errors"; +import { linkExternalIssue, unlinkExternalIssue } from "."; + +const api = new SentryApiService({ accessToken: "test-token" }); +const params = { organizationSlug: "example", issueId: "123" }; +const endpoint = + "https://sentry.io/api/0/organizations/example/issues/123/integrations/"; +const appEndpoint = + "https://sentry.io/api/0/organizations/example/issues/123/external-issues/"; + +function integration( + overrides: Partial = {}, +): IssueIntegration { + return { + id: "1", + name: "acme", + domainName: "github.com/acme", + provider: { key: "github" }, + externalIssues: [], + ...overrides, + }; +} + +const nativeCases = [ + { + provider: "github", + domainName: "github.com/acme", + url: "https://github.com/acme/repo/pull/42/files?diff=split#change", + storedUrl: "https://github.com/acme/repo/issues/42", + }, + { + provider: "github_enterprise", + domainName: "github.example.com:8443/acme", + url: "https://github.example.com:8443/acme/repo/issues/42", + storedUrl: "https://github.example.com:8443/acme/repo/issues/42", + }, + { + provider: "jira", + domainName: "acme.atlassian.net", + url: "https://acme.atlassian.net/browse/ENG-42?source=search", + storedUrl: "https://acme.atlassian.net/browse/ENG-42", + }, + { + provider: "jira_server", + domainName: "jira.example.com:8443", + url: "https://jira.example.com:8443/jira/browse/eng-42", + storedUrl: "https://jira.example.com:8443/jira/browse/ENG-42", + }, + { + provider: "jira", + domainName: "acme.atlassian.net", + url: "https://acme.atlassian.net/jira/software/projects/ENG/boards/1?selectedIssue=ENG-42", + storedUrl: "https://acme.atlassian.net/browse/ENG-42", + }, + { + provider: "jira_server", + domainName: "jira.example.com", + url: "https://jira.example.com/jira/projects/ENG/issues/ENG-42", + storedUrl: "https://jira.example.com/jira/browse/ENG-42", + }, + { + provider: "gitlab", + domainName: "gitlab.com/acme", + url: "https://gitlab.com/acme/backend/repo/-/issues/42", + storedUrl: "https://gitlab.com/acme/backend/repo/issues/42", + }, + { + provider: "gitlab", + domainName: "gitlab.example.com/acme", + url: "https://gitlab.example.com/gitlab/acme/backend/repo/issues/42", + storedUrl: + "https://gitlab.example.com/gitlab/acme/backend/repo/-/issues/42", + }, + { + provider: "bitbucket", + domainName: "bitbucket.org/acme", + url: "https://bitbucket.org/acme/repo/issues/42/old-title", + storedUrl: "https://bitbucket.org/acme/repo/issues/42/current-title", + }, + { + provider: "bitbucket", + domainName: "acme", + url: "https://bitbucket.org/acme/repo/issues/42", + storedUrl: "https://bitbucket.org/acme/repo/issues/42", + }, + { + provider: "vsts", + domainName: "https://acme.visualstudio.com", + url: "https://dev.azure.com/acme/project/_workitems/edit/42", + storedUrl: "https://acme.visualstudio.com/project/_workitems/edit/42", + }, + { + provider: "vsts", + domainName: "dev.azure.com/acme", + url: "https://acme.visualstudio.com/_workitems/edit/42", + storedUrl: "https://dev.azure.com/acme/project/_workitems/edit/42", + }, +]; + +function useIntegrations(integrations: IssueIntegration[]) { + mswServer.use(http.get(endpoint, () => HttpResponse.json(integrations))); +} + +function usePut(status = 201) { + const writes: { integrationId: string; body: unknown }[] = []; + mswServer.use( + http.put( + `${endpoint}:integrationId/`, + async ({ request, params: route }) => { + const body = await request.json(); + writes.push({ integrationId: String(route.integrationId), body }); + return HttpResponse.json( + { + id: "900", + key: "acme/repo#42", + url: "https://github.com/acme/repo/issues/42", + }, + { status }, + ); + }, + ), + ); + return writes; +} + +function useDelete(status = 204) { + const writes: string[] = []; + mswServer.use( + http.delete(`${endpoint}:integrationId/`, ({ request }) => { + writes.push(request.url); + return new HttpResponse(null, { status }); + }), + ); + return writes; +} + +describe("linkExternalIssue", () => { + it("rejects App fields on native links instead of silently ignoring them", async () => { + await expect( + linkExternalIssue(api, { + ...params, + externalIssueUrl: "https://github.com/acme/repo/issues/42", + fields: { issue: "99" }, + }), + ).rejects.toThrow("fields are only supported for Sentry App links"); + }); + + it.each(nativeCases)( + "passes a full URL to the selected $provider integration: $url", + async ({ provider, domainName, url }) => { + useIntegrations([ + integration({ provider: { key: provider }, domainName }), + ]); + const writes = usePut(); + const result = await linkExternalIssue(api, { + ...params, + externalIssueUrl: url, + }); + expect(writes).toEqual([ + { integrationId: "1", body: { externalIssue: url } }, + ]); + expect(result).toMatchObject({ provider, status: "linked" }); + }, + ); + + it.each([201, 200])( + "uses HTTP %i to report whether the backend created the association", + async (status) => { + const externalIssueUrl = "https://github.com/acme/repo/issues/42"; + useIntegrations([ + integration({ + externalIssues: [ + { id: "900", key: "acme/repo#42", url: externalIssueUrl }, + ], + }), + ]); + const writes = usePut(status); + expect( + await linkExternalIssue(api, { ...params, externalIssueUrl }), + ).toEqual({ + url: externalIssueUrl, + displayName: "acme/repo#42", + provider: "github", + status: status === 201 ? "linked" : "already_linked", + }); + expect(writes).toHaveLength(1); + }, + ); + + it("requires integrationId for overlapping GitLab installations and never tries candidates", async () => { + useIntegrations([ + integration({ + provider: { key: "gitlab" }, + domainName: "gitlab.com/acme", + }), + integration({ + id: "2", + provider: { key: "gitlab" }, + domainName: "gitlab.com/acme/backend", + }), + ]); + const writes = usePut(); + const input = { + ...params, + externalIssueUrl: "https://gitlab.com/acme/backend/repo/-/issues/42", + }; + await expect(linkExternalIssue(api, input)).rejects.toThrow( + "Provide integrationId", + ); + expect(writes).toEqual([]); + await linkExternalIssue(api, { ...input, integrationId: "2" }); + expect(writes).toEqual([ + { integrationId: "2", body: { externalIssue: input.externalIssueUrl } }, + ]); + }); + + it.each([ + { + provider: "github", + domainName: "github.com/other", + url: "https://github.com/acme/repo/issues/42", + }, + { + provider: "github", + domainName: null, + url: "https://internal.example.com/acme/repo/issues/42", + }, + { + provider: "github_enterprise", + domainName: "github.example.com/acme", + url: "https://github.example.com:8443/acme/repo/issues/42", + }, + { + provider: "gitlab", + domainName: "gitlab.com/acme", + url: "https://gitlab.com/acme-other/repo/-/issues/42", + }, + { + provider: "jira", + domainName: "other.atlassian.net", + url: "https://acme.atlassian.net/browse/ENG-42", + }, + { + provider: "bitbucket", + domainName: "bitbucket.org/other", + url: "https://bitbucket.org/acme/repo/issues/42", + }, + { + provider: "vsts", + domainName: "dev.azure.com/other", + url: "https://acme.visualstudio.com/_workitems/edit/42", + }, + { + provider: "unsupported", + domainName: "github.com/acme", + url: "https://github.com/acme/repo/issues/42", + }, + ])( + "rejects a mismatched $provider installation before PUT: $url", + async ({ provider, domainName, url }) => { + useIntegrations([ + integration({ provider: { key: provider }, domainName }), + ]); + const writes = usePut(); + await expect( + linkExternalIssue(api, { + ...params, + externalIssueUrl: url, + integrationId: "1", + }), + ).rejects.toThrow("does not match"); + expect(writes).toEqual([]); + }, + ); + + it.each([ + "file:///acme/repo/issues/42", + "javascript:alert(1)", + "https://user:secret@github.com/acme/repo/issues/42", + "https://github.com/acme/repo/issues/%ZZ", + "https://github.com/acme/repo/issues/42%3Ffake", + ])( + "rejects unsafe URL %s before reading integrations", + async (externalIssueUrl) => { + const requests: string[] = []; + mswServer.use( + http.all("https://sentry.io/api/0/*", ({ request }) => { + requests.push(request.url); + return HttpResponse.json([]); + }), + ); + await expect( + linkExternalIssue(api, { ...params, externalIssueUrl }), + ).rejects.toBeInstanceOf(UserInputError); + expect(requests).toEqual([]); + }, + ); + + it.each([400, 403, 404, 409])( + "leaves URL validation and HTTP %i errors to the backend", + async (status) => { + const externalIssueUrl = + status === 400 + ? "https://github.com/acme/repo/commit/abc123" + : "https://github.com/acme/repo/issues/42"; + useIntegrations([integration()]); + mswServer.use( + http.put(`${endpoint}1/`, async ({ request }) => { + expect(await request.json()).toEqual({ + externalIssue: externalIssueUrl, + }); + return HttpResponse.json({ detail: "Cannot link" }, { status }); + }), + ); + await expect( + linkExternalIssue(api, { + ...params, + externalIssueUrl, + }), + ).rejects.toMatchObject({ status }); + }, + ); +}); + +describe("unlinkExternalIssue", () => { + it.each(nativeCases)( + "finds equivalent $provider URLs and deletes the internal association ID: $url", + async ({ provider, domainName, url, storedUrl }) => { + useIntegrations([ + integration({ + provider: { key: provider }, + domainName, + externalIssues: [{ id: "900", key: "42", url: storedUrl }], + }), + ]); + const writes = useDelete(); + expect( + await unlinkExternalIssue(api, { ...params, externalIssueUrl: url }), + ).toMatchObject({ url: storedUrl, provider, status: "not_linked" }); + expect(writes).toEqual([`${endpoint}1/?externalIssue=900`]); + }, + ); + + it.each([ + [ + "https://jira.example.com/jira/browse/ENG-1?selectedIssue=invalid&selectedIssue=eng-2&selectedIssue=ENG-1", + "902", + ], + [ + "https://jira.example.com/jira-archive/browse/ENG-2?selectedIssue=ENG-2", + null, + ], + ["https://other.example.com/jira/browse/ENG-2?selectedIssue=ENG-2", null], + ])( + "matches Jira's selectedIssue precedence and installation boundary: %s", + async (externalIssueUrl, expectedId) => { + useIntegrations([ + integration({ + provider: { key: "jira_server" }, + domainName: "jira.example.com", + externalIssues: [1, 2].map((number) => ({ + id: `90${number}`, + key: `ENG-${number}`, + url: `https://jira.example.com/jira/browse/ENG-${number}`, + })), + }), + ]); + const writes = useDelete(); + const result = await unlinkExternalIssue(api, { + ...params, + integrationId: "1", + externalIssueUrl, + }); + expect(result.status).toBe("not_linked"); + expect(writes).toEqual( + expectedId ? [`${endpoint}1/?externalIssue=${expectedId}`] : [], + ); + }, + ); + + it("does not delete when only a different external issue is linked", async () => { + mswServer.use(http.get(appEndpoint, () => HttpResponse.json([]))); + useIntegrations([ + integration({ + externalIssues: [ + { + id: "900", + key: "acme/repo#99", + url: "https://github.com/acme/repo/issues/99", + }, + ], + }), + ]); + const writes = useDelete(); + expect( + await unlinkExternalIssue(api, { + ...params, + externalIssueUrl: "https://github.com/acme/repo/issues/42", + }), + ).toMatchObject({ status: "not_linked" }); + expect(writes).toEqual([]); + }); + + it("unlinks a stored association after its installation was disabled or reconfigured", async () => { + const externalIssueUrl = "https://github.com/acme/repo/issues/42"; + useIntegrations([ + integration({ + status: "disabled", + domainName: "github.com/new-owner", + externalIssues: [ + { id: "900", key: "acme/repo#42", url: externalIssueUrl }, + ], + }), + ]); + const writes = useDelete(); + await unlinkExternalIssue(api, { ...params, externalIssueUrl }); + expect(writes).toEqual([`${endpoint}1/?externalIssue=900`]); + }); + + it("finds a custom App association by its URL without requiring appSlug", async () => { + const externalIssueUrl = "https://tracker.example.com/tasks/42"; + useIntegrations([]); + const writes: string[] = []; + mswServer.use( + http.get(appEndpoint, () => + HttpResponse.json([ + { + id: "700", + issueId: "123", + serviceType: "custom-tracker", + displayName: "Task 42", + webUrl: externalIssueUrl, + }, + ]), + ), + http.delete(`${appEndpoint}700/`, ({ request }) => { + writes.push(request.url); + return new HttpResponse(null, { status: 204 }); + }), + ); + expect( + await unlinkExternalIssue(api, { ...params, externalIssueUrl }), + ).toMatchObject({ + provider: "custom-tracker", + status: "not_linked", + }); + expect(writes).toEqual([`${appEndpoint}700/`]); + }); + + it("rejects multiple matching links until an integration is selected", async () => { + const externalIssueUrl = "https://github.com/acme/repo/issues/42"; + const externalIssues = [ + { id: "900", key: "acme/repo#42", url: externalIssueUrl }, + ]; + useIntegrations([ + integration({ externalIssues }), + integration({ id: "2", externalIssues }), + ]); + const writes = useDelete(); + await expect( + unlinkExternalIssue(api, { ...params, externalIssueUrl }), + ).rejects.toThrow("Provide integrationId"); + expect(writes).toEqual([]); + await unlinkExternalIssue(api, { + ...params, + externalIssueUrl, + integrationId: "2", + }); + expect(writes).toEqual([`${endpoint}2/?externalIssue=900`]); + }); + + it.each(["lookup", "delete"])( + "does not swallow a 404 during %s", + async (step) => { + const externalIssueUrl = "https://github.com/acme/repo/issues/42"; + useIntegrations([ + integration({ + externalIssues: [ + { id: "900", key: "acme/repo#42", url: externalIssueUrl }, + ], + }), + ]); + useDelete(404); + if (step === "lookup") + mswServer.use( + http.get(endpoint, () => new HttpResponse(null, { status: 404 })), + ); + await expect( + unlinkExternalIssue(api, { ...params, externalIssueUrl }), + ).rejects.toMatchObject({ status: 404 }); + }, + ); +}); 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 000000000..0296eab9a --- /dev/null +++ b/packages/mcp-core/src/tools/support/issue-linking/index.ts @@ -0,0 +1,334 @@ +/** Select native integrations locally; Sentry owns URL validation and link mutations. */ +import type { SentryApiService } from "../../../api-client"; +import type { IssueIntegration } from "../../../api-client/types"; +import { UserInputError } from "../../../errors"; +import type { AppIssueLinkParams } from "./app"; +import { inferAppSlug, linkAppIssue, unlinkAppIssue } from "./app"; + +export type IssueLinkParams = AppIssueLinkParams & { + integrationId?: string; +}; + +export type IssueLinkResult = { + url: string; + displayName?: string; + provider?: string; + status: "linked" | "already_linked" | "not_linked"; +}; + +function parseUrl(value: string): URL { + try { + const url = new URL(value); + if ( + !["https:", "http:"].includes(url.protocol) || + url.username || + url.password || + [...value].some( + (character) => character.charCodeAt(0) <= 32 || character === "\\", + ) + ) { + throw new Error("Invalid URL"); + } + const path = pathOf(url); + if ( + /[\\?#]/.test(path) || + path + .split("/") + .slice(1) + .some((part) => !part || part === "." || part === "..") + ) { + throw new Error("Invalid URL path"); + } + return url; + } catch { + throw new UserInputError( + "Provide a valid HTTP(S) external issue URL without credentials.", + ); + } +} + +function pathOf(url: URL): string { + return decodeURIComponent(url.pathname).replace(/\/+$/, ""); +} + +function integrationDomain(integration: IssueIntegration): URL | undefined { + const value = integration.domainName; + if (!value) return undefined; + try { + return new URL(/^https?:\/\//i.test(value) ? value : `https://${value}`); + } catch { + return undefined; + } +} + +function azureAccount(url: URL): string | undefined { + if (url.hostname === "dev.azure.com") + return pathOf(url).split("/")[1]?.toLowerCase(); + if (url.hostname.endsWith(".visualstudio.com")) { + return url.hostname.slice(0, -".visualstudio.com".length); + } + return undefined; +} + +/** Compare references, ignoring presentation-only URL components. */ +function nativeIdentity( + url: URL, + provider: string, + linkedUrl: URL, +): string | undefined { + const path = pathOf(url); + switch (provider) { + case "github": + case "github_enterprise": { + const match = path.match( + /^\/([^/]+\/[^/]+)\/(issues|pull)\/(\d+)(?:\/(files|changes|commits|checks))?$/, + ); + if (!match || (match[2] === "issues" && match[4])) return undefined; + return `${url.origin}/${match[1]!.toLowerCase()}#${match[3]}`; + } + case "gitlab": { + const match = path.match(/^\/(.+?)(?:\/-)?\/issues\/(\d+)$/); + return match ? `${url.origin}/${match[1]}#${match[2]}` : undefined; + } + case "bitbucket": { + const match = path.match(/^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/[^/]+)?$/); + return match ? `${url.origin}/${match[1]}#${match[2]}` : undefined; + } + case "jira": + case "jira_server": { + // Sentry returns canonical /browse/ URLs. Their prefix preserves the + // Jira Server context path, which integration.domainName can omit. + const basePath = linkedUrl.pathname.match( + /^(.*)\/browse\/[^/]+\/?$/, + )?.[1]; + if ( + basePath === undefined || + url.origin !== linkedUrl.origin || + (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) + ) { + return undefined; + } + const issueKey = /^[a-z][a-z\d]*-\d+$/i; + const segments = url.pathname + .slice(basePath.length) + .split("/") + .filter(Boolean); + // Keep selectedIssue precedence aligned with Sentry's parse_jira_issue_key. + const key = + url.searchParams + .getAll("selectedIssue") + .find((value) => issueKey.test(value)) ?? + (["browse", "issues"].includes(segments.at(-2) ?? "") && + issueKey.test(segments.at(-1) ?? "") + ? segments.at(-1) + : undefined); + return key ? `${url.origin}${basePath}/${key.toUpperCase()}` : undefined; + } + case "vsts": { + const account = azureAccount(url); + const accountPath = + url.hostname === "dev.azure.com" ? path.replace(/^\/[^/]+/, "") : path; + const match = accountPath.match( + /^\/(?:[^/]+\/)?_workitems\/edit\/(\d+)$/, + ); + return account && match + ? `${url.protocol}//${account}:${url.port}#${match[1]}` + : undefined; + } + default: + return undefined; + } +} + +function matchesIntegration(url: URL, integration: IssueIntegration): boolean { + if (integration.status && integration.status !== "active") return false; + const provider = integration.provider.key; + const domain = integrationDomain(integration); + const path = pathOf(url); + const owner = path.split("/")[1]; + + switch (provider) { + case "github": + case "github_enterprise": { + if (provider === "github" && url.host !== "github.com") return false; + if (provider === "github_enterprise" && !domain) return false; + if (domain && domain.host !== url.host) return false; + const installedOwner = domain + ? pathOf(domain).split("/")[1] || integration.name + : integration.name; + return owner?.toLowerCase() === installedOwner.toLowerCase(); + } + case "bitbucket": { + if (url.host !== "bitbucket.org") return false; + // Older personal installations store only the username as domainName. + const installedOwner = + domain?.host === "bitbucket.org" + ? pathOf(domain).split("/")[1] + : integration.name; + return owner?.toLowerCase() === installedOwner?.toLowerCase(); + } + case "vsts": + return ( + !!domain && + !!azureAccount(url) && + azureAccount(url) === azureAccount(domain) && + url.port === domain.port + ); + case "gitlab": { + if (!domain || domain.host !== url.host) return false; + const group = pathOf(domain); + // domainName omits a self-hosted instance's deployment prefix. The + // backend validates its base URL; matching groups stay ambiguous. + return !group || path.includes(`${group}/`); + } + case "jira": + case "jira_server": { + if (!domain || domain.host !== url.host) return false; + const prefix = pathOf(domain); + return !prefix || path === prefix || path.startsWith(`${prefix}/`); + } + default: + return false; + } +} + +async function nativeCandidates( + apiService: SentryApiService, + params: IssueLinkParams, + url: URL, +): Promise { + const integrations = await apiService.listIssueIntegrations(params); + const candidates = integrations.filter( + (integration) => + (!params.integrationId || + String(integration.id) === params.integrationId) && + matchesIntegration(url, integration), + ); + if (params.integrationId && !candidates.length) { + throw new UserInputError( + "The selected integration does not match the external issue URL or is not active.", + ); + } + return candidates; +} + +function describeIntegrations(integrations: IssueIntegration[]): string { + return integrations + .slice(0, 5) + .map( + (integration) => + `${integration.id} (${integration.name.slice(0, 60)}, ${integration.provider.key})`, + ) + .join("; "); +} + +function validatedParams(params: IssueLinkParams): { + params: IssueLinkParams; + url: URL; +} { + const externalIssueUrl = params.externalIssueUrl.trim(); + const url = parseUrl(externalIssueUrl); + const appSlug = params.appSlug ?? inferAppSlug(externalIssueUrl); + if (appSlug && params.integrationId) { + throw new UserInputError( + "Provide either appSlug or integrationId to select an integration.", + ); + } + if (!appSlug && params.fields !== undefined) { + throw new UserInputError( + "fields are only supported for Sentry App links. Provide appSlug to select an App.", + ); + } + return { params: { ...params, externalIssueUrl, appSlug }, url }; +} + +/** Link a reference to a resolved numeric Sentry issue, preserving the backend's no-op result. */ +export async function linkExternalIssue( + apiService: SentryApiService, + input: IssueLinkParams, +): Promise { + const { params, url } = validatedParams(input); + if (params.appSlug) return linkAppIssue(apiService, params); + const candidates = await nativeCandidates(apiService, params, url); + if (!candidates.length) { + throw new UserInputError( + "No active issue integration matches this URL. Use a supported issue or pull request URL, or provide appSlug for a Sentry App.", + ); + } + if (candidates.length > 1) { + throw new UserInputError( + `Multiple installed issue integrations match this URL. Provide integrationId to select one: ${describeIntegrations(candidates)}.`, + ); + } + const integration = candidates[0]!; + const { issue, changed } = await apiService.linkNativeExternalIssue({ + ...params, + integrationId: String(integration.id), + }); + return { + url: issue.url || params.externalIssueUrl, + displayName: issue.displayName || issue.key, + provider: integration.provider.key, + status: changed ? "linked" : "already_linked", + }; +} + +/** Find the stored association by URL, then delete it by its internal Sentry ID. */ +export async function unlinkExternalIssue( + apiService: SentryApiService, + input: IssueLinkParams, +): Promise { + const { params, url } = validatedParams(input); + if (params.appSlug) return unlinkAppIssue(apiService, params); + // Existing associations can outlive a disabled or reconfigured installation. + // Match their stored URL, without applying link-time installation eligibility. + const integrations = await apiService.listIssueIntegrations(params); + const candidates = integrations.filter( + (integration) => + !params.integrationId || String(integration.id) === params.integrationId, + ); + if (params.integrationId && !candidates.length) { + throw new UserInputError( + "The selected integration was not found on this issue.", + ); + } + const matches = candidates.flatMap((integration) => + integration.externalIssues.flatMap((issue) => { + if (!issue.url) return []; + let existing: URL; + try { + existing = parseUrl(issue.url); + } catch { + return []; + } + const identity = nativeIdentity(url, integration.provider.key, existing); + return identity && + nativeIdentity(existing, integration.provider.key, existing) === + identity + ? [{ integration, issue }] + : []; + }), + ); + if (matches.length > 1) { + throw new UserInputError( + `Multiple linked issues match this URL. Provide integrationId to select one: ${describeIntegrations(matches.map(({ integration }) => integration))}.`, + ); + } + const match = matches[0]; + if (!match) { + // Custom Apps can own any URL, including one with a native provider shape. + if (!params.integrationId) return unlinkAppIssue(apiService, params); + return { url: params.externalIssueUrl, status: "not_linked" }; + } + await apiService.unlinkNativeExternalIssue({ + ...params, + integrationId: String(match.integration.id), + externalIssueId: String(match.issue.id), + }); + return { + url: match.issue.url || params.externalIssueUrl, + displayName: match.issue.displayName || match.issue.key, + provider: match.integration.provider.key, + status: "not_linked", + }; +} diff --git a/packages/mcp-core/src/tools/support/issue-linking/tool.ts b/packages/mcp-core/src/tools/support/issue-linking/tool.ts new file mode 100644 index 000000000..8e29cefc9 --- /dev/null +++ b/packages/mcp-core/src/tools/support/issue-linking/tool.ts @@ -0,0 +1,78 @@ +/** Shared inputs and scoped issue resolution for the external-reference tools. */ +import { z } from "zod"; +import { apiServiceFromContext } from "../../../internal/tool-helpers/api"; +import { + assertIssueWithinProjectConstraint, + parseIssueParams, +} from "../../../internal/tool-helpers/issue"; +import { + ParamExternalIssueUrl, + ParamIssueShortId, + ParamIssueUrl, + ParamOrganizationSlug, + ParamRegionUrl, +} from "../../../schema"; +import { setOrganizationContext } from "../../../telem/organization"; +import type { ServerContext } from "../../../types"; + +export const issueLinkInputSchema = { + organizationSlug: ParamOrganizationSlug.optional(), + regionUrl: ParamRegionUrl.nullable().default(null), + issueId: ParamIssueShortId.optional(), + issueUrl: ParamIssueUrl.optional(), + externalIssueUrl: ParamExternalIssueUrl, + integrationId: z + .string() + .regex(/^\d+$/) + .optional() + .describe( + "Native integration ID, only needed when multiple installations match the URL.", + ), + appSlug: z + .string() + .trim() + .min(1) + .optional() + .describe( + "Installed Sentry App slug. Inferred for Linear and Shortcut URLs; specify it for other Apps.", + ), +}; + +export const issueLinkOutputSchema = z.object({ + organizationSlug: z.string(), + issueId: z.string(), + issueUrl: z.string(), + externalIssue: z.object({ + url: z.string(), + displayName: z.string().optional(), + provider: z.string().optional(), + }), + status: z.enum(["linked", "already_linked", "not_linked"]), +}); + +/** Resolve the numeric issue ID and enforce project scope before any link mutation. */ +export async function resolveIssueLinkContext( + params: { + organizationSlug?: string; + regionUrl?: string | null; + issueId?: string; + issueUrl?: string; + }, + context: ServerContext, +) { + const { organizationSlug, issueId } = parseIssueParams({ + ...params, + organizationSlug: + context.constraints.organizationSlug ?? params.organizationSlug, + }); + const apiService = apiServiceFromContext(context, { + regionUrl: context.constraints.regionUrl ?? params.regionUrl ?? undefined, + }); + setOrganizationContext(organizationSlug); + const issue = await apiService.getIssue({ organizationSlug, issueId }); + assertIssueWithinProjectConstraint({ + issue, + projectSlug: context.constraints.projectSlug, + }); + return { apiService, organizationSlug, issue }; +}