diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a95dc4ca..d47cc6a0 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -48,8 +48,8 @@ { "name": "bitwarden-atlassian-tools", "source": "./plugins/bitwarden-atlassian-tools", - "version": "2.4.0", - "description": "Read-only Atlassian access via MCP server with deep Jira issue research skill, JQL search, Confluence pages, CQL search, and attachments" + "version": "2.5.0", + "description": "Atlassian access via MCP server with deep Jira issue research skill, JQL search, Confluence pages, CQL search, attachments, and opt-in Jira write tools" }, { "name": "bitwarden-security-engineer", diff --git a/.cspell.json b/.cspell.json index 1d9466ad..66fd679f 100644 --- a/.cspell.json +++ b/.cspell.json @@ -3,6 +3,7 @@ "version": "0.2", "words": [ "accum", + "acli", "adf", "ADRs", "AKIA", @@ -27,6 +28,7 @@ "CODEOWNERS", "Confluence", "CQL", + "createmeta", "customfield", "cvss", "Dashlane", @@ -66,6 +68,7 @@ "issueIdOrKey", "issuelinks", "issuetype", + "issuetypes", "Jira", "JQL", "keyserver", diff --git a/README.md b/README.md index 3c15398e..e89f6979 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A curated collection of plugins for AI-assisted development at Bitwarden. Enable | [bitwarden-ai-telemetry](plugins/bitwarden-ai-telemetry/) | 1.1.0 | Claude Code hooks emitting metadata-only AI-usage telemetry (identity, git-linkage, MCP) via OTLP | | [bitwarden-tech-lead](plugins/bitwarden-tech-lead/) | 3.0.0 | Tech lead for technical planning, architecture coherence, and surfacing patterns to Technical Strategy Ideas | | [bitwarden-shepherd](plugins/bitwarden-shepherd/) | 1.0.1 | Champion of a technical strategy — shepherds a TSI through evaluation into the funnel, then through to adoption | -| [bitwarden-atlassian-tools](plugins/bitwarden-atlassian-tools/) | 2.4.0 | Read-only Atlassian access via MCP server with deep Jira issue research skill | +| [bitwarden-atlassian-tools](plugins/bitwarden-atlassian-tools/) | 2.5.0 | Atlassian access via MCP server with deep Jira issue research skill and opt-in Jira write tools | | [bitwarden-code-review](plugins/bitwarden-code-review/) | 1.13.1 | Autonomous code review agent following Bitwarden engineering standards with GitHub integration | | [bitwarden-delivery-tools](plugins/bitwarden-delivery-tools/) | 2.4.0 | Delivery lifecycle skills: initiative funnel navigation, work transitions, tech breakdowns and task decomposition, commits, PRs, preflight, labeling | | [bitwarden-designer](plugins/bitwarden-designer/) | 0.1.0 | Product designer persona: Code of Conduct and 30/60/90 critique, critique facilitation; dispatches into bitwarden-design-tools | diff --git a/plugins/bitwarden-atlassian-tools/.claude-plugin/plugin.json b/plugins/bitwarden-atlassian-tools/.claude-plugin/plugin.json index 379c417d..0cdf27a4 100644 --- a/plugins/bitwarden-atlassian-tools/.claude-plugin/plugin.json +++ b/plugins/bitwarden-atlassian-tools/.claude-plugin/plugin.json @@ -1,9 +1,9 @@ { "name": "bitwarden-atlassian-tools", - "version": "2.4.0", - "description": "Read-only Atlassian access via MCP server with deep Jira issue research skill, JQL search, Confluence pages, CQL search, and attachments", + "version": "2.5.0", + "description": "Atlassian access via MCP server with deep Jira issue research skill, JQL search, Confluence pages, CQL search, attachments, and opt-in Jira write tools", "author": { "name": "Bitwarden" }, - "keywords": ["atlassian", "jira", "confluence", "mcp", "read-only", "cql"] + "keywords": ["atlassian", "jira", "confluence", "mcp", "cql"] } diff --git a/plugins/bitwarden-atlassian-tools/.mcp.json b/plugins/bitwarden-atlassian-tools/.mcp.json index 6bf20c3d..d136ac34 100644 --- a/plugins/bitwarden-atlassian-tools/.mcp.json +++ b/plugins/bitwarden-atlassian-tools/.mcp.json @@ -11,6 +11,7 @@ "ATLASSIAN_CLOUD_ID": "${ATLASSIAN_CLOUD_ID}", "ATLASSIAN_EMAIL": "${ATLASSIAN_EMAIL}", "ATLASSIAN_JIRA_READ_ONLY_TOKEN": "${ATLASSIAN_JIRA_READ_ONLY_TOKEN}", + "ATLASSIAN_JIRA_WRITE_TOKEN": "${ATLASSIAN_JIRA_WRITE_TOKEN}", "ATLASSIAN_CONFLUENCE_READ_ONLY_TOKEN": "${ATLASSIAN_CONFLUENCE_READ_ONLY_TOKEN}" } } diff --git a/plugins/bitwarden-atlassian-tools/CHANGELOG.md b/plugins/bitwarden-atlassian-tools/CHANGELOG.md index b20db999..9e70bc95 100644 --- a/plugins/bitwarden-atlassian-tools/CHANGELOG.md +++ b/plugins/bitwarden-atlassian-tools/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to the Bitwarden Atlassian Tools plugin will be documented i The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.5.0] - 2026-08-03 + +### Added + +- **`get_create_fields` MCP tool** (read-only) — reports a project's creatable issue types, and for a given type every field on the create screen with its field id, required flag, and allowed values. Lets callers discover a project's shape instead of hardcoding it, which matters because Bitwarden's projects differ: PM and SM expose an Acceptance criteria field, QA and VULN do not; VULN has no Story type; PLT's only creatable type is `Platform Initiative`. +- **`create_issue` MCP tool** (write, opt-in) — creates a single work item in any project. Defaults to a dry run that returns the exact payload without sending it; a live create requires an explicit `dryRun: false`. Carries no project-specific field knowledge: the issue type is a name Jira resolves, and anything beyond project/type/summary/description/parent/labels is passed through a `fields` object keyed by Jira field id. +- **`link_issues` MCP tool** (write, opt-in) — links two work items. For a dependency it takes `blockerKey` and `blockedKey` and applies Jira's inward/outward mapping internally, so the direction cannot be inverted by argument order. Also defaults to a dry run. +- **Optional `ATLASSIAN_JIRA_WRITE_TOKEN`** — write capability is opt-in per install. The write tools are always listed and their dry-run paths always work; without this variable, a live write refuses to execute. + ## [2.4.0] - 2026-07-24 ### Added diff --git a/plugins/bitwarden-atlassian-tools/README.md b/plugins/bitwarden-atlassian-tools/README.md index cced860f..aae03fb5 100644 --- a/plugins/bitwarden-atlassian-tools/README.md +++ b/plugins/bitwarden-atlassian-tools/README.md @@ -2,7 +2,9 @@ ## Overview -Read-only Atlassian access via a custom MCP server providing Jira issue retrieval, JQL search, Confluence page reading, CQL search, and attachment downloads. All operations are read-only — the server never creates, updates, or deletes any Atlassian resource. +Atlassian access via a custom MCP server providing Jira issue retrieval, JQL search, Confluence page reading, CQL search, and attachment downloads. + +Read access is the default and always available. Jira write access (creating work items and links) is **opt-in per install**: `create_issue` and `link_issues` are always listed and their dry-run preview always works, but without `ATLASSIAN_JIRA_WRITE_TOKEN` a live write refuses to execute. Confluence remains read-only with no write path. ## Installation @@ -14,6 +16,10 @@ export ATLASSIAN_CLOUD_ID="your-cloud-id" export ATLASSIAN_EMAIL="your-email@company.com" export ATLASSIAN_JIRA_READ_ONLY_TOKEN="your-jira-scoped-token" export ATLASSIAN_CONFLUENCE_READ_ONLY_TOKEN="your-confluence-scoped-token" + +# Optional — enables the Jira write tools (create_issue, link_issues). +# Omit to keep this install read-only. +export ATLASSIAN_JIRA_WRITE_TOKEN="your-jira-write-scoped-token" ``` API requests are routed through the Atlassian API gateway (`api.atlassian.com`), which supports both classic and scoped API tokens. @@ -67,17 +73,59 @@ The Jira Agile (Software) endpoints behind `list_boards`, `get_sprints`, and `ge ### Jira -| Tool | Purpose | -| ------------------------ | -------------------------------------------------------------------- | -| `get_issue` | Read a Jira issue by key or ID | -| `search_issues` | Search issues using JQL | -| `get_issue_comments` | Get comments for an issue | -| `get_issue_remote_links` | Get remote links for an issue (Confluence pages, PRs, external URLs) | -| `list_projects` | List accessible Jira projects | -| `list_boards` | List Agile boards, optionally filtered by project | -| `get_sprints` | List sprints for a board (filter by active/future/closed) | -| `get_sprint_issues` | List all issues in a sprint | -| `download_attachment` | Download a Jira attachment as Base64 | +| Tool | Purpose | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| `get_issue` | Read a Jira issue by key or ID | +| `search_issues` | Search issues using JQL | +| `get_issue_comments` | Get comments for an issue | +| `get_issue_remote_links` | Get remote links for an issue (Confluence pages, PRs, external URLs) | +| `list_projects` | List accessible Jira projects | +| `list_boards` | List Agile boards, optionally filtered by project | +| `get_sprints` | List sprints for a board (filter by active/future/closed) | +| `get_sprint_issues` | List all issues in a sprint | +| `download_attachment` | Download a Jira attachment as Base64 | +| `get_create_fields` | Report a project's creatable issue types, and a type's create-screen fields with ids, required flags, and allowed values | + +### Jira (write, requires `ATLASSIAN_JIRA_WRITE_TOKEN`) + +Both tools default to a dry run that returns the exact payload without sending it. A live write requires an explicit `dryRun: false`. Dry runs need no write token. + +| Tool | Purpose | +| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `create_issue` | Create one work item in any project. Carries no project-specific field knowledge: pass anything beyond the common core through `fields`, keyed by field id from `get_create_fields` | +| `link_issues` | Link two work items. For a dependency, takes `blockerKey` and `blockedKey` and maps them onto Jira's inward/outward sides internally so the direction cannot be inverted | + +Write tokens need write scopes in addition to the read scopes above: + +| Scope | Required for | +| ----------------------------- | --------------------------------------------------------------------------------- | +| `read:issue:jira` | `create_issue` | +| `read:issue:jira-software` | `create_issue` | +| `write:issue:jira` | `create_issue`, `link_issues` | +| `write:issue:jira-software` | `create_issue`, `link_issues` | +| `write:issue-link:jira` | `link_issues` | +| `write:comment:jira` | `create_issue`, `link_issues` (required even though neither tool sends a comment) | +| `write:comment.property:jira` | `create_issue` (required even though it never sends a comment) | +| `write:attachment:jira` | `create_issue` (required even though it never sends an attachment) | + +Grant the **whole** set, not a subset — a token holding only some of them fails every write with `401 Unauthorized; scope does not match`. For a scoped write token covering both write tools, that's: + +``` +read:issue:jira +read:issue:jira-software +write:attachment:jira +write:comment.property:jira +write:comment:jira +write:issue-link:jira +write:issue:jira-software +write:issue:jira +``` + +`get_create_fields` needs no additional scope. It calls the createmeta endpoints, which the existing read-only token already satisfies. + +Token scope is separate from Jira project permission. Creating also requires the **Create Issues** permission in the target project, and linking requires **Link Issues**. In a project where the user lacks Create Issues, Jira answers `You cannot create issues in this project`, which `get_create_fields` reports as an ordinary result rather than an error. + +A leaked write token permits more than these two tools use: `write:comment:jira`, `write:comment.property:jira`, and `write:attachment:jira` are granted only because Atlassian rejects a narrower scope set, so the token can also add comments and attachments across every project the user can reach. Treat this token as higher-blast-radius than the read-only token and rotate it accordingly. ### Confluence @@ -98,6 +146,7 @@ The MCP tools are available as `mcp__bitwarden-atlassian__`. Examples - Search with JQL: `mcp__bitwarden-atlassian__search_issues` with `jql: "project = PROJ AND status = Open"` - Read a Confluence page: `mcp__bitwarden-atlassian__get_confluence_page` with `pageId: "123456789"` - Search Confluence: `mcp__bitwarden-atlassian__search_confluence_cql` with `cql: "space = EN AND text ~ \"search term\""` +- Preview a ticket before creating it: `mcp__bitwarden-atlassian__create_issue` with `project: "PM"`, `issueType: "Story"`, `summary: "Add CSV export to the item list"` — omit `dryRun` (defaults to `true`) to get the payload back without creating anything ## Skills diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/package.json b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/package.json index b5aee950..75ccfb13 100644 --- a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/package.json +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/atlassian-tools-mcp", "version": "1.0.0", - "description": "Read-only Jira MCP server for issue retrieval, JQL search, comments, and project discovery", + "description": "Jira MCP server for issue retrieval, JQL search, comments, project discovery, and opt-in issue creation/linking", "type": "module", "main": "build/index.js", "bin": { @@ -20,7 +20,6 @@ "jira", "model-context-protocol", "atlassian", - "read-only", "jql" ], "author": "Bitwarden", diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/index.ts b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/index.ts index cff11e98..3f143666 100644 --- a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/index.ts +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/index.ts @@ -2,7 +2,9 @@ /** * Atlassian MCP Server - * Read-only MCP server for Jira and Confluence integration with Claude Code + * MCP server for Jira and Confluence integration with Claude Code. + * Confluence is read-only; Jira read is always on, and Jira write is opt-in + * per install via ATLASSIAN_JIRA_WRITE_TOKEN. */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; @@ -34,6 +36,25 @@ import listSpaces from "./tools/list-spaces.js"; // Cross-domain tools import downloadAttachment from "./tools/download-attachment.js"; +// Jira write tools +// +// These are registered unconditionally so that their dry-run path, which sends +// no request and needs no write credential, is available on a read-only install. +// Live execution is gated inside each handler on ATLASSIAN_JIRA_WRITE_TOKEN, so +// an install without that token behaves exactly as it does today. +// +// The alternative is gating registration itself on hasJiraWriteToken(), which +// hides the tools entirely from read-only installs. That is arguably the better +// production choice; it is not used here because it would also hide the dry-run +// preview, which should stay available so anyone can evaluate the write tools +// before minting a token. +// get_create_fields is read-only and useful on its own: it answers what a given +// project requires, which is what keeps the write tools free of any hardcoded +// per-project field knowledge. +import getCreateFields from "./tools/get-create-fields.js"; +import createIssue from "./tools/create-issue.js"; +import linkIssues from "./tools/link-issues.js"; + const tools: ToolDefinition[] = [ getIssue, getIssueComments, @@ -50,6 +71,9 @@ const tools: ToolDefinition[] = [ searchConfluenceCql, listSpaces, downloadAttachment, + getCreateFields, + createIssue, + linkIssues, ]; async function main() { diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/auth-write.spec.ts b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/auth-write.spec.ts new file mode 100644 index 00000000..49fd69bb --- /dev/null +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/auth-write.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +import { loadJiraConfig, hasJiraWriteToken } from "./auth.js"; + +const ENV_KEYS = [ + "ATLASSIAN_CLOUD_ID", + "ATLASSIAN_EMAIL", + "ATLASSIAN_JIRA_READ_ONLY_TOKEN", + "ATLASSIAN_JIRA_WRITE_TOKEN", +] as const; + +describe("Jira write-token access mode", () => { + const saved: Record = {}; + + beforeEach(() => { + for (const key of ENV_KEYS) { + saved[key] = process.env[key]; + delete process.env[key]; + } + process.env.ATLASSIAN_CLOUD_ID = "test-cloud-id"; + process.env.ATLASSIAN_EMAIL = "user@example.com"; + process.env.ATLASSIAN_JIRA_READ_ONLY_TOKEN = "read-token"; + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = saved[key]; + } + } + }); + + it("reports no write token on a read-only install", () => { + expect(hasJiraWriteToken()).toBe(false); + }); + + it("still loads read config on a read-only install", () => { + expect(loadJiraConfig().apiToken).toBe("read-token"); + }); + + it("defaults to the read token when no mode is given", () => { + process.env.ATLASSIAN_JIRA_WRITE_TOKEN = "write-token"; + + expect(loadJiraConfig().apiToken).toBe("read-token"); + }); + + it("uses the write token in write mode", () => { + process.env.ATLASSIAN_JIRA_WRITE_TOKEN = "write-token"; + + expect(hasJiraWriteToken()).toBe(true); + expect(loadJiraConfig("write").apiToken).toBe("write-token"); + }); + + it("throws naming the write variable when write mode has no token", () => { + expect(() => loadJiraConfig("write")).toThrow(/ATLASSIAN_JIRA_WRITE_TOKEN/); + }); + + it("treats an unexpanded template placeholder as absent", () => { + process.env.ATLASSIAN_JIRA_WRITE_TOKEN = "${ATLASSIAN_JIRA_WRITE_TOKEN}"; + + expect(hasJiraWriteToken()).toBe(false); + }); +}); diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/auth.ts b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/auth.ts index 5634250b..81104834 100644 --- a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/auth.ts +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/auth.ts @@ -17,19 +17,44 @@ function resolveEnv(name: string): string | undefined { return value; } +/** + * Access mode for a Jira client. + * + * "read" uses ATLASSIAN_JIRA_READ_ONLY_TOKEN, which every install already sets. + * "write" uses ATLASSIAN_JIRA_WRITE_TOKEN, which is optional: when it is absent, + * the write tools are still listed and their dry-run preview still works, but a + * live write refuses to execute. Write capability is therefore opt-in per + * install rather than shipped to everyone. + */ +export type JiraAccessMode = "read" | "write"; + +const TOKEN_ENV_VAR: Record = { + read: "ATLASSIAN_JIRA_READ_ONLY_TOKEN", + write: "ATLASSIAN_JIRA_WRITE_TOKEN", +}; + +/** + * Whether this install has been given a write-capable Jira token. + */ +export function hasJiraWriteToken(): boolean { + return resolveEnv(TOKEN_ENV_VAR.write) !== undefined; +} + /** * Load JIRA configuration from environment variables + * @param mode - Which token to authenticate with. Defaults to read-only. * @throws {Error} If required environment variables are missing */ -export function loadJiraConfig(): JiraConfig { +export function loadJiraConfig(mode: JiraAccessMode = "read"): JiraConfig { const cloudId = resolveEnv("ATLASSIAN_CLOUD_ID"); const email = resolveEnv("ATLASSIAN_EMAIL"); - const apiToken = resolveEnv("ATLASSIAN_JIRA_READ_ONLY_TOKEN"); + const tokenVar = TOKEN_ENV_VAR[mode]; + const apiToken = resolveEnv(tokenVar); if (!cloudId || !email || !apiToken) { throw new Error( "Missing required JIRA environment variables. " + - "Please set ATLASSIAN_CLOUD_ID, ATLASSIAN_EMAIL, and ATLASSIAN_JIRA_READ_ONLY_TOKEN", + `Please set ATLASSIAN_CLOUD_ID, ATLASSIAN_EMAIL, and ${tokenVar}`, ); } diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/client.spec.ts b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/client.spec.ts index 0a0900f3..8ddc3e11 100644 --- a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/client.spec.ts +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/client.spec.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; */ const mockInstanceGet = vi.fn(); +const mockInterceptorUse = vi.fn(); // Mock axios before importing the client vi.mock("axios", () => { @@ -15,7 +16,7 @@ vi.mock("axios", () => { create: vi.fn(() => ({ get: mockInstanceGet, interceptors: { - response: { use: vi.fn() }, + response: { use: mockInterceptorUse }, }, })), get: vi.fn(), @@ -117,3 +118,68 @@ describe("JiraClient.downloadAttachment", () => { await expect(client.downloadAttachment("not-a-url")).rejects.toThrow(); }); }); + +describe("JiraClient error handling", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function getErrorHandler(): (error: unknown) => Promise { + new JiraClient(); + return mockInterceptorUse.mock.calls[0][1]; + } + + it("surfaces Jira's field-level errors from the errors map", async () => { + const errorHandler = getErrorHandler(); + + const axiosError = { + response: { + status: 400, + data: { + errorMessages: [], + errors: { + customfield_10192: + "Field 'customfield_10192' cannot be set. It is not on the appropriate screen, or unknown.", + }, + }, + }, + message: "Request failed with status code 400", + }; + + await expect(errorHandler(axiosError)).rejects.toThrow( + "customfield_10192: Field 'customfield_10192' cannot be set. It is not on the appropriate screen, or unknown.", + ); + }); + + it("combines errorMessages and field errors when both are present", async () => { + const errorHandler = getErrorHandler(); + + const axiosError = { + response: { + status: 400, + data: { + errorMessages: ["Issue does not exist"], + errors: { summary: "Summary is required." }, + }, + }, + message: "Request failed with status code 400", + }; + + await expect(errorHandler(axiosError)).rejects.toThrow( + "Issue does not exist; summary: Summary is required.", + ); + }); + + it("falls back to the axios message when neither errorMessages nor errors is present", async () => { + const errorHandler = getErrorHandler(); + + const axiosError = { + response: { status: 400, data: {} }, + message: "Request failed with status code 400", + }; + + await expect(errorHandler(axiosError)).rejects.toThrow( + "JIRA API error (400): Request failed with status code 400", + ); + }); +}); diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/client.ts b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/client.ts index d235dbcd..b9efca17 100644 --- a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/client.ts +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/client.ts @@ -4,7 +4,7 @@ */ import axios, { AxiosInstance, AxiosError } from "axios"; -import { loadJiraConfig, getJiraHeaders } from "./auth.js"; +import { loadJiraConfig, getJiraHeaders, JiraAccessMode } from "./auth.js"; import { JiraConfig, JiraSearchParams, @@ -17,6 +17,8 @@ import { JiraSprintsResponse, JiraSprintIssuesResponse, JiraRemoteLink, + JiraCreateMetaIssueType, + JiraCreateMetaField, } from "./types.js"; export class JiraClient { @@ -25,8 +27,14 @@ export class JiraClient { private readonly API_BASE = "/rest/api/3"; private readonly AGILE_API_BASE = "/rest/agile/1.0"; - constructor() { - this.config = loadJiraConfig(); + /** + * @param mode - "read" (default) authenticates with the read-only token that + * every install sets. "write" authenticates with the optional write token + * and throws if it is absent, so a missing token fails at construction + * rather than on a half-completed sequence of creates. + */ + constructor(mode: JiraAccessMode = "read") { + this.config = loadJiraConfig(mode); this.client = axios.create({ baseURL: this.config.gatewayBaseUrl, headers: getJiraHeaders(this.config), @@ -67,10 +75,19 @@ export class JiraClient { return new Error( "JIRA API rate limit exceeded. Please try again later.", ); - default: - return new Error( - `JIRA API error (${status}): ${data?.errorMessages?.join(", ") || error.message}`, - ); + default: { + // Field-level create/update errors (missing required field, field not on + // screen) land in `errors` keyed by field id, not in `errorMessages`. + const fieldErrors = data?.errors + ? Object.entries(data.errors).map( + ([field, msg]) => `${field}: ${msg}`, + ) + : []; + const detail = + [...(data?.errorMessages ?? []), ...fieldErrors].join("; ") || + error.message; + return new Error(`JIRA API error (${status}): ${detail}`); + } } } @@ -314,4 +331,94 @@ export class JiraClient { return response.data; } + + // ── Write Methods (require a client constructed with mode "write") ── + + /** + * Create a work item. + * + * @param fields - A fully-formed Jira `fields` object. Built by the calling + * tool so that field-ID knowledge lives in one place. + * @returns The created item's key and id. + */ + async createIssue( + fields: Record, + ): Promise<{ id: string; key: string; self: string }> { + const response = await this.client.post<{ + id: string; + key: string; + self: string; + }>(`${this.API_BASE}/issue`, { fields }); + + return response.data; + } + + /** + * Link two work items. + * + * Jira's payload is (inwardIssue, outwardIssue) and reads + * "outwardIssue inwardIssue". For type "Blocks" the + * descriptions are outward "blocks" and inward "is blocked by", so the + * outward issue is the blocker. + * + * Verified read-only against the live PM project: the same Blocks link between + * PM-39203 and PM-38796 is reported from PM-39203 as carrying `inwardIssue: + * PM-38796`, and from PM-38796 as carrying `outwardIssue: PM-39203`. Each end + * names the other and labels it with the other end's role, so the canonical + * pair is outward=PM-39203, inward=PM-38796, i.e. PM-39203 blocks PM-38796. + * + * Note this is the opposite of the acli CLI's `--in`/`--out` mapping. + */ + async createIssueLink(params: { + typeName: string; + outwardKey: string; + inwardKey: string; + }): Promise { + await this.client.post(`${this.API_BASE}/issueLink`, { + type: { name: params.typeName }, + outwardIssue: { key: params.outwardKey }, + inwardIssue: { key: params.inwardKey }, + }); + } + + // ── Create-screen Metadata (read-only, works with either token) ───── + + /** + * List the issue types a project can create, as this user. + * + * Jira answers 404 with "You cannot create issues in this project" when the + * user has no create permission there, which the calling tool surfaces as an + * ordinary result rather than a failure. + */ + async getCreateMetaIssueTypes( + projectKey: string, + ): Promise<{ issueTypes: JiraCreateMetaIssueType[] }> { + const response = await this.client.get<{ + issueTypes: JiraCreateMetaIssueType[]; + }>(`${this.API_BASE}/issue/createmeta/${projectKey}/issuetypes`, { + params: { maxResults: 60 }, + }); + + return response.data; + } + + /** + * Read the create screen's field metadata for a project + issue type. + * + * This is the authority on which fields exist, which are required, and what + * their allowed values are. It varies per project: PM and SM expose an + * Acceptance criteria field, QA and VULN do not, and team-managed projects can + * scope custom fields to themselves. + */ + async getCreateMetaFields( + projectKey: string, + issueTypeId: string, + ): Promise<{ fields: JiraCreateMetaField[] }> { + const response = await this.client.get<{ fields: JiraCreateMetaField[] }>( + `${this.API_BASE}/issue/createmeta/${projectKey}/issuetypes/${issueTypeId}`, + { params: { maxResults: 200 } }, + ); + + return response.data; + } } diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/types.ts b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/types.ts index 4d65cd92..6b79b62a 100644 --- a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/types.ts +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/jira/types.ts @@ -119,6 +119,7 @@ export interface JiraIssueFields { attachment?: JiraAttachment[]; subtasks?: JiraIssue[]; parent?: JiraIssue; + issuelinks?: JiraIssueLink[]; [key: string]: any; // Custom fields } @@ -250,3 +251,49 @@ export interface JiraRemoteLink { }; }; } + +/** + * An entry in an issue's `issuelinks` field. Only one of `inwardIssue` / + * `outwardIssue` is present per entry — each end of a link names the other + * end and labels it with the other end's role (see `JiraClient.createIssueLink`). + */ +export interface JiraIssueLink { + id: string; + self: string; + type: { + id: string; + name: string; + inward: string; + outward: string; + }; + inwardIssue?: { id: string; key: string; self: string }; + outwardIssue?: { id: string; key: string; self: string }; +} + +/** + * An issue type a project can create, from + * `GET /issue/createmeta/{project}/issuetypes`. + */ +export interface JiraCreateMetaIssueType { + id: string; + name: string; + subtask: boolean; + description?: string; +} + +/** + * A field on a project's create screen, from + * `GET /issue/createmeta/{project}/issuetypes/{issueTypeId}`. + */ +export interface JiraCreateMetaField { + fieldId: string; + name: string; + required: boolean; + hasDefaultValue?: boolean; + schema?: { type?: string; custom?: string; items?: string }; + allowedValues?: Array<{ + id?: string; + value?: string; + name?: string; + }>; +} diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/create-issue.spec.ts b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/create-issue.spec.ts new file mode 100644 index 00000000..f74f2706 --- /dev/null +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/create-issue.spec.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const mockPost = vi.fn(); + +vi.mock("axios", () => { + const mockAxios: any = { + create: vi.fn(() => ({ + post: mockPost, + interceptors: { response: { use: vi.fn() } }, + })), + }; + return { default: mockAxios }; +}); + +import createIssueTool, { buildCreateFields } from "./create-issue.js"; +import { + validateInput, + CreateIssueSchema, + RESERVED_FIELD_KEYS, +} from "../utils/validation.js"; + +function fields(input: Record) { + return buildCreateFields(validateInput(CreateIssueSchema, input)); +} + +const story = { + project: "PM", + issueType: "Story", + summary: "Add CSV export to the item list (web)", +}; + +describe("buildCreateFields", () => { + it("sends only the common core when nothing else is supplied", () => { + expect(fields(story)).toEqual({ + project: { key: "PM" }, + issuetype: { name: "Story" }, + summary: "Add CSV export to the item list (web)", + }); + }); + + it("passes an arbitrary field id through untouched", () => { + // customfield_10192 is PM and SM's Acceptance criteria field. The tool does + // not know that; the caller discovers it via get_create_fields. + const result = fields({ + ...story, + fields: { customfield_10192: "Given X When Y Then Z" }, + }); + + expect(result.customfield_10192).toBe("Given X When Y Then Z"); + }); + + it("passes structured option fields through untouched", () => { + const result = fields({ + project: "PM", + issueType: "Epic", + summary: "Data export capability", + fields: { + customfield_11518: { value: "Internal" }, + customfield_11519: { value: "Tech debt" }, + }, + }); + + expect(result.customfield_11518).toEqual({ value: "Internal" }); + expect(result.customfield_11519).toEqual({ value: "Tech debt" }); + }); + + it("accepts an issue type no other project has", () => { + // PLT's only creatable type. An enum of PM's types would reject this. + const result = fields({ + project: "PLT", + issueType: "Platform Initiative", + summary: "Adopt the new telemetry pipeline", + }); + + expect(result.issuetype).toEqual({ name: "Platform Initiative" }); + expect(result.project).toEqual({ key: "PLT" }); + }); + + it("builds description ADF from paragraphs", () => { + const result = fields({ + ...story, + descriptionParagraphs: ["First.", "Second."], + }); + + expect(result.description).toMatchObject({ version: 1, type: "doc" }); + }); + + it("omits description entirely when no paragraphs are supplied", () => { + expect(fields(story)).not.toHaveProperty("description"); + }); + + it("sets parent as a key reference", () => { + expect(fields({ ...story, parentKey: "PM-12345" }).parent).toEqual({ + key: "PM-12345", + }); + }); + + it("omits labels rather than sending an empty array", () => { + expect(fields(story)).not.toHaveProperty("labels"); + expect(fields({ ...story, labels: ["web"] }).labels).toEqual(["web"]); + }); +}); + +describe("CreateIssueSchema", () => { + it("defaults dryRun to true so a forgotten flag previews instead of creating", () => { + expect(validateInput(CreateIssueSchema, story).dryRun).toBe(true); + }); + + it("requires an explicit project, privileging none", () => { + expect(() => + validateInput(CreateIssueSchema, { + issueType: "Story", + summary: "No project given", + }), + ).toThrow(/project/i); + }); + + it("rejects a project key that isn't a bare Jira key", () => { + expect(() => + validateInput(CreateIssueSchema, { + ...story, + project: "../../../rest/api/3/user/search", + }), + ).toThrow(/project key/i); + }); + + it("does not constrain issue type to any project's list", () => { + expect(() => + validateInput(CreateIssueSchema, { + project: "VULN", + issueType: "Security", + summary: "Remediate the reported finding", + }), + ).not.toThrow(); + }); + + it.each(RESERVED_FIELD_KEYS)( + "rejects reserved key %s inside fields", + (key) => { + expect(() => + validateInput(CreateIssueSchema, { + ...story, + fields: { [key]: "anything" }, + }), + ).toThrow(/named parameters/); + }, + ); + + it("rejects a summary over Jira's 255 character limit", () => { + expect(() => + validateInput(CreateIssueSchema, { ...story, summary: "x".repeat(256) }), + ).toThrow(/255/); + }); + + it("rejects a malformed parent key", () => { + expect(() => + validateInput(CreateIssueSchema, { ...story, parentKey: "pm-1" }), + ).toThrow(/valid Jira issue key/); + }); +}); + +describe("create_issue handler", () => { + const ENV_KEYS = [ + "ATLASSIAN_CLOUD_ID", + "ATLASSIAN_EMAIL", + "ATLASSIAN_JIRA_READ_ONLY_TOKEN", + "ATLASSIAN_JIRA_WRITE_TOKEN", + ] as const; + const saved: Record = {}; + + beforeEach(() => { + vi.clearAllMocks(); + for (const key of ENV_KEYS) { + saved[key] = process.env[key]; + delete process.env[key]; + } + process.env.ATLASSIAN_CLOUD_ID = "test-cloud-id"; + process.env.ATLASSIAN_EMAIL = "user@example.com"; + process.env.ATLASSIAN_JIRA_READ_ONLY_TOKEN = "read-token"; + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = saved[key]; + } + } + }); + + it("sends no request on a dry run, even with a write token set", async () => { + process.env.ATLASSIAN_JIRA_WRITE_TOKEN = "write-token"; + + const out = await createIssueTool.handler({ ...story, dryRun: true }); + + expect(out).toContain("Dry run: create Story in PM"); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it("refuses a live create when no write token is set", async () => { + const out = await createIssueTool.handler({ ...story, dryRun: false }); + + expect(out).toContain("Refusing to create"); + expect(out).toContain("ATLASSIAN_JIRA_WRITE_TOKEN"); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it("posts the previewed payload on a live create with a write token", async () => { + process.env.ATLASSIAN_JIRA_WRITE_TOKEN = "write-token"; + mockPost.mockResolvedValueOnce({ + data: { id: "140050", key: "AI-60", self: "https://example" }, + }); + + const out = await createIssueTool.handler({ ...story, dryRun: false }); + + expect(out).toContain("Created **AI-60**"); + expect(mockPost).toHaveBeenCalledOnce(); + expect(mockPost).toHaveBeenCalledWith("/rest/api/3/issue", { + fields: fields(story), + }); + }); + + it("reports the API error rather than throwing on a failed live create", async () => { + process.env.ATLASSIAN_JIRA_WRITE_TOKEN = "write-token"; + mockPost.mockRejectedValueOnce(new Error("JIRA API error (400): boom")); + + const out = await createIssueTool.handler({ ...story, dryRun: false }); + + expect(out).toContain("Error creating issue"); + expect(out).toContain("boom"); + expect(out).not.toContain("full scope"); + }); + + it("hints at partial write-token scopes on a 401, instead of the field hint", async () => { + process.env.ATLASSIAN_JIRA_WRITE_TOKEN = "write-token"; + mockPost.mockRejectedValueOnce( + new Error("JIRA authentication failed. Check your API token and email."), + ); + + const out = await createIssueTool.handler({ ...story, dryRun: false }); + + expect(out).toContain("Error creating issue"); + expect(out).toContain("full scope"); + expect(out).not.toContain("get_create_fields"); + }); +}); diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/create-issue.ts b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/create-issue.ts new file mode 100644 index 00000000..e6655d85 --- /dev/null +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/create-issue.ts @@ -0,0 +1,219 @@ +/** + * Create Issue Tool (write, opt-in) + * + * Creates a single Jira work item in any project. Three properties are deliberate: + * + * - `dryRun` defaults to true. Forgetting the flag yields a payload preview, + * not a live ticket. Dry runs need no write token at all. + * - No project's shape is encoded here. `issueType` is a name Jira resolves, + * and anything beyond the common core rides in `fields` keyed by field id. + * Use `get_create_fields` to discover those ids for the target project. + * - Jira is the validator. Required-field and screen-membership errors come + * back from the API verbatim rather than being second-guessed locally. + */ + +import { JiraClient } from "../jira/client.js"; +import { hasJiraWriteToken } from "../jira/auth.js"; +import { + validateInput, + CreateIssueSchema, + CreateIssueInput, + ToolDefinition, +} from "../utils/validation.js"; +import { buildDescriptionAdf } from "../utils/adf-build.js"; +import { + writeTokenDryRunNote, + writeTokenRefusalMessage, + isWriteAuthError, + writeScopeHint, +} from "../utils/write-guard.js"; + +/** + * Assemble the Jira `fields` object for a create call. + * + * Exported for testing: the payload shape is the whole contract, so it is worth + * asserting directly rather than only through a mocked HTTP call. + */ +export function buildCreateFields( + input: CreateIssueInput, +): Record { + // Passthrough first, so a named parameter wins on collision. The schema already + // rejects reserved keys, so this ordering is a redundant safeguard. + const fields: Record = { ...input.fields }; + + fields.project = { key: input.project }; + fields.issuetype = { name: input.issueType }; + fields.summary = input.summary; + + const description = buildDescriptionAdf(input.descriptionParagraphs); + if (description) { + fields.description = description; + } + + if (input.parentKey) { + fields.parent = { key: input.parentKey }; + } + + if (input.labels.length > 0) { + fields.labels = input.labels; + } + + return fields; +} + +function renderDryRun( + input: CreateIssueInput, + fields: Record, +): string { + const extraKeys = Object.keys(input.fields); + + const lines: string[] = [ + `# Dry run: create ${input.issueType} in ${input.project}`, + "", + "No request was sent. Re-run with `dryRun: false` to create this item.", + "", + "## Summary of what would be created", + "", + `- **Project:** ${input.project}`, + `- **Type:** ${input.issueType}`, + `- **Summary:** ${input.summary}`, + `- **Parent:** ${input.parentKey ?? "(none)"}`, + `- **Labels:** ${input.labels.length > 0 ? input.labels.join(", ") : "(none)"}`, + `- **Description paragraphs:** ${input.descriptionParagraphs.length}`, + `- **Additional fields:** ${extraKeys.length > 0 ? extraKeys.join(", ") : "(none)"}`, + "", + "## Exact request", + "", + "```", + "POST /rest/api/3/issue", + "```", + "", + "```json", + JSON.stringify({ fields }, null, 2), + "```", + "", + ]; + + if (extraKeys.length === 0) { + lines.push( + "> No additional fields were supplied. If this project requires any, or has " + + "a field this item should populate (an acceptance-criteria field, for " + + "example), check `get_create_fields` for " + + `${input.project} / ${input.issueType} first.`, + "", + ); + } + + if (!hasJiraWriteToken()) { + lines.push(...writeTokenDryRunNote("create")); + } + + return lines.join("\n"); +} + +async function handler(input: any): Promise { + const validated = validateInput(CreateIssueSchema, input); + const fields = buildCreateFields(validated); + + if (validated.dryRun) { + return renderDryRun(validated, fields); + } + + if (!hasJiraWriteToken()) { + return writeTokenRefusalMessage("create"); + } + + try { + const client = new JiraClient("write"); + const created = await client.createIssue(fields); + + return [ + `Created **${created.key}** (${validated.issueType} in ${validated.project}).`, + "", + `- Summary: ${validated.summary}`, + `- Parent: ${validated.parentKey ?? "(none)"}`, + `- Id: ${created.id}`, + ].join("\n"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + if (isWriteAuthError(message)) { + return [`Error creating issue: ${message}`, "", writeScopeHint()].join( + "\n", + ); + } + + return [ + `Error creating issue: ${message}`, + "", + "If this names a required or unavailable field, read the create screen " + + `with get_create_fields for ${validated.project} / ${validated.issueType} ` + + "and retry with the corrected `fields`.", + ].join("\n"); + } +} + +const createIssueTool: ToolDefinition = { + name: "create_issue", + description: + "Create a single Jira work item in any project. Defaults to a dry run that " + + "returns the exact payload without sending it; pass dryRun: false to create " + + "for real. Projects differ in their issue types and required fields, so call " + + "get_create_fields first and pass anything project-specific through `fields`. " + + "Requires ATLASSIAN_JIRA_WRITE_TOKEN for live creation.", + inputSchema: { + type: "object", + properties: { + project: { + type: "string", + description: "Project key, e.g. PM, SM, QA, VULN, PLT.", + }, + issueType: { + type: "string", + description: + "Issue type name as it exists in that project, e.g. Story, Task, " + + "Security, 'Platform Initiative'. Jira resolves and validates it.", + }, + summary: { + type: "string", + description: + "Ticket title: imperative verb, outcome, and area. Not a decomposition label.", + maxLength: 255, + }, + descriptionParagraphs: { + type: "array", + items: { type: "string" }, + description: "Description body as paragraphs of plain text.", + }, + parentKey: { + type: "string", + description: "Parent key, e.g. an epic to file this item under.", + pattern: "^[A-Z][A-Z0-9_]+-\\d+$", + }, + labels: { + type: "array", + items: { type: "string" }, + description: "Labels to apply.", + }, + fields: { + type: "object", + description: + "Additional fields keyed by Jira field id (e.g. customfield_10192), " + + "merged into the payload as-is. Discover ids and allowed values with " + + "get_create_fields. Do not pass project, issuetype, summary, " + + "description, parent, or labels here; they have their own parameters.", + additionalProperties: true, + }, + dryRun: { + type: "boolean", + default: true, + description: + "When true (the default), returns the exact payload without sending it.", + }, + }, + required: ["project", "issueType", "summary"], + }, + handler, +}; + +export default createIssueTool; diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/get-create-fields.spec.ts b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/get-create-fields.spec.ts new file mode 100644 index 00000000..d8949c7b --- /dev/null +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/get-create-fields.spec.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * The tool constructs a JiraClient, whose constructor reads env via + * loadJiraConfig(), so auth and axios are both mocked. + */ +const mockGet = vi.fn(); + +vi.mock("axios", () => { + const mockAxios: any = { + create: vi.fn(() => ({ + get: mockGet, + post: vi.fn(), + interceptors: { response: { use: vi.fn() } }, + })), + }; + return { default: mockAxios }; +}); + +vi.mock("../jira/auth.js", () => ({ + loadJiraConfig: () => ({ + cloudId: "test-cloud-id", + gatewayBaseUrl: "https://api.atlassian.com/ex/jira/test-cloud-id", + email: "user@example.com", + apiToken: "read-token", + }), + getJiraHeaders: () => ({ Accept: "application/json" }), + hasJiraWriteToken: () => false, +})); + +const { default: getCreateFields } = await import("./get-create-fields.js"); + +const PM_TYPES = { + data: { + issueTypes: [ + { id: "10027", name: "Story", subtask: false }, + { id: "10000", name: "Epic", subtask: false }, + { id: "10177", name: "Subtask", subtask: true }, + ], + }, +}; + +describe("get_create_fields", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("lists creatable types when no issueType is given", async () => { + mockGet.mockResolvedValueOnce(PM_TYPES); + + const out = await getCreateFields.handler({ project: "PM" }); + + expect(out).toContain("Creatable issue types in PM"); + expect(out).toContain("**Story** (id 10027)"); + expect(out).toContain("Sub-task types:"); + expect(mockGet).toHaveBeenCalledOnce(); + }); + + it("reports the available types when the requested one does not exist", async () => { + mockGet.mockResolvedValueOnce(PM_TYPES); + + const out = await getCreateFields.handler({ + project: "PM", + issueType: "Platform Initiative", + }); + + expect(out).toContain('has no issue type named "Platform Initiative"'); + expect(out).toContain("- Story"); + // Should not have gone on to fetch fields. + expect(mockGet).toHaveBeenCalledOnce(); + }); + + it("matches the issue type name case-insensitively", async () => { + mockGet.mockResolvedValueOnce(PM_TYPES).mockResolvedValueOnce({ + data: { fields: [] }, + }); + + const out = await getCreateFields.handler({ + project: "PM", + issueType: "story", + }); + + expect(out).toContain("PM / Story create screen"); + }); + + it("separates required from optional and surfaces allowed values", async () => { + mockGet.mockResolvedValueOnce(PM_TYPES).mockResolvedValueOnce({ + data: { + fields: [ + { + fieldId: "customfield_11519", + name: "Business Driver", + required: true, + hasDefaultValue: false, + schema: { type: "option" }, + allowedValues: [{ value: "Tech debt" }, { value: "Architecture" }], + }, + { + fieldId: "customfield_10192", + name: "Acceptance criteria", + required: false, + schema: { type: "string" }, + }, + ], + }, + }); + + const out = await getCreateFields.handler({ + project: "PM", + issueType: "Epic", + }); + + expect(out).toContain("## Required"); + expect(out).toContain("`customfield_11519` **Business Driver**"); + expect(out).toContain("allowed: Tech debt, Architecture"); + expect(out).toContain("## Optional"); + expect(out).toContain("`customfield_10192` **Acceptance criteria**"); + }); + + it("does not mangle a field name with 'type' surrounded by spaces", async () => { + mockGet.mockResolvedValueOnce(PM_TYPES).mockResolvedValueOnce({ + data: { + fields: [ + { + fieldId: "customfield_10300", + name: "Issue type detail", + required: false, + schema: { type: "string" }, + }, + ], + }, + }); + + const out = await getCreateFields.handler({ + project: "PM", + issueType: "Epic", + }); + + expect(out).toContain( + "`customfield_10300` **Issue type detail** (optional, type string)", + ); + }); + + it("explains a project the user cannot create in rather than throwing", async () => { + mockGet.mockRejectedValueOnce( + new Error("JIRA resource not found: /rest/api/3/issue/createmeta/ARCH"), + ); + + const out = await getCreateFields.handler({ project: "ARCH" }); + + expect(out).toContain("Cannot read the create screen for ARCH"); + expect(out).toContain("cannot create issues in this project"); + }); + + it("handles a project reporting no creatable types", async () => { + mockGet.mockResolvedValueOnce({ data: { issueTypes: [] } }); + + const out = await getCreateFields.handler({ project: "TES" }); + + expect(out).toContain("no creatable issue types"); + }); + + it("rejects a project key that isn't a bare Jira key, without ever calling the API", async () => { + await expect( + getCreateFields.handler({ + project: "../../../rest/api/3/user/search", + }), + ).rejects.toThrow(/project key/i); + + expect(mockGet).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/get-create-fields.ts b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/get-create-fields.ts new file mode 100644 index 00000000..c0ebdbdd --- /dev/null +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/get-create-fields.ts @@ -0,0 +1,190 @@ +/** + * Get Create Fields Tool (read-only) + * + * Answers "what does this project need in order to create this kind of item?" + * from Jira itself, so no project's field IDs, required fields, or option values + * have to be hardcoded here or remembered in a skill. + * + * Bitwarden's projects differ from one another. PM and SM expose an Acceptance + * criteria field, QA and VULN do not. VULN has no Story type. PLT's only + * creatable type is "Platform Initiative". + */ + +import { JiraClient } from "../jira/client.js"; +import { + validateInput, + GetCreateFieldsSchema, + ToolDefinition, +} from "../utils/validation.js"; +import type { JiraCreateMetaField } from "../jira/types.js"; + +// Jira's own createmeta 404 carries no body, so this is the Jira UI's typical +// wording for a permission denial rather than something extracted from the response. +const NO_CREATE_PERMISSION = "You cannot create issues in this project"; + +function renderIssueTypes( + project: string, + issueTypes: Array<{ id: string; name: string; subtask: boolean }>, +): string { + const lines = [`# Creatable issue types in ${project}`, ""]; + + const standard = issueTypes.filter((t) => !t.subtask); + const subtasks = issueTypes.filter((t) => t.subtask); + + for (const t of standard) { + lines.push(`- **${t.name}** (id ${t.id})`); + } + if (subtasks.length > 0) { + lines.push("", "Sub-task types:"); + for (const t of subtasks) { + lines.push(`- **${t.name}** (id ${t.id})`); + } + } + + lines.push( + "", + "Call again with `issueType` to see that type's fields and requirements.", + ); + + return lines.join("\n"); +} + +function renderField(field: JiraCreateMetaField): string { + const flags = [field.required ? "required" : "optional"]; + if (field.hasDefaultValue) { + flags.push("has default"); + } + flags.push(`type ${field.schema?.type ?? "unknown"}`); + + let line = `- \`${field.fieldId}\` **${field.name}** (${flags.join(", ")})`; + + if (field.allowedValues && field.allowedValues.length > 0) { + const values = field.allowedValues + .map((v) => v.value ?? v.name ?? v.id) + .filter(Boolean); + if (values.length > 0) { + line += `\n - allowed: ${values.join(", ")}`; + } + } + + return line; +} + +function renderFields( + project: string, + issueTypeName: string, + issueTypeId: string, + fields: JiraCreateMetaField[], +): string { + const required = fields.filter((f) => f.required); + const optional = fields.filter((f) => !f.required); + + const lines = [ + `# ${project} / ${issueTypeName} create screen`, + "", + `Issue type id ${issueTypeId}. ${fields.length} fields on the screen.`, + "", + "## Required", + "", + ]; + + if (required.length === 0) { + lines.push("(none)"); + } else { + lines.push(...required.map(renderField)); + } + + lines.push("", "## Optional", ""); + lines.push(...optional.map(renderField)); + lines.push( + "", + "Pass any of these to `create_issue` via `fields`, keyed by field id. " + + "`project`, `issuetype`, `summary`, `description`, `parent`, and `labels` " + + "have their own parameters.", + ); + + return lines.join("\n"); +} + +async function handler(input: any): Promise { + const validated = validateInput(GetCreateFieldsSchema, input); + const client = new JiraClient(); + + let issueTypes; + try { + const meta = await client.getCreateMetaIssueTypes(validated.project); + issueTypes = meta.issueTypes ?? []; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("not found") || message.includes("404")) { + return ( + `Cannot read the create screen for ${validated.project}.\n\n` + + `This project may not exist, or the authenticated user may lack create ` + + `permission in it (Jira's UI describes the latter as "${NO_CREATE_PERMISSION}"). ` + + `This is expected for projects the authenticated user cannot create in ` + + `(for example AC and ARCH).` + ); + } + return `Error reading create metadata: ${message}`; + } + + if (issueTypes.length === 0) { + return `${validated.project} reports no creatable issue types for this user.`; + } + + if (!validated.issueType) { + return renderIssueTypes(validated.project, issueTypes); + } + + const wanted = validated.issueType.toLowerCase(); + const match = issueTypes.find((t) => t.name.toLowerCase() === wanted); + + if (!match) { + return [ + `${validated.project} has no issue type named "${validated.issueType}".`, + "", + "Available:", + ...issueTypes.map((t) => `- ${t.name}`), + ].join("\n"); + } + + try { + const { fields } = await client.getCreateMetaFields( + validated.project, + match.id, + ); + + return renderFields(validated.project, match.name, match.id, fields ?? []); + } catch (error) { + return `Error reading fields for ${match.name}: ${error instanceof Error ? error.message : String(error)}`; + } +} + +const getCreateFieldsTool: ToolDefinition = { + name: "get_create_fields", + description: + "Discover what a Jira project needs to create a work item: its creatable " + + "issue types, and for a given type, every field on the create screen with " + + "its field id, whether it is required, and its allowed values. Read-only. " + + "Use this before create_issue instead of assuming any project's field " + + "layout, since Bitwarden's projects differ from one another.", + inputSchema: { + type: "object", + properties: { + project: { + type: "string", + description: "Project key, e.g. PM, SM, QA, VULN, PLT.", + }, + issueType: { + type: "string", + description: + "Issue type name, e.g. Story or 'Platform Initiative'. Omit to list " + + "the project's creatable types.", + }, + }, + required: ["project"], + }, + handler, +}; + +export default getCreateFieldsTool; diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/link-issues.spec.ts b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/link-issues.spec.ts new file mode 100644 index 00000000..e91ad9c2 --- /dev/null +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/link-issues.spec.ts @@ -0,0 +1,262 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const mockPost = vi.fn(); +const mockGet = vi.fn(); + +vi.mock("axios", () => { + const mockAxios: any = { + create: vi.fn(() => ({ + post: mockPost, + get: mockGet, + interceptors: { response: { use: vi.fn() } }, + })), + }; + return { default: mockAxios }; +}); + +import linkIssuesTool, { resolveLinkDirection } from "./link-issues.js"; +import { validateInput, LinkIssuesSchema } from "../utils/validation.js"; + +describe("resolveLinkDirection", () => { + it("puts the blocker on outwardIssue for a Blocks link", () => { + // Ground truth, verified read-only against the live PM project: the same + // Blocks link is reported from PM-39203 as inwardIssue: PM-38796 and from + // PM-38796 as outwardIssue: PM-39203, so outward is the blocker. + const resolved = resolveLinkDirection( + validateInput(LinkIssuesSchema, { + linkType: "Blocks", + blockerKey: "PM-39203", + blockedKey: "PM-38796", + }), + ); + + expect(resolved).toEqual({ + typeName: "Blocks", + outwardKey: "PM-39203", + inwardKey: "PM-38796", + }); + }); + + it("does not invert when the caller lists the blocked item first", () => { + const resolved = resolveLinkDirection( + validateInput(LinkIssuesSchema, { + linkType: "Blocks", + blockedKey: "PM-2", + blockerKey: "PM-1", + }), + ); + + expect(resolved.outwardKey).toBe("PM-1"); + expect(resolved.inwardKey).toBe("PM-2"); + }); + + it("maps a symmetric Relates link in argument order", () => { + const resolved = resolveLinkDirection( + validateInput(LinkIssuesSchema, { + linkType: "Relates", + firstKey: "PM-10", + secondKey: "PM-11", + }), + ); + + expect(resolved).toEqual({ + typeName: "Relates", + outwardKey: "PM-10", + inwardKey: "PM-11", + }); + }); +}); + +describe("LinkIssuesSchema", () => { + it("defaults dryRun to true", () => { + const parsed = validateInput(LinkIssuesSchema, { + linkType: "Blocks", + blockerKey: "PM-1", + blockedKey: "PM-2", + }); + + expect(parsed.dryRun).toBe(true); + }); + + it("rejects a Blocks link missing one end", () => { + expect(() => + validateInput(LinkIssuesSchema, { + linkType: "Blocks", + blockerKey: "PM-1", + }), + ).toThrow(/blockedKey/); + }); + + it("rejects a malformed issue key", () => { + expect(() => + validateInput(LinkIssuesSchema, { + linkType: "Blocks", + blockerKey: "pm-1", + blockedKey: "PM-2", + }), + ).toThrow(/valid Jira issue key/); + }); +}); + +describe("link_issues handler", () => { + const ENV_KEYS = [ + "ATLASSIAN_CLOUD_ID", + "ATLASSIAN_EMAIL", + "ATLASSIAN_JIRA_READ_ONLY_TOKEN", + "ATLASSIAN_JIRA_WRITE_TOKEN", + ] as const; + const saved: Record = {}; + const blocksLink = { + linkType: "Blocks", + blockerKey: "PM-1", + blockedKey: "PM-2", + }; + + beforeEach(() => { + vi.clearAllMocks(); + for (const key of ENV_KEYS) { + saved[key] = process.env[key]; + delete process.env[key]; + } + process.env.ATLASSIAN_CLOUD_ID = "test-cloud-id"; + process.env.ATLASSIAN_EMAIL = "user@example.com"; + process.env.ATLASSIAN_JIRA_READ_ONLY_TOKEN = "read-token"; + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = saved[key]; + } + } + }); + + it("sends no request on a dry run, even with a write token set", async () => { + process.env.ATLASSIAN_JIRA_WRITE_TOKEN = "write-token"; + + const out = await linkIssuesTool.handler({ ...blocksLink, dryRun: true }); + + expect(out).toContain("Dry run: link Blocks"); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it("refuses a live link when no write token is set", async () => { + const out = await linkIssuesTool.handler({ + ...blocksLink, + dryRun: false, + }); + + expect(out).toContain("Refusing to link"); + expect(out).toContain("ATLASSIAN_JIRA_WRITE_TOKEN"); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it("posts the resolved link direction and verifies it by reading the outward issue back", async () => { + process.env.ATLASSIAN_JIRA_WRITE_TOKEN = "write-token"; + mockPost.mockResolvedValueOnce({ data: undefined }); + mockGet.mockResolvedValueOnce({ + data: { + fields: { + issuelinks: [ + { + id: "10001", + self: "https://example/rest/api/3/issueLink/10001", + type: { + id: "10000", + name: "Blocks", + inward: "is blocked by", + outward: "blocks", + }, + inwardIssue: { + id: "2", + key: "PM-2", + self: "https://example/PM-2", + }, + }, + ], + }, + }, + }); + + const out = await linkIssuesTool.handler({ + ...blocksLink, + dryRun: false, + }); + + expect(out).toContain("Linked: PM-1 blocks PM-2"); + expect(out).toContain("Verified: reading PM-1 back shows the link to PM-2"); + expect(mockPost).toHaveBeenCalledOnce(); + expect(mockPost).toHaveBeenCalledWith("/rest/api/3/issueLink", { + type: { name: "Blocks" }, + outwardIssue: { key: "PM-1" }, + inwardIssue: { key: "PM-2" }, + }); + expect(mockGet).toHaveBeenCalledWith( + "/rest/api/3/issue/PM-1", + expect.objectContaining({ params: { fields: "issuelinks" } }), + ); + }); + + it("reports when the read-back does not show the expected link", async () => { + process.env.ATLASSIAN_JIRA_WRITE_TOKEN = "write-token"; + mockPost.mockResolvedValueOnce({ data: undefined }); + mockGet.mockResolvedValueOnce({ data: { fields: { issuelinks: [] } } }); + + const out = await linkIssuesTool.handler({ + ...blocksLink, + dryRun: false, + }); + + expect(out).toContain("Linked: PM-1 blocks PM-2"); + expect(out).toContain("Could not verify"); + expect(out).toContain("Linked Issues panel"); + }); + + it("reports a verification failure without treating the link creation as failed", async () => { + process.env.ATLASSIAN_JIRA_WRITE_TOKEN = "write-token"; + mockPost.mockResolvedValueOnce({ data: undefined }); + mockGet.mockRejectedValueOnce(new Error("JIRA API error (404): not found")); + + const out = await linkIssuesTool.handler({ + ...blocksLink, + dryRun: false, + }); + + expect(out).toContain("Linked: PM-1 blocks PM-2"); + expect(out).toContain("Could not verify the link by reading PM-1 back"); + expect(out).toContain("not found"); + }); + + it("reports the API error rather than throwing on a failed live link", async () => { + process.env.ATLASSIAN_JIRA_WRITE_TOKEN = "write-token"; + mockPost.mockRejectedValueOnce(new Error("JIRA API error (400): boom")); + + const out = await linkIssuesTool.handler({ + ...blocksLink, + dryRun: false, + }); + + expect(out).toContain("Error creating link"); + expect(out).toContain("boom"); + expect(out).not.toContain("full scope"); + expect(mockGet).not.toHaveBeenCalled(); + }); + + it("hints at partial write-token scopes on a 401", async () => { + process.env.ATLASSIAN_JIRA_WRITE_TOKEN = "write-token"; + mockPost.mockRejectedValueOnce( + new Error("JIRA authentication failed. Check your API token and email."), + ); + + const out = await linkIssuesTool.handler({ + ...blocksLink, + dryRun: false, + }); + + expect(out).toContain("Error creating link"); + expect(out).toContain("full scope"); + expect(mockGet).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/link-issues.ts b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/link-issues.ts new file mode 100644 index 00000000..5ea948bb --- /dev/null +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/tools/link-issues.ts @@ -0,0 +1,209 @@ +/** + * Link Issues Tool (write, opt-in) + * + * Parameters are named by role (`blockerKey` / `blockedKey`) rather than by + * Jira's inward/outward vocabulary. The direction mapping is applied once, here, + * so a caller cannot invert it. That removes the failure mode the acli path has + * to document and guard with an eval case. + */ + +import { JiraClient } from "../jira/client.js"; +import { hasJiraWriteToken } from "../jira/auth.js"; +import { + validateInput, + LinkIssuesSchema, + LinkIssuesInput, + ToolDefinition, +} from "../utils/validation.js"; +import { + writeTokenDryRunNote, + writeTokenRefusalMessage, + isWriteAuthError, + writeScopeHint, +} from "../utils/write-guard.js"; + +/** + * Resolve role-named parameters onto Jira's (outward, inward) pair. + * + * For type "Blocks" the link type's descriptions are outward "blocks" and + * inward "is blocked by", and the payload reads + * "outwardIssue inwardIssue". So the blocker is the outward issue. + * Verified read-only against a real PM link; see JiraClient.createIssueLink. + * + * "Relates" is symmetric (both descriptions are "relates to"), so the ordering + * of the pair carries no meaning. + * + * Exported so the mapping can be tested directly. + */ +export function resolveLinkDirection(input: LinkIssuesInput): { + typeName: string; + outwardKey: string; + inwardKey: string; +} { + if (input.linkType === "Blocks") { + return { + typeName: "Blocks", + outwardKey: input.blockerKey, + inwardKey: input.blockedKey, + }; + } + + return { + typeName: "Relates", + outwardKey: input.firstKey, + inwardKey: input.secondKey, + }; +} + +function describeLink(input: LinkIssuesInput): string { + return input.linkType === "Blocks" + ? `${input.blockerKey} blocks ${input.blockedKey}` + : `${input.firstKey} relates to ${input.secondKey}`; +} + +/** + * Read the outward issue back and confirm the link landed in the direction we + * intended. Each end of a link names the other end and labels it with the + * other end's role (see `JiraClient.createIssueLink`), so the outward issue's + * own record should show an `inwardIssue` entry pointing at the inward key. + * + * Uses a fresh read-mode client rather than the write client the create call + * used, so this never requires a read scope on the write token. + */ +async function verifyLink(resolved: { + typeName: string; + outwardKey: string; + inwardKey: string; +}): Promise { + try { + const readClient = new JiraClient("read"); + const issue = await readClient.getIssue(resolved.outwardKey, [ + "issuelinks", + ]); + const links = issue.fields.issuelinks ?? []; + const found = links.some( + (link) => + link.type.name === resolved.typeName && + link.inwardIssue?.key === resolved.inwardKey, + ); + + return found + ? `Verified: reading ${resolved.outwardKey} back shows the link to ${resolved.inwardKey}.` + : `Could not verify: reading ${resolved.outwardKey} back does not show a ` + + `${resolved.typeName} link to ${resolved.inwardKey}. Check Jira's Linked Issues panel.`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Could not verify the link by reading ${resolved.outwardKey} back: ${message}`; + } +} + +async function handler(input: any): Promise { + const validated = validateInput(LinkIssuesSchema, input); + const resolved = resolveLinkDirection(validated); + + if (validated.dryRun) { + const lines = [ + `# Dry run: link ${validated.linkType}`, + "", + "No request was sent. Re-run with `dryRun: false` to create this link.", + "", + `Reads as: **${describeLink(validated)}**`, + "", + "## Exact request", + "", + "```", + "POST /rest/api/3/issueLink", + "```", + "", + "```json", + JSON.stringify( + { + type: { name: resolved.typeName }, + outwardIssue: { key: resolved.outwardKey }, + inwardIssue: { key: resolved.inwardKey }, + }, + null, + 2, + ), + "```", + "", + ]; + + if (!hasJiraWriteToken()) { + lines.push(...writeTokenDryRunNote("link")); + } + + return lines.join("\n"); + } + + if (!hasJiraWriteToken()) { + return writeTokenRefusalMessage("link"); + } + + try { + const client = new JiraClient("write"); + await client.createIssueLink(resolved); + + const verification = await verifyLink(resolved); + + return [`Linked: ${describeLink(validated)}.`, verification].join("\n\n"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + return isWriteAuthError(message) + ? [`Error creating link: ${message}`, "", writeScopeHint()].join("\n") + : `Error creating link: ${message}`; + } +} + +const linkIssuesTool: ToolDefinition = { + name: "link_issues", + description: + "Link two Jira work items. For a dependency use linkType 'Blocks' with " + + "blockerKey (the item that must land first) and blockedKey (the item waiting " + + "on it); the inward/outward mapping is handled internally so direction cannot " + + "be inverted. Defaults to a dry run. Requires ATLASSIAN_JIRA_WRITE_TOKEN for " + + "live linking.", + inputSchema: { + type: "object", + properties: { + linkType: { + type: "string", + enum: ["Blocks", "Relates"], + description: + "'Blocks' for a hard dependency, 'Relates' for soft or ordering-only.", + }, + blockerKey: { + type: "string", + description: + "Blocks only: the item that must land first (e.g. PM-12345).", + pattern: "^[A-Z][A-Z0-9_]+-\\d+$", + }, + blockedKey: { + type: "string", + description: "Blocks only: the item waiting on the blocker.", + pattern: "^[A-Z][A-Z0-9_]+-\\d+$", + }, + firstKey: { + type: "string", + description: "Relates only: one side of the symmetric relationship.", + pattern: "^[A-Z][A-Z0-9_]+-\\d+$", + }, + secondKey: { + type: "string", + description: "Relates only: the other side.", + pattern: "^[A-Z][A-Z0-9_]+-\\d+$", + }, + dryRun: { + type: "boolean", + default: true, + description: + "When true (the default), returns the exact payload without sending it.", + }, + }, + required: ["linkType"], + }, + handler, +}; + +export default linkIssuesTool; diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/utils/adf-build.spec.ts b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/utils/adf-build.spec.ts new file mode 100644 index 00000000..a5693666 --- /dev/null +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/utils/adf-build.spec.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; + +import { buildDescriptionAdf } from "./adf-build.js"; + +describe("buildDescriptionAdf", () => { + it("builds one ADF paragraph per input string", () => { + const doc = buildDescriptionAdf(["First para.", "Second para."]); + + expect(doc).toEqual({ + version: 1, + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "First para." }] }, + { + type: "paragraph", + content: [{ type: "text", text: "Second para." }], + }, + ], + }); + }); + + it("trims surrounding whitespace", () => { + const doc = buildDescriptionAdf([" padded "]); + + expect(doc?.content[0].content[0].text).toBe("padded"); + }); + + it("drops empty and whitespace-only paragraphs", () => { + const doc = buildDescriptionAdf(["kept", " ", ""]); + + expect(doc?.content).toHaveLength(1); + }); + + it("returns undefined when there is nothing to send", () => { + expect(buildDescriptionAdf([])).toBeUndefined(); + expect(buildDescriptionAdf([" ", ""])).toBeUndefined(); + }); +}); diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/utils/adf-build.ts b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/utils/adf-build.ts new file mode 100644 index 00000000..7787c165 --- /dev/null +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/utils/adf-build.ts @@ -0,0 +1,52 @@ +/** + * ADF construction for Jira writes. + * + * The counterpart to `adf.ts`, which extracts plain text out of ADF for reads. + * + * This is deliberately small. Acceptance criteria are NOT built here: the PM + * project exposes `Acceptance criteria` (customfield_10192) as a plain textarea, + * so Gherkin is sent as a string and needs no ADF at all. That leaves the + * description, which is prose paragraphs. + */ + +export interface AdfTextNode { + type: "text"; + text: string; +} + +export interface AdfParagraph { + type: "paragraph"; + content: AdfTextNode[]; +} + +export interface AdfDoc { + version: 1; + type: "doc"; + content: AdfParagraph[]; +} + +/** + * Build an ADF document from discrete paragraphs of plain text. + * + * @param paragraphs - Paragraph strings. Empty and whitespace-only entries are + * dropped, since Jira renders an empty paragraph as visible dead space. + * @returns An ADF doc, or undefined when there is nothing to send so the caller + * can omit the description field entirely rather than posting an empty doc. + */ +export function buildDescriptionAdf( + paragraphs: readonly string[], +): AdfDoc | undefined { + const content: AdfParagraph[] = paragraphs + .map((text) => text.trim()) + .filter((text) => text.length > 0) + .map((text) => ({ + type: "paragraph" as const, + content: [{ type: "text" as const, text }], + })); + + if (content.length === 0) { + return undefined; + } + + return { version: 1, type: "doc", content }; +} diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/utils/validation.ts b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/utils/validation.ts index 2df78b3f..76c3aade 100644 --- a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/utils/validation.ts +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/utils/validation.ts @@ -201,6 +201,131 @@ export type GetIssueRemoteLinksInput = z.infer< typeof GetIssueRemoteLinksSchema >; +// ── Write Schemas (opt-in, require ATLASSIAN_JIRA_WRITE_TOKEN) ──────── + +const JiraIssueKey = z + .string() + .regex( + /^[A-Z][A-Z0-9_]+-\d+$/, + "Must be a valid Jira issue key (e.g. PM-123)", + ); + +/** + * `project` is interpolated directly into a createmeta REST path, so unlike + * `CreateIssueSchema.project` (which only ever lands in a JSON body), an + * unconstrained string here would let a caller redirect the request to a + * different path on the same host. + */ +const JiraProjectKey = z + .string() + .regex(/^[A-Z][A-Z0-9_]+$/, "Must be a valid Jira project key (e.g. PM)"); + +/** + * Field keys owned by named parameters. Passing them inside `fields` would make + * two sources of truth for the same value, so they are rejected there. + */ +export const RESERVED_FIELD_KEYS = [ + "project", + "issuetype", + "summary", + "description", + "parent", + "labels", +] as const; + +/** + * Create parameters carry no project-specific knowledge. + * + * Bitwarden files into many projects (PM, SM, QA, VULN, PLT, and more), and they + * do not agree on issue types, screen fields, or which fields are required. A + * team-managed project can also scope custom fields to itself. So rather than + * enumerate any project's shape here: + * + * - `issueType` is a free-form name. Jira resolves it against the project and + * rejects it if absent, which is validation we do not need to duplicate. + * - everything beyond the common core goes through `fields` untouched, and Jira + * is the authority on what is required and what is on the screen. + * - `get_create_fields` exists to discover a project's shape when drafting. + */ +export const CreateIssueSchema = z.object({ + project: JiraProjectKey, + issueType: z + .string() + .min( + 1, + "Issue type name is required (e.g. Story, Task, Platform Initiative)", + ), + summary: z + .string() + .min(1, "Summary cannot be empty") + .max(255, "Jira summaries are limited to 255 characters"), + /** Description body as paragraphs of plain text, converted to ADF. */ + descriptionParagraphs: z.array(z.string().min(1)).optional().default([]), + /** Parent key, for a child of an epic. */ + parentKey: JiraIssueKey.optional(), + labels: z.array(z.string().min(1)).optional().default([]), + /** + * Arbitrary additional fields, merged into the create payload as-is. Keys are + * Jira field IDs (e.g. `customfield_10192`) discovered via `get_create_fields` + * for the target project, not assumed. + */ + fields: z + .record(z.string(), z.unknown()) + .optional() + .default({}) + .refine( + (value) => + !Object.keys(value).some((key) => + (RESERVED_FIELD_KEYS as readonly string[]).includes(key), + ), + `These keys have named parameters and must not be passed in fields: ${RESERVED_FIELD_KEYS.join(", ")}`, + ), + /** + * Defaults to true. A live create requires an explicit `dryRun: false`, so + * the failure mode of forgetting the flag is a preview, not a ticket. + */ + dryRun: z.boolean().optional().default(true), +}); + +export type CreateIssueInput = z.infer; + +/** + * Read the create screen for a project + issue type, so a caller can discover + * required fields, field IDs, and allowed values instead of hardcoding them. + */ +export const GetCreateFieldsSchema = z.object({ + project: JiraProjectKey, + issueType: z + .string() + .min(1) + .optional() + .describe("Issue type name. Omit to list the project's creatable types."), +}); + +export type GetCreateFieldsInput = z.infer; + +/** + * Link parameters are named by role rather than by Jira's inward/outward + * vocabulary, so the caller cannot invert the direction. The inward/outward + * mapping is applied once, in the client. + */ +export const LinkIssuesSchema = z.discriminatedUnion("linkType", [ + z.object({ + linkType: z.literal("Blocks"), + blockerKey: JiraIssueKey, + blockedKey: JiraIssueKey, + dryRun: z.boolean().optional().default(true), + }), + z.object({ + linkType: z.literal("Relates"), + firstKey: JiraIssueKey, + secondKey: JiraIssueKey, + dryRun: z.boolean().optional().default(true), + }), +]); + +export type LinkIssuesInput = z.infer; + /** * Validate input against a Zod schema * @param schema - Zod schema to validate against diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/utils/write-guard.spec.ts b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/utils/write-guard.spec.ts new file mode 100644 index 00000000..80507c86 --- /dev/null +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/utils/write-guard.spec.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; + +import { + writeTokenDryRunNote, + writeTokenRefusalMessage, + isWriteAuthError, + writeScopeHint, +} from "./write-guard.js"; + +describe("writeTokenDryRunNote", () => { + it("names the action in the no-token dry-run note", () => { + const lines = writeTokenDryRunNote("create"); + + expect(lines.join("\n")).toContain("create would fail"); + expect(lines.join("\n")).toContain("ATLASSIAN_JIRA_WRITE_TOKEN"); + }); +}); + +describe("writeTokenRefusalMessage", () => { + it("names the action in the live-refusal message", () => { + const message = writeTokenRefusalMessage("link"); + + expect(message).toContain("Refusing to link"); + expect(message).toContain("ATLASSIAN_JIRA_WRITE_TOKEN"); + expect(message).toContain("dryRun: true"); + }); +}); + +describe("isWriteAuthError", () => { + it("recognizes JiraClient's generic 401 text", () => { + expect( + isWriteAuthError( + "JIRA authentication failed. Check your API token and email.", + ), + ).toBe(true); + }); + + it("does not match unrelated errors", () => { + expect(isWriteAuthError("JIRA API error (400): boom")).toBe(false); + }); +}); + +describe("writeScopeHint", () => { + it("points at the README's scope set", () => { + expect(writeScopeHint()).toContain("ATLASSIAN_JIRA_WRITE_TOKEN"); + expect(writeScopeHint()).toContain("README"); + }); +}); diff --git a/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/utils/write-guard.ts b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/utils/write-guard.ts new file mode 100644 index 00000000..f6f8e533 --- /dev/null +++ b/plugins/bitwarden-atlassian-tools/mcp/bitwarden-atlassian-mcp-server/src/utils/write-guard.ts @@ -0,0 +1,40 @@ +/** + * Text shared by every opt-in write tool's `ATLASSIAN_JIRA_WRITE_TOKEN` gate, + * kept in one place so the next write tool doesn't re-derive this wording. + */ + +/** Appended to a dry-run preview when no write token is configured. */ +export function writeTokenDryRunNote(action: string): string[] { + return [ + "> Note: ATLASSIAN_JIRA_WRITE_TOKEN is not set on this install, so a live", + `> ${action} would fail at authentication. Dry runs do not need it.`, + "", + ]; +} + +/** Returned in place of a live write when no write token is configured. */ +export function writeTokenRefusalMessage(action: string): string { + return ( + `Refusing to ${action}: ATLASSIAN_JIRA_WRITE_TOKEN is not set.\n\n` + + "This install has read-only credentials. Set a write-scoped Atlassian API " + + "token as ATLASSIAN_JIRA_WRITE_TOKEN to enable this, or call this tool " + + "with dryRun: true to preview the payload." + ); +} + +/** + * True for JiraClient's generic 401 text — the one failure that could mean + * the write token is present but missing part of the required scope set, + * rather than a config or field problem. + */ +export function isWriteAuthError(message: string): boolean { + return message.includes("JIRA authentication failed"); +} + +/** Appended when a live write fails with isWriteAuthError. */ +export function writeScopeHint(): string { + return ( + "If ATLASSIAN_JIRA_WRITE_TOKEN is set, verify it carries the full scope " + + "set from the plugin README — Jira rejects a partial set with this same 401." + ); +}