diff --git a/README.md b/README.md index 03051cd..ee1967c 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ node bundle/backlog-api.mjs trace get_issue node bundle/backlog-api.mjs call get_issue --input request.json ``` -The CLI supports all 58 normal tools registered by the checked upstream -`v0.13.2` source plus the Node-specific `get_rate_limit` operation. `call` +The CLI supports all 62 normal tools registered by the checked upstream +`v0.14.0` source plus the Node-specific `get_rate_limit` operation. `call` reads one JSON object and writes one structured JSON envelope containing the result, diagnostics, and upstream trace information. @@ -40,6 +40,19 @@ permission metadata, confirmation requirements, and curated examples when available. `call --help` is an alias for the same credential-free JSON output. +### Issue updates and relations + +The v0.14.0 compatibility baseline adds `update_issue_comment`, +`get_related_issues`, `add_related_issue`, and `remove_related_issue`. +`update_issue` also accepts an optional `parentIssueId`. Use `tools describe` +for the exact input schema before calling an operation. + +`get_related_issues` is a READ operation. `add_related_issue` requires CREATE, +`update_issue_comment` requires UPDATE, and `remove_related_issue` requires +DELETE plus `--confirm-destructive`. Every operation accepts either a positive +`issueId` or an `issueKey` for its source issue; when both are present, a +non-positive `issueId` falls back to `issueKey`. + Delete operations and broad notification reset require `--confirm-destructive`. Use `--dry-run` to validate input without calling Backlog or resolving a configured connection. Write permissions and destructive @@ -57,7 +70,7 @@ Backlog API access to stderr. Events identify the operation, Backlog client method, CRUD category, and whether the default or a named organization was selected. A strict whitelist also exposes resource identifiers such as `spaceKey`, `projectId`, and `issueKey`, IDs returned by successful API -operations, duration, changed field names without values, pagination, and an +operations, related-issue IDs, duration, changed field names without values, pagination, and an HTTP failure status when the upstream error exposes one. Each line starts with `verbose: ` followed by a JSON object. Request and diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index aac5976..2572325 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -3,7 +3,7 @@ ## Nulab Backlog MCP Server This repository's generated Node runtime directly bundles published handler -code from `backlog-mcp-server` version `0.13.2`. +code from `backlog-mcp-server` version `0.14.0`. - Project: - Copyright: Copyright (c) 2025 Nulab Inc. diff --git a/TODO.md b/TODO.md index cefafb4..cf02608 100644 --- a/TODO.md +++ b/TODO.md @@ -18,9 +18,14 @@ - [ ] Confirm that CI and release workflows pass independently in both repositories and that the Node release contains no Agent Skill files. - [x] Register the Node-specific GitHub Issues as - [Issues #2–#7](https://github.com/igapyon/backlog-api/issues). + [Issues #2–#7 and #18](https://github.com/igapyon/backlog-api/issues). ## Node Compatibility and Maintenance +- [x] Refresh the upstream compatibility baseline to `backlog-mcp-server` + v0.14.0 for [Issue #18](https://github.com/igapyon/backlog-api/issues/18), + including operation mapping, safety contracts, differential tests, and + downstream handoff prerequisites for [Issue #7](https://github.com/igapyon/backlog-api/issues/7). + Node compatibility and maintenance work is tracked in -[GitHub Issues #2–#7](https://github.com/igapyon/backlog-api/issues). +[GitHub Issues #2–#7 and #18](https://github.com/igapyon/backlog-api/issues). diff --git a/docs/backlog-project-constraints.md b/docs/backlog-project-constraints.md index 8163303..0daeae3 100644 --- a/docs/backlog-project-constraints.md +++ b/docs/backlog-project-constraints.md @@ -55,6 +55,31 @@ Backlog APIからHTTP 400、エラーコード7が返され、プロジェクト `issueIdOrKey`を指定すると`Issue ID or key is required`となり、 `issueKey`へ修正することで取得に成功しました。 +`issueId`と`issueKey`を同時に指定した場合、正の`issueId`を優先します。 +`issueId`が`0`以下の場合は、`issueKey`を指定していればそちらへフォールバック +します。どちらも有効でない場合は入力エラーです。 + +## Related Issues + +`get_related_issues`は、課題に紐づく関連課題を読み取ります。`issueId`または +`issueKey`のいずれかが必要です。 + +```json +{"issueKey":"PROJECT-1"} +``` + +`add_related_issue`は、元課題の`issueId`または`issueKey`と、関連付け先の +数値`targetIssueId`を必要とするCREATE操作です。 + +```json +{"issueKey":"PROJECT-1","targetIssueId":12346} +``` + +`remove_related_issue`は、元課題の`issueId`または`issueKey`と、解除する +数値`relatedIssueId`を必要とするDELETE操作です。実行には環境側と呼び出し側の +`DELETE`許可に加え、`--confirm-destructive`が必要です。作成・解除の前には +同じ入力でdry-runを実行して確認してください。 + ## Issue Listing `get_issues`でプロジェクトを絞り込む場合、`projectId`は単一の数値ではなく @@ -151,3 +176,11 @@ Backlog APIからHTTP 400、エラーコード7が返され、プロジェクト コメント追加はBacklogを変更する操作です。事前に同じ入力でdry-runを実行し、 実行は1回に限定してください。 + +`update_issue_comment`は、`issueId`または`issueKey`、数値`commentId`、 +新しい`content`を必要とするUPDATE操作です。環境側と呼び出し側の両方で +`UPDATE`を許可したうえで、dry-runによる入力検証後に実行してください。 + +```json +{"issueKey":"PROJECT-1","commentId":12345,"content":"更新後のコメント"} +``` diff --git a/docs/backlog-rate-limit.md b/docs/backlog-rate-limit.md index 0944bc0..02c54d7 100644 --- a/docs/backlog-rate-limit.md +++ b/docs/backlog-rate-limit.md @@ -57,7 +57,7 @@ Backlog APIのレート制限は、APIキー単位ではなくユーザー単位 - verboseログでは3つのレート制限ヘッダーだけをホワイトリストで取得し、URL、 APIキー、レスポンス本文、その他のヘッダーを出力しない - ヘッダーからリクエスト種別を確定できない場合は、`read`などを推測して記録しない -- `get_rate_limit`は、既存の上流由来58操作とは異なるNode CLI独自のREAD操作 +- `get_rate_limit`は、既存の上流由来62操作とは異なるNode CLI独自のREAD操作 としてトレース情報へ記録する - `get_rate_limit`自体もAPIアクセスを1回消費する diff --git a/docs/development.md b/docs/development.md index 76900ef..10f4caa 100644 --- a/docs/development.md +++ b/docs/development.md @@ -3,7 +3,7 @@ ## Initial Design Record - checked date: 2026-07-22 -- repository version: `0.5.0` +- repository version: `0.6.0` - implementation maturity: beta standalone Node Core/CLI - split source: `backlog-api-skills` initial combined implementation @@ -19,9 +19,9 @@ ## Upstream Anchor - repository: -- compatibility version: `v0.13.2` -- checked commit: `d12f010de976af11bcd43f1d3497dc7043d26e62` -- npm package: `backlog-mcp-server@0.13.2` +- compatibility version: `v0.14.0` +- checked commit: `9da42fcfb5b69f1455e3864c49f2b57a45a4cbe9` +- npm package: `backlog-mcp-server@0.14.0` - upstream license: MIT - disposable checkout: `workplace/upstream/backlog-mcp-server` @@ -52,7 +52,7 @@ cross-product integrations. - preserve every upstream normal tool name as one Node operation - expose `get_rate_limit` as a clearly identified Node-specific operation -- use one generic, tested operation runner instead of duplicating 58 handlers +- use one generic, tested operation runner instead of duplicating 62 handlers - generate and commit an upstream tool mapping - bundle CLI and importable runtime artifacts separately - require a CLI-level confirmation flag for destructive and broad-reset calls diff --git a/docs/traceability/cli-json-parity.md b/docs/traceability/cli-json-parity.md index 907c975..376147e 100644 --- a/docs/traceability/cli-json-parity.md +++ b/docs/traceability/cli-json-parity.md @@ -9,6 +9,19 @@ - upstream Backlog error parsing - upstream source and test identity in trace metadata +## v0.14.0 Upstream Delta + +- `update_issue_comment` is exposed as an UPDATE operation. +- `get_related_issues`, `add_related_issue`, and `remove_related_issue` are + exposed as READ, CREATE, and destructive DELETE operations respectively. +- `update_issue` preserves the upstream optional `parentIssueId` input. +- Issue handlers use `issueKey` when both identifiers are supplied and + `issueId` is non-positive, matching the upstream resolver. + +The Node policy layer adds the same issue ID/key alternative validation to the +new issue operations. `remove_related_issue` is a DELETE operation and therefore +also requires `--confirm-destructive`. + ## CLI Envelope Successful calls write one JSON object to stdout: @@ -58,6 +71,8 @@ use the same envelope with `success: false` and error diagnostics. - `--verbose` writes sanitized Backlog access start/outcome JSON events to stderr; a whitelist permits resource identifiers, duration, changed field names, pagination, actual response status, and validated rate-limit metadata +- related-issue target and relation IDs are included as whitelisted resource + identifiers when supplied - content values, full request/response data, organization names, credentials, personal data, and upstream error text are omitted from verbose events - `get_rate_limit` is a Node-specific READ operation, not an upstream normal diff --git a/docs/traceability/downstream-runtime-handoff.md b/docs/traceability/downstream-runtime-handoff.md new file mode 100644 index 0000000..d416a5e --- /dev/null +++ b/docs/traceability/downstream-runtime-handoff.md @@ -0,0 +1,40 @@ +# Downstream Runtime Handoff + +## Compatibility Baseline + +- upstream package: `backlog-mcp-server@0.14.0` +- upstream tag: `v0.14.0` +- upstream commit: `9da42fcfb5b69f1455e3864c49f2b57a45a4cbe9` +- current package version: `0.6.0` +- normal upstream operations: 62 +- Node-specific operations: `get_rate_limit` + +## Runtime Changes for `backlog-api-skills` + +- added `get_related_issues` (READ) +- added `add_related_issue` (CREATE) +- added `update_issue_comment` (UPDATE) +- added `remove_related_issue` (DELETE and destructive confirmation) +- preserved `update_issue.parentIssueId` +- preserved fallback from non-positive `issueId` to `issueKey` + +The Node runtime continues to require the environment permission ceiling and +call-level `--allow` for every write. `remove_related_issue` also requires +`--confirm-destructive`. + +## Handoff Preconditions + +Do not pin an uncommitted local bundle in the downstream repository. After the +source change is committed and a release version or accepted tag suffix is +chosen, rebuild from a clean worktree and provide all of the following: + +1. the exact `backlog-api` commit and release tag +2. `bundle/backlog-api.mjs` +3. `bundle/backlog-api-runtime.mjs` +4. `bundle/backlog-api-sources.tgz` +5. SHA-256 values calculated from those clean-build artifacts +6. the v0.14.0 compatibility and safety delta above + +Run `npm run typecheck`, `npm run trace:refresh`, `npm test`, and +`npm run smoke:node` before recording the runtime identity in +`backlog-api-skills`. diff --git a/docs/traceability/upstream-followup-log.md b/docs/traceability/upstream-followup-log.md index ff485cd..413dfb8 100644 --- a/docs/traceability/upstream-followup-log.md +++ b/docs/traceability/upstream-followup-log.md @@ -1,5 +1,20 @@ # Upstream Follow-Up Log +## 2026-07-31 — v0.14.0 Compatibility Refresh + +- pinned npm packages `backlog-mcp-server@0.14.0` and `backlog-js@0.19.0` +- checked upstream tag `v0.14.0` at commit + `9da42fcfb5b69f1455e3864c49f2b57a45a4cbe9` +- regenerated mappings for 62 upstream normal tools plus the Node-specific + `get_rate_limit` operation +- exposed `update_issue_comment`, `get_related_issues`, `add_related_issue`, + and `remove_related_issue` with explicit READ/CREATE/UPDATE/DELETE policy + and destructive confirmation for relation removal +- preserved `update_issue.parentIssueId` and non-positive `issueId` fallback to + `issueKey` through the upstream handlers +- added differential coverage for all new and changed issue operations and + retained the Node-only permission, dry-run, verbose, and rate-limit guards + ## 2026-07-22 — Initial v0.13.2 Conversion - pinned npm package `backlog-mcp-server@0.13.2` diff --git a/docs/traceability/upstream-snapshot.md b/docs/traceability/upstream-snapshot.md index e932a4d..00a567f 100644 --- a/docs/traceability/upstream-snapshot.md +++ b/docs/traceability/upstream-snapshot.md @@ -1,10 +1,10 @@ # Upstream Snapshot - repository: -- tag: `v0.13.2` -- commit: `d12f010de976af11bcd43f1d3497dc7043d26e62` -- npm package: `backlog-mcp-server@0.13.2` -- checked: 2026-07-22 +- tag: `v0.14.0` +- commit: `9da42fcfb5b69f1455e3864c49f2b57a45a4cbe9` +- npm package: `backlog-mcp-server@0.14.0` +- checked: 2026-07-31 - local checkout: `workplace/upstream/backlog-mcp-server` - license: MIT diff --git a/docs/traceability/upstream-tool-mapping.json b/docs/traceability/upstream-tool-mapping.json index 10558b2..16aeb26 100644 --- a/docs/traceability/upstream-tool-mapping.json +++ b/docs/traceability/upstream-tool-mapping.json @@ -2,15 +2,15 @@ "schemaVersion": 1, "upstream": { "repository": "https://github.com/nulab/backlog-mcp-server", - "version": "0.13.2", - "tag": "v0.13.2", - "commit": "d12f010de976af11bcd43f1d3497dc7043d26e62", - "checked": "2026-07-22" + "version": "0.14.0", + "tag": "v0.14.0", + "commit": "9da42fcfb5b69f1455e3864c49f2b57a45a4cbe9", + "checked": "2026-07-31" }, "target": { "repository": "backlog-api", "product": "backlog-api", - "version": "0.5.0", + "version": "0.6.0", "strategy": "published-handler-direct-invocation" }, "operations": [ @@ -74,6 +74,16 @@ "targetEntry": "src/core/run-operation.ts", "targetTest": "tests/upstream-differential.test.mjs" }, + { + "operation": "add_related_issue", + "toolset": "issue", + "mutationClass": "mutation", + "origin": "upstream", + "upstreamSource": "src/tools/addRelatedIssue.ts", + "upstreamTest": "src/tools/addRelatedIssue.test.ts", + "targetEntry": "src/core/run-operation.ts", + "targetTest": "tests/upstream-differential.test.mjs" + }, { "operation": "add_version_milestone", "toolset": "issue", @@ -384,6 +394,16 @@ "targetEntry": "src/core/local-tools.ts", "targetTest": "tests/access-policy-and-rate-limit.test.mjs" }, + { + "operation": "get_related_issues", + "toolset": "issue", + "mutationClass": "read", + "origin": "upstream", + "upstreamSource": "src/tools/getRelatedIssues.ts", + "upstreamTest": "src/tools/getRelatedIssues.test.ts", + "targetEntry": "src/core/run-operation.ts", + "targetTest": "tests/upstream-differential.test.mjs" + }, { "operation": "get_resolutions", "toolset": "issue", @@ -524,6 +544,16 @@ "targetEntry": "src/core/run-operation.ts", "targetTest": "tests/upstream-differential.test.mjs" }, + { + "operation": "remove_related_issue", + "toolset": "issue", + "mutationClass": "destructive", + "origin": "upstream", + "upstreamSource": "src/tools/removeRelatedIssue.ts", + "upstreamTest": "src/tools/removeRelatedIssue.test.ts", + "targetEntry": "src/core/run-operation.ts", + "targetTest": "tests/upstream-differential.test.mjs" + }, { "operation": "reset_unread_notification_count", "toolset": "notifications", @@ -544,6 +574,16 @@ "targetEntry": "src/core/run-operation.ts", "targetTest": "tests/upstream-differential.test.mjs" }, + { + "operation": "update_issue_comment", + "toolset": "issue", + "mutationClass": "mutation", + "origin": "upstream", + "upstreamSource": "src/tools/updateIssueComment.ts", + "upstreamTest": "src/tools/updateIssueComment.test.ts", + "targetEntry": "src/core/run-operation.ts", + "targetTest": "tests/upstream-differential.test.mjs" + }, { "operation": "update_project", "toolset": "project", diff --git a/package-lock.json b/package-lock.json index f7d7507..e327865 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "backlog-api", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "backlog-api", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { - "backlog-js": "0.18.1", - "backlog-mcp-server": "0.13.2", + "backlog-js": "0.19.0", + "backlog-mcp-server": "0.14.0", "zod": "3.25.76", "zod-to-json-schema": "3.25.2" }, @@ -976,28 +976,28 @@ } }, "node_modules/backlog-js": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/backlog-js/-/backlog-js-0.18.1.tgz", - "integrity": "sha512-bTW2+dpJ3GpUW5UBIVV3zHbmOCP+Hrok2o/0sB6UFY6kgWtzzjFg3XiXjxRiI86gKOj87t+PhePcb76eBzcclA==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/backlog-js/-/backlog-js-0.19.0.tgz", + "integrity": "sha512-X4C2uQRJD5Xt8gf0Ygje6C5LKjr43eCt40yiVYO8G4WmAITSJGpnDREA0f4jMar0Z66Kcf+Bt6ljTYDWw1l0kA==", "license": "MIT", "dependencies": { "qs": "^6.12.1" } }, "node_modules/backlog-mcp-server": { - "version": "0.13.2", - "resolved": "https://registry.npmjs.org/backlog-mcp-server/-/backlog-mcp-server-0.13.2.tgz", - "integrity": "sha512-1klTlECpzZwy/pCKcQ/TGAThJuZVqmhZULcXA4S/w9U8OsssUZ1mHqYlgzdGdf/jwDmInRp9wEaHVkKKq/ssaw==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/backlog-mcp-server/-/backlog-mcp-server-0.14.0.tgz", + "integrity": "sha512-sQtjAcKxm+SpNf5RHo7xpWzd28PrrA/Eb4maOevzbIEOyNizUGiW6xf2DfdoYlbtagozTo1sA4M58Kbvu4fmIw==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@hono/node-server": "^2.0.4", + "@hono/node-server": "^2.0.10", "@modelcontextprotocol/sdk": "^1.29.0", - "backlog-js": "^0.18.1", + "backlog-js": "^0.19.0", "cosmiconfig": "^9.0.1", "env-var": "^7.5.0", "graphql": "^16.14.1", - "hono": "^4.12.25", + "hono": "^4.12.27", "pino": "^10.3.1", "pino-pretty": "^13.1.3", "yargs": "^18.0.0", diff --git a/package.json b/package.json index 99c3a24..3cfeafe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "backlog-api", - "version": "0.5.0", + "version": "0.6.0", "private": true, "type": "module", "bin": { @@ -19,8 +19,8 @@ "trace:refresh": "npm run build:ts && node scripts/generate-upstream-tool-mapping.mjs" }, "dependencies": { - "backlog-js": "0.18.1", - "backlog-mcp-server": "0.13.2", + "backlog-js": "0.19.0", + "backlog-mcp-server": "0.14.0", "zod": "3.25.76", "zod-to-json-schema": "3.25.2" }, diff --git a/scripts/generate-upstream-tool-mapping.mjs b/scripts/generate-upstream-tool-mapping.mjs index 7769815..67b05f6 100644 --- a/scripts/generate-upstream-tool-mapping.mjs +++ b/scripts/generate-upstream-tool-mapping.mjs @@ -67,10 +67,10 @@ const mapping = { schemaVersion: 1, upstream: { repository: "https://github.com/nulab/backlog-mcp-server", - version: "0.13.2", - tag: "v0.13.2", - commit: "d12f010de976af11bcd43f1d3497dc7043d26e62", - checked: "2026-07-22" + version: "0.14.0", + tag: "v0.14.0", + commit: "9da42fcfb5b69f1455e3864c49f2b57a45a4cbe9", + checked: "2026-07-31" }, target: { repository: "backlog-api", diff --git a/scripts/smoke-node.mjs b/scripts/smoke-node.mjs index b50062e..b32cfa0 100644 --- a/scripts/smoke-node.mjs +++ b/scripts/smoke-node.mjs @@ -13,8 +13,8 @@ for (const args of [["--version"], ["--help"], ["tools", "list"]]) { const runtime = await import("../bundle/backlog-api-runtime.mjs"); assert.equal(runtime.product.name, "backlog-api"); -assert.equal(runtime.product.version, "0.5.0"); -assert.equal(runtime.listOperations().length, 59); +assert.equal(runtime.product.version, "0.6.0"); +assert.equal(runtime.listOperations().length, 63); assert.equal( runtime.listOperations().find((operation) => operation.name === "get_rate_limit") ?.requiredPermission, diff --git a/src/core/catalog.ts b/src/core/catalog.ts index d2dca37..070b34a 100644 --- a/src/core/catalog.ts +++ b/src/core/catalog.ts @@ -24,6 +24,7 @@ const OPERATION_POLICIES = new Map([ "get_issue_comments", "get_issue_types", "get_issues", + "get_related_issues", "get_myself", "get_notifications", "get_priorities", @@ -55,6 +56,7 @@ const OPERATION_POLICIES = new Map([ "add_project", "add_pull_request", "add_pull_request_comment", + "add_related_issue", "add_version_milestone", "add_watching", "add_wiki" @@ -63,6 +65,7 @@ const OPERATION_POLICIES = new Map([ "mark_notification_as_read", "mark_watching_as_read", "update_issue", + "update_issue_comment", "update_project", "update_pull_request", "update_pull_request_comment", @@ -74,7 +77,8 @@ const OPERATION_POLICIES = new Map([ "delete_issue", "delete_project", "delete_version", - "delete_watching" + "delete_watching", + "remove_related_issue" ], "destructive", "DELETE"), ...policyEntries([ "reset_unread_notification_count" @@ -205,6 +209,7 @@ type JsonObject = Record; const OPERATION_EXAMPLES = new Map([ ["get_issue", [{ issueKey: "PROJ-1" }, { issueId: 12345 }]], + ["get_related_issues", [{ issueKey: "PROJ-1" }, { issueId: 12345 }]], ["get_project", [{ projectKey: "PROJ" }, { projectId: 12345 }]], ["get_rate_limit", [{}]], [ @@ -216,6 +221,12 @@ const OPERATION_EXAMPLES = new Map([ priorityId: 3 }] ], + ["add_related_issue", [{ issueKey: "PROJ-1", targetIssueId: 12346 }]], + ["remove_related_issue", [{ issueKey: "PROJ-1", relatedIssueId: 12346 }]], + [ + "update_issue_comment", + [{ issueKey: "PROJ-1", commentId: 12345, content: "Updated comment" }] + ], ["delete_issue", [{ issueKey: "PROJ-1" }]] ]); diff --git a/src/core/contracts.ts b/src/core/contracts.ts index 0654652..a35d74b 100644 --- a/src/core/contracts.ts +++ b/src/core/contracts.ts @@ -47,6 +47,8 @@ export interface BacklogResourceIdentifiers { projectKey?: string; issueId?: number | readonly number[]; issueKey?: string; + targetIssueId?: number; + relatedIssueId?: number; wikiId?: number; repositoryId?: number; repositoryName?: string; diff --git a/src/core/operation-input-constraints.ts b/src/core/operation-input-constraints.ts index 9686522..069fd76 100644 --- a/src/core/operation-input-constraints.ts +++ b/src/core/operation-input-constraints.ts @@ -5,10 +5,14 @@ export interface AlternativeFieldConstraint { const ISSUE_ID_OR_KEY_OPERATIONS = [ "add_issue_comment", + "add_related_issue", "delete_issue", "get_issue", "get_issue_comments", - "update_issue" + "get_related_issues", + "remove_related_issue", + "update_issue", + "update_issue_comment" ] as const; const PROJECT_ID_OR_KEY_OPERATIONS = [ diff --git a/src/core/verbose-metadata.ts b/src/core/verbose-metadata.ts index 64ac65e..bab29d4 100644 --- a/src/core/verbose-metadata.ts +++ b/src/core/verbose-metadata.ts @@ -26,6 +26,8 @@ const TARGET_FIELDS = new Set([ "issueId", "issueKey", "issueIdOrKey", + "targetIssueId", + "relatedIssueId", "wikiId", "repoId", "repoName", @@ -45,6 +47,8 @@ export function extractInputAccessMetadata( spaceKey: stringValue(input.spaceKey), ...idOrKey(input.projectId, input.projectKey, input.projectIdOrKey, "project"), ...idOrKey(input.issueId, input.issueKey, input.issueIdOrKey, "issue"), + targetIssueId: numberValue(input.targetIssueId), + relatedIssueId: numberValue(input.relatedIssueId), wikiId: numberValue(input.wikiId), repositoryId: numberValue(input.repositoryId) ?? numberValue(input.repoId), repositoryName: stringValue(input.repositoryName) ?? stringValue(input.repoName), diff --git a/src/product.ts b/src/product.ts index 8fae437..51d79ec 100644 --- a/src/product.ts +++ b/src/product.ts @@ -1,5 +1,5 @@ export const product = Object.freeze({ name: "backlog-api", - version: "0.5.0", - upstream: "backlog-mcp-server@0.13.2" + version: "0.6.0", + upstream: "backlog-mcp-server@0.14.0" }); diff --git a/tests/node-cli.test.mjs b/tests/node-cli.test.mjs index 25daa3d..1d6ffc1 100644 --- a/tests/node-cli.test.mjs +++ b/tests/node-cli.test.mjs @@ -7,7 +7,7 @@ const CLI = "bundle/backlog-api.mjs"; test("CLI metadata commands do not require credentials", () => { const version = run(["--version"]); assert.equal(version.status, 0); - assert.equal(version.stdout, "0.5.0\n"); + assert.equal(version.stdout, "0.6.0\n"); assert.equal(version.stderr, ""); const help = run(["--help"]); @@ -33,7 +33,7 @@ test("CLI metadata commands do not require credentials", () => { ); const catalog = JSON.parse(run(["tools", "list"]).stdout); - assert.equal(catalog.operations.length, 59); + assert.equal(catalog.operations.length, 63); assert.equal( catalog.operations.find((operation) => operation.name === "get_issue").requiredPermission, "READ" @@ -46,6 +46,26 @@ test("CLI metadata commands do not require credentials", () => { catalog.operations.find((operation) => operation.name === "get_rate_limit").requiredPermission, "READ" ); + assert.equal( + catalog.operations.find((operation) => operation.name === "get_related_issues") + .requiredPermission, + "READ" + ); + assert.equal( + catalog.operations.find((operation) => operation.name === "add_related_issue") + .requiredPermission, + "CREATE" + ); + assert.equal( + catalog.operations.find((operation) => operation.name === "update_issue_comment") + .requiredPermission, + "UPDATE" + ); + assert.equal( + catalog.operations.find((operation) => operation.name === "remove_related_issue") + .requiredPermission, + "DELETE" + ); const description = JSON.parse(run(["tools", "describe", "get_issue"]).stdout); assert.equal(description.operation.name, "get_issue"); @@ -66,6 +86,23 @@ test("CLI metadata commands do not require credentials", () => { { issueId: 12345 } ]); + const relatedDescription = JSON.parse( + run(["tools", "describe", "add_related_issue"]).stdout + ); + assert.equal(relatedDescription.operation.requiredPermission, "CREATE"); + assert.equal(relatedDescription.operation.requiresConfirmation, false); + assert.equal(relatedDescription.operation.inputSchema.properties.targetIssueId.type, "number"); + assert.deepEqual(relatedDescription.operation.inputSchema.allOf[0].anyOf, [ + { required: ["issueId"] }, + { required: ["issueKey"] } + ]); + + const removeRelatedDescription = JSON.parse( + run(["tools", "describe", "remove_related_issue"]).stdout + ); + assert.equal(removeRelatedDescription.operation.requiredPermission, "DELETE"); + assert.equal(removeRelatedDescription.operation.requiresConfirmation, true); + const alias = JSON.parse(run(["call", "get_issue", "--help"]).stdout); assert.deepEqual(alias, description); @@ -89,6 +126,7 @@ test("CLI dry-run validates complete input without Backlog credentials", () => { for (const [operation, path] of [ ["get_issue", "issueId|issueKey"], + ["get_related_issues", "issueId|issueKey"], ["get_project", "projectId|projectKey"] ]) { const invalid = run(["call", operation, "--input", "-", "--dry-run"], "{}"); @@ -105,6 +143,14 @@ test("CLI dry-run validates complete input without Backlog credentials", () => { ); assert.equal(write.status, 0); assert.equal(JSON.parse(write.stdout).dryRun, true); + + const updateComment = run( + ["call", "update_issue_comment", "--input", "-", "--allow", "UPDATE", "--dry-run"], + '{"issueKey":"TEST-1","commentId":2,"content":"updated"}', + { BACKLOG_API_ALLOWED_PERMISSIONS: "UPDATE" } + ); + assert.equal(updateComment.status, 0); + assert.equal(JSON.parse(updateComment.stdout).dryRun, true); }); test("CLI allows READ only by default and checks permissions before credentials", () => { @@ -146,6 +192,14 @@ test("CLI returns a structured confirmation error for destructive calls", () => const body = JSON.parse(result.stdout); assert.equal(body.success, false); assert.equal(body.diagnostics[0].code, "CONFIRMATION_REQUIRED"); + + const removeRelated = run( + ["call", "remove_related_issue", "--input", "-", "--allow", "DELETE"], + '{"issueKey":"TEST-1","relatedIssueId":2}', + { BACKLOG_API_ALLOWED_PERMISSIONS: "DELETE" } + ); + assert.equal(removeRelated.status, 1); + assert.equal(JSON.parse(removeRelated.stdout).diagnostics[0].code, "CONFIRMATION_REQUIRED"); }); test("CLI reports invalid fields as a structured usage failure", () => { diff --git a/tests/node-runtime.test.mjs b/tests/node-runtime.test.mjs index dda3421..55bd446 100644 --- a/tests/node-runtime.test.mjs +++ b/tests/node-runtime.test.mjs @@ -21,11 +21,11 @@ test("all upstream operations have deterministic source and test mappings", () = const runtimeNames = listOperations().map((entry) => entry.name); const mappedNames = mapping.operations.map((entry) => entry.operation); - assert.equal(runtimeNames.length, 59); + assert.equal(runtimeNames.length, 63); assert.deepEqual(mappedNames, runtimeNames); const upstreamEntries = mapping.operations.filter((entry) => entry.origin === "upstream"); const localEntries = mapping.operations.filter((entry) => entry.origin === "backlog-api"); - assert.equal(upstreamEntries.length, 58); + assert.equal(upstreamEntries.length, 62); assert.deepEqual(localEntries.map((entry) => entry.operation), ["get_rate_limit"]); assert.equal(localEntries[0].upstreamSource, null); assert.equal(localEntries[0].targetEntry, "src/core/local-tools.ts"); @@ -64,15 +64,23 @@ test("all operations expose machine-readable agent contracts", () => { test("mutation classification preserves unusual upstream names", () => { assert.equal(classifyMutation("get_issue"), "read"); + assert.equal(classifyMutation("get_related_issues"), "read"); assert.equal(classifyMutation("add_issue"), "mutation"); + assert.equal(classifyMutation("add_related_issue"), "mutation"); assert.equal(classifyMutation("addDocument"), "mutation"); + assert.equal(classifyMutation("update_issue_comment"), "mutation"); assert.equal(classifyMutation("delete_project"), "destructive"); + assert.equal(classifyMutation("remove_related_issue"), "destructive"); assert.equal(classifyMutation("reset_unread_notification_count"), "broad-mutation"); assert.equal(requiredPermission("get_issue"), "READ"); + assert.equal(requiredPermission("get_related_issues"), "READ"); assert.equal(requiredPermission("addDocument"), "CREATE"); + assert.equal(requiredPermission("add_related_issue"), "CREATE"); assert.equal(requiredPermission("mark_notification_as_read"), "UPDATE"); + assert.equal(requiredPermission("update_issue_comment"), "UPDATE"); assert.equal(requiredPermission("reset_unread_notification_count"), "UPDATE"); assert.equal(requiredPermission("delete_project"), "DELETE"); + assert.equal(requiredPermission("remove_related_issue"), "DELETE"); assert.throws( () => classifyMutation("archive_issue"), /has no declared access policy/ @@ -346,6 +354,39 @@ test("verbose metadata includes pagination and ID filters but omits search text" assert.doesNotMatch(events.map(formatBacklogAccessEvent).join("\n"), /SECRET|keyword/); }); +test("related-issue operations expose only whitelisted identifiers in verbose events", async () => { + const events = []; + const result = await runOperation( + "add_related_issue", + { + issueKey: "SAFE-5", + targetIssueId: 6, + note: "SECRET RELATION NOTE" + }, + { + env: { BACKLOG_API_ALLOWED_PERMISSIONS: "CREATE" }, + allowedPermissions: ["CREATE"], + registry: { + resolveClient() { + return { + async addRelatedIssue() { + return { id: 6, issueKey: "SAFE-6", summary: "SECRET RESULT" }; + } + }; + } + }, + onAccess(event) { + events.push(event); + } + } + ); + + assert.equal(result.success, true); + assert.deepEqual(events[0].target, { issueKey: "SAFE-5", targetIssueId: 6 }); + assert.deepEqual(events[1].result, { issueId: 6, issueKey: "SAFE-6" }); + assert.doesNotMatch(events.map(formatBacklogAccessEvent).join("\n"), /SECRET|note/); +}); + test("fields selects top-level and nested result data", async () => { const registry = { resolveClient() { @@ -434,4 +475,17 @@ test("destructive operations require confirmation before client resolution", asy assert.equal(result.success, false); assert.equal(result.diagnostics[0].code, "CONFIRMATION_REQUIRED"); + + const removeRelated = await runOperation( + "remove_related_issue", + { issueKey: "TEST-1", relatedIssueId: 12345 }, + { + registry, + env: { BACKLOG_API_ALLOWED_PERMISSIONS: "DELETE" }, + allowedPermissions: ["DELETE"] + } + ); + + assert.equal(removeRelated.success, false); + assert.equal(removeRelated.diagnostics[0].code, "CONFIRMATION_REQUIRED"); }); diff --git a/tests/upstream-differential.test.mjs b/tests/upstream-differential.test.mjs index ce6ffe1..b37cb5d 100644 --- a/tests/upstream-differential.test.mjs +++ b/tests/upstream-differential.test.mjs @@ -3,9 +3,13 @@ import test from "node:test"; import { backlogErrorHandler } from "backlog-mcp-server/build/backlog/backlogErrorHandler.js"; import { composeToolHandler } from "backlog-mcp-server/build/handlers/builders/composeToolHandler.js"; import { addIssueTool } from "backlog-mcp-server/build/tools/addIssue.js"; +import { addRelatedIssueTool } from "backlog-mcp-server/build/tools/addRelatedIssue.js"; import { deleteIssueTool } from "backlog-mcp-server/build/tools/deleteIssue.js"; import { getIssueTool } from "backlog-mcp-server/build/tools/getIssue.js"; +import { getRelatedIssuesTool } from "backlog-mcp-server/build/tools/getRelatedIssues.js"; +import { removeRelatedIssueTool } from "backlog-mcp-server/build/tools/removeRelatedIssue.js"; import { updateIssueTool } from "backlog-mcp-server/build/tools/updateIssue.js"; +import { updateIssueCommentTool } from "backlog-mcp-server/build/tools/updateIssueComment.js"; import { runOperation } from "../dist/ts/core/run-operation.js"; const translation = { @@ -51,6 +55,7 @@ const cases = [ organization: "TEST", issueKey: "TEST-3", summary: "updated fixture", + parentIssueId: 99, customFields: [{ id: 41, value: ["one", "two"] }] }, response: { id: 3, issueKey: "TEST-3", summary: "updated fixture" } @@ -62,6 +67,43 @@ const cases = [ factory: deleteIssueTool, input: { organization: "TEST", issueKey: "TEST-4" }, response: { id: 4, issueKey: "TEST-4", summary: "deleted fixture" } + }, + { + operation: "get_related_issues", + permission: "READ", + method: "getRelatedIssues", + factory: getRelatedIssuesTool, + input: { organization: "TEST", issueKey: "TEST-5" }, + response: [{ id: 5, issueKey: "TEST-6", summary: "related fixture", type: "Relates" }] + }, + { + operation: "add_related_issue", + permission: "CREATE", + method: "addRelatedIssue", + factory: addRelatedIssueTool, + input: { organization: "TEST", issueKey: "TEST-7", targetIssueId: 8 }, + response: { id: 7, issueKey: "TEST-7", summary: "added relation", type: "Relates" } + }, + { + operation: "update_issue_comment", + permission: "UPDATE", + method: "patchIssueComment", + factory: updateIssueCommentTool, + input: { + organization: "TEST", + issueKey: "TEST-8", + commentId: 9, + content: "updated comment fixture" + }, + response: { id: 9, content: "updated comment fixture" } + }, + { + operation: "remove_related_issue", + permission: "DELETE", + method: "removeRelatedIssue", + factory: removeRelatedIssueTool, + input: { organization: "TEST", issueKey: "TEST-9", relatedIssueId: 10 }, + response: { id: 9, issueKey: "TEST-9", summary: "removed relation", type: "Relates" } } ]; @@ -138,6 +180,40 @@ test("fields selection matches the upstream composed MCP handler", async () => { assert.deepEqual(nodeResult.result, mcpData(upstreamResult)); }); +test("non-positive issue IDs fall back to issueKey in both wrappers", async () => { + const input = { organization: "TEST", issueId: 0, issueKey: "TEST-10" }; + const upstreamCalls = []; + const nodeCalls = []; + const upstreamTool = getIssueTool(mockBacklog("getIssue", { + id: 10, + issueKey: "TEST-10", + summary: "fallback fixture" + }, upstreamCalls), translation); + const upstreamHandler = composeToolHandler(upstreamTool, { + useFields: false, + errorHandler: backlogErrorHandler, + maxTokens: 100_000 + }); + const upstreamResult = await upstreamHandler(input, {}); + const nodeResult = await runOperation("get_issue", input, { + registry: { + resolveClient() { + return mockBacklog("getIssue", { + id: 10, + issueKey: "TEST-10", + summary: "fallback fixture" + }, nodeCalls); + } + }, + allowedPermissions: ["READ"] + }); + + assert.equal(nodeResult.success, true); + assert.deepEqual(nodeResult.result, mcpData(upstreamResult)); + assert.deepEqual(upstreamCalls, [{ method: "getIssue", args: ["TEST-10"] }]); + assert.deepEqual(nodeCalls, upstreamCalls); +}); + test("upstream and Node wrappers preserve the same Backlog error message", async () => { const message = "fixture Backlog failure"; const upstreamTool = getIssueTool(failingBacklog("getIssue", message), translation);