backlog-mcp-server 0.17.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/README.ja.md +31 -32
  2. package/README.md +36 -32
  3. package/build/auth/oauthRoutes.js +53 -1
  4. package/build/backlog/customFields.js +12 -9
  5. package/build/createBacklogMcpServer.d.ts +4 -6
  6. package/build/createBacklogMcpServer.js +4 -12
  7. package/build/handlers/builders/composeToolHandler.d.ts +1 -1
  8. package/build/handlers/builders/composeToolHandler.js +15 -9
  9. package/build/handlers/transformers/wrapWithFieldPicking.d.ts +14 -1
  10. package/build/handlers/transformers/wrapWithFieldPicking.js +30 -36
  11. package/build/httpMcpServer.js +8 -1
  12. package/build/index.js +20 -15
  13. package/build/lib.d.ts +1 -1
  14. package/build/loadDescriptionOverrides.js +2 -2
  15. package/build/reportUnknownOverrideKeys.js +0 -1
  16. package/build/tools/addDocument.d.ts +3 -3
  17. package/build/tools/addDocument.js +18 -3
  18. package/build/tools/addIssue.d.ts +3 -3
  19. package/build/tools/addIssue.js +32 -2
  20. package/build/tools/addIssueComment.d.ts +3 -3
  21. package/build/tools/addIssueComment.js +14 -2
  22. package/build/tools/addProject.d.ts +3 -3
  23. package/build/tools/addProject.js +22 -2
  24. package/build/tools/addPullRequest.d.ts +3 -3
  25. package/build/tools/addPullRequest.js +26 -2
  26. package/build/tools/addPullRequestComment.d.ts +3 -3
  27. package/build/tools/addPullRequestComment.js +12 -2
  28. package/build/tools/addRelatedIssue.d.ts +3 -3
  29. package/build/tools/addRelatedIssue.js +33 -2
  30. package/build/tools/addVersionMilestone.d.ts +3 -3
  31. package/build/tools/addVersionMilestone.js +12 -2
  32. package/build/tools/addWatching.d.ts +3 -3
  33. package/build/tools/addWatching.js +12 -2
  34. package/build/tools/addWiki.d.ts +3 -3
  35. package/build/tools/addWiki.js +16 -2
  36. package/build/tools/countIssues.d.ts +3 -3
  37. package/build/tools/countIssues.js +3 -2
  38. package/build/tools/deleteIssue.d.ts +3 -3
  39. package/build/tools/deleteIssue.js +32 -2
  40. package/build/tools/deleteProject.d.ts +3 -3
  41. package/build/tools/deleteProject.js +22 -2
  42. package/build/tools/deleteVersion.d.ts +3 -3
  43. package/build/tools/deleteVersion.js +12 -2
  44. package/build/tools/deleteWatching.d.ts +3 -3
  45. package/build/tools/deleteWatching.js +12 -2
  46. package/build/tools/getCategories.d.ts +3 -3
  47. package/build/tools/getCategories.js +8 -2
  48. package/build/tools/getCustomFields.d.ts +3 -3
  49. package/build/tools/getCustomFields.js +22 -2
  50. package/build/tools/getDocument.d.ts +3 -3
  51. package/build/tools/getDocument.js +17 -2
  52. package/build/tools/getDocumentTree.d.ts +3 -3
  53. package/build/tools/getDocumentTree.js +7 -2
  54. package/build/tools/getDocuments.d.ts +3 -3
  55. package/build/tools/getDocuments.js +17 -2
  56. package/build/tools/getGitRepositories.d.ts +3 -3
  57. package/build/tools/getGitRepositories.js +17 -2
  58. package/build/tools/getGitRepository.d.ts +3 -3
  59. package/build/tools/getGitRepository.js +17 -2
  60. package/build/tools/getIssue.d.ts +3 -3
  61. package/build/tools/getIssue.js +32 -2
  62. package/build/tools/getIssueComments.d.ts +3 -3
  63. package/build/tools/getIssueComments.js +14 -2
  64. package/build/tools/getIssueTypes.d.ts +3 -3
  65. package/build/tools/getIssueTypes.js +11 -2
  66. package/build/tools/getIssues.d.ts +17 -17
  67. package/build/tools/getIssues.js +32 -2
  68. package/build/tools/getMyself.d.ts +3 -3
  69. package/build/tools/getMyself.js +11 -2
  70. package/build/tools/getNotifications.d.ts +3 -3
  71. package/build/tools/getNotifications.js +15 -2
  72. package/build/tools/getNotificationsCount.d.ts +3 -3
  73. package/build/tools/getNotificationsCount.js +5 -2
  74. package/build/tools/getPriorities.d.ts +3 -3
  75. package/build/tools/getPriorities.js +3 -2
  76. package/build/tools/getProject.d.ts +3 -3
  77. package/build/tools/getProject.js +22 -2
  78. package/build/tools/getProjectList.d.ts +3 -3
  79. package/build/tools/getProjectList.js +22 -2
  80. package/build/tools/getProjectUsers.d.ts +3 -3
  81. package/build/tools/getProjectUsers.js +11 -2
  82. package/build/tools/getPullRequest.d.ts +3 -3
  83. package/build/tools/getPullRequest.js +26 -2
  84. package/build/tools/getPullRequestComments.d.ts +3 -3
  85. package/build/tools/getPullRequestComments.js +12 -2
  86. package/build/tools/getPullRequests.d.ts +3 -3
  87. package/build/tools/getPullRequests.js +26 -2
  88. package/build/tools/getPullRequestsCount.d.ts +3 -3
  89. package/build/tools/getPullRequestsCount.js +5 -2
  90. package/build/tools/getRelatedIssues.d.ts +3 -3
  91. package/build/tools/getRelatedIssues.js +33 -2
  92. package/build/tools/getResolutions.d.ts +3 -3
  93. package/build/tools/getResolutions.js +3 -2
  94. package/build/tools/getSpace.d.ts +3 -3
  95. package/build/tools/getSpace.js +13 -2
  96. package/build/tools/getSpaceActivities.d.ts +3 -3
  97. package/build/tools/getSpaceActivities.js +12 -2
  98. package/build/tools/getUserRecentUpdates.d.ts +3 -3
  99. package/build/tools/getUserRecentUpdates.js +12 -2
  100. package/build/tools/getUserStarsCount.d.ts +3 -3
  101. package/build/tools/getUserStarsCount.js +3 -2
  102. package/build/tools/getUsers.d.ts +3 -3
  103. package/build/tools/getUsers.js +11 -2
  104. package/build/tools/getVersionMilestoneList.d.ts +3 -3
  105. package/build/tools/getVersionMilestoneList.js +12 -2
  106. package/build/tools/getWatchingListCount.d.ts +3 -3
  107. package/build/tools/getWatchingListCount.js +5 -2
  108. package/build/tools/getWatchingListItems.d.ts +3 -3
  109. package/build/tools/getWatchingListItems.js +12 -2
  110. package/build/tools/getWiki.d.ts +3 -3
  111. package/build/tools/getWiki.js +16 -2
  112. package/build/tools/getWikiPages.d.ts +3 -3
  113. package/build/tools/getWikiPages.js +12 -2
  114. package/build/tools/getWikisCount.d.ts +3 -3
  115. package/build/tools/getWikisCount.js +3 -2
  116. package/build/tools/markNotificationAsRead.d.ts +7 -2
  117. package/build/tools/markNotificationAsRead.js +6 -1
  118. package/build/tools/markWatchingAsRead.d.ts +7 -2
  119. package/build/tools/markWatchingAsRead.js +6 -1
  120. package/build/tools/removeRelatedIssue.d.ts +3 -3
  121. package/build/tools/removeRelatedIssue.js +33 -2
  122. package/build/tools/resetUnreadNotificationCount.d.ts +3 -3
  123. package/build/tools/resetUnreadNotificationCount.js +5 -2
  124. package/build/tools/shared/customFieldFiltersSchema.d.ts +1 -1
  125. package/build/tools/updateIssue.d.ts +3 -3
  126. package/build/tools/updateIssue.js +32 -2
  127. package/build/tools/updateIssueComment.d.ts +3 -3
  128. package/build/tools/updateIssueComment.js +14 -2
  129. package/build/tools/updateProject.d.ts +3 -3
  130. package/build/tools/updateProject.js +22 -2
  131. package/build/tools/updatePullRequest.d.ts +3 -3
  132. package/build/tools/updatePullRequest.js +26 -2
  133. package/build/tools/updatePullRequestComment.d.ts +3 -3
  134. package/build/tools/updatePullRequestComment.js +12 -2
  135. package/build/tools/updateVersionMilestone.d.ts +3 -3
  136. package/build/tools/updateVersionMilestone.js +12 -2
  137. package/build/tools/updateWatching.d.ts +3 -3
  138. package/build/tools/updateWatching.js +12 -2
  139. package/build/tools/updateWiki.d.ts +3 -3
  140. package/build/tools/updateWiki.js +16 -2
  141. package/build/types/outputFields.d.ts +26 -0
  142. package/build/types/outputFields.js +3 -0
  143. package/build/types/tool.d.ts +22 -9
  144. package/build/types/zod/activityType.d.ts +38 -0
  145. package/build/types/zod/activityType.js +38 -0
  146. package/build/utils/fieldSelection.d.ts +22 -0
  147. package/build/utils/fieldSelection.js +40 -0
  148. package/build/utils/logger.js +41 -3
  149. package/build/utils/resolveIdOrKey.d.ts +2 -2
  150. package/build/utils/toolsetUtils.d.ts +1 -3
  151. package/build/utils/toolsetUtils.js +0 -12
  152. package/package.json +17 -25
  153. package/build/tools/dynamicTools/toolsets.d.ts +0 -15
  154. package/build/tools/dynamicTools/toolsets.js +0 -103
  155. package/build/types/zod/backlogOutputDefinition.d.ts +0 -3334
  156. package/build/types/zod/backlogOutputDefinition.js +0 -483
  157. package/build/utils/generateFieldsDescription.d.ts +0 -5
  158. package/build/utils/generateFieldsDescription.js +0 -50
  159. package/build/utils/toolRegistrar.d.ts +0 -5
  160. package/build/utils/toolRegistrar.js +0 -12
package/README.ja.md CHANGED
@@ -16,7 +16,7 @@ Backlog API とやり取りするための Model Context Protocol(MCP)サー
16
16
  - Wikiページサポート
17
17
  - Gitリポジトリとプルリクエストツール
18
18
  - 通知ツール
19
- - 最適化されたレスポンスのためのGraphQLスタイルのフィールド選択
19
+ - 最適化されたレスポンスのためのフィールド選択
20
20
  - 大規模なレスポンスに対するトークン制限
21
21
 
22
22
  ## 利用開始
@@ -188,6 +188,14 @@ OAuth有効時、サーバーは以下のOAuthエンドポイントを自動的
188
188
 
189
189
  MCP認可仕様に対応するMCPクライアントは、これらのエンドポイントを自動的に使用します。
190
190
 
191
+ `POST /register` は登録できる redirect URI を制限します。ループバック URI
192
+ (`http://localhost`、`http://127.0.0.1`、`http://[::1]`)はユーザーのマシン上で動く
193
+ アプリが認可コードを受け取るための手段で、`"application_type": "native"` を宣言した
194
+ クライアント、または宣言がない場合は redirect URI が**すべて**ループバックである
195
+ クライアントから受け付けます。`"application_type": "web"` を宣言したクライアントや、
196
+ 宣言なしでリモートの `https:` URI とループバック URI を混在させた登録は
197
+ `invalid_client_metadata` で拒否されます。
198
+
191
199
  > **制約事項:**
192
200
  >
193
201
  > - OAuthモードは現在、単一のBacklog組織のみをサポートしています。複数組織設定との併用はできません。
@@ -233,26 +241,6 @@ ENABLE_TOOLSETS="space,project,issue"
233
241
 
234
242
  > 🧩 ヒント: `project` ツールセットは、他の多くのツールがエントリポイントとしてプロジェクトデータに依存しているため、強く推奨されます。
235
243
 
236
- ### 動的なツールセット検出(実験的)
237
-
238
- MCPサーバーをAIエージェントと共に使用している場合、実行時にツールセットの動的な検出を有効にすることができます:
239
-
240
- CLI経由での有効化:
241
-
242
- ```
243
- --dynamic-toolsets
244
- ```
245
-
246
- または環境変数経由:
247
-
248
- ```
249
- -e DYNAMIC_TOOLSETS=1 \
250
- ```
251
-
252
- 動的ツールセットを有効にすると、LLMはツールインターフェースを介してオンデマンドでツールセットを一覧表示およびアクティブ化できるようになります。
253
-
254
- > **HTTPでのスコープ:** MCP `2026-07-28` にはプロトコルセッションがないため、アクティブ化したツールセットはクライアント単位ではなく**サーバープロセス単位**で保持されます。HTTPトランスポートでは接続中の全クライアントが1つのツールセット状態を共有し、プロセス再起動でリセットされます。共有されるのはツールの*可視性*のみで、認可は共有されません(各呼び出しは呼び出し元自身の認証情報で認証されます)。
255
-
256
244
  ## 利用可能なツール
257
245
 
258
246
  以下のような Backlog 機能に対応するツールを提供しています:
@@ -437,7 +425,7 @@ PREFIX="backlog_"
437
425
 
438
426
  ### レスポンスの最適化とトークン制限
439
427
 
440
- #### フィールド選択(GraphQLスタイル)
428
+ #### フィールド選択
441
429
 
442
430
  ```
443
431
  --optimize-response
@@ -449,17 +437,13 @@ PREFIX="backlog_"
449
437
  OPTIMIZE_RESPONSE=1
450
438
  ```
451
439
 
452
- 次に、特定のフィールドのみを要求します:
440
+ **一覧を返すツール**に `fields` パラメータ(省略可)が追加されます。値はそのツールの結果のトップレベルのフィールド名のリストで、enum として公開されるため、そのツールに存在しない名前は無視されずに拒否されます。単体のレコードを返すツールには追加されません(パラメータは毎セッション費用がかかる一方、1 件のレコードから削れる量はごくわずかなためです)。
453
441
 
454
442
  ```
455
- get_project(projectIdOrKey: "PROJECT-KEY", fields: "{ name key description }")
443
+ get_project(projectIdOrKey: "PROJECT-KEY", fields: ["name", "key", "description"])
456
444
  ```
457
445
 
458
- AIはフィールド選択を使用してレスポンスを最適化します:
459
-
460
- ```
461
- get_project(projectIdOrKey: "PROJECT-KEY", fields: "{ name key description }")
462
- ```
446
+ `fields` を省略すると結果全体が返ります。選択は 1 階層のみで、オブジェクトや配列のフィールドを指定した場合はそのまま丸ごと返ります。
463
447
 
464
448
  利点:
465
449
 
@@ -485,6 +469,22 @@ MAX_TOKENS=10000
485
469
 
486
470
  > 注:これはベストエフォートの緩和策であり、保証された強制ではありません。
487
471
 
472
+ ### ログ出力
473
+
474
+ ログは **stderr** に出力されます(stdio トランスポートでは stdout が JSON-RPC のストリームに使われるため)。
475
+
476
+ | 変数 | 説明 |
477
+ | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
478
+ | `LOG_LEVEL` | `fatal` / `error` / `warn` / `info` / `debug` / `trace` / `silent`。既定は `NODE_ENV` が `production`(未設定時もこれに含まれる)のとき `error`、それ以外は `debug`。認識できない値は通知した上で既定値にフォールバックします。 |
479
+
480
+ 出力**フォーマット**は引き続き `NODE_ENV` が決めます(`production` 以外では `pino-pretty` が利用可能なら人間向けの整形出力に切り替わります)。ログ量を変えたいときは `NODE_ENV` ではなく `LOG_LEVEL` を設定してください。デプロイ先で構造化 JSON のまま保てます。
481
+
482
+ `pino-pretty` は開発用の依存なので、公開されている npm パッケージにもコンテナイメージにも含まれていません。そのため、これらでは `NODE_ENV` の値に関わらず常に構造化 JSON が出力され、出力を変えられるのは `LOG_LEVEL` だけです。
483
+
484
+ ```
485
+ LOG_LEVEL=info node build/index.js --transport http
486
+ ```
487
+
488
488
  ### 完全なカスタム設定例
489
489
 
490
490
  このセクションでは、複数の環境変数を使用した高度な設定を示します。これらは実験的な機能であり、すべてのMCPクライアントでサポートされているとは限りません。これはMCP標準仕様の一部ではなく、注意して使用する必要があります。
@@ -518,8 +518,7 @@ MAX_TOKENS=10000
518
518
  "MAX_TOKENS": "10000",
519
519
  "OPTIMIZE_RESPONSE": "1",
520
520
  "PREFIX": "backlog_",
521
- "ENABLE_TOOLSETS": "space,project,issue",
522
- "ENABLE_DYNAMIC_TOOLSETS": "1"
521
+ "ENABLE_TOOLSETS": "space,project,issue"
523
522
  }
524
523
  }
525
524
  }
@@ -546,7 +545,7 @@ pnpm test
546
545
  サーバーはいくつかのコマンドラインオプションをサポートしています:
547
546
 
548
547
  - `--export-descriptions`: ツール一覧の構築時に解決される説明キーと値をエクスポート。旧名は `--export-translations` で、非推奨エイリアスとして当面動作しますが、将来のリリースで削除されます
549
- - `--optimize-response`: GraphQLスタイルのフィールド選択を有効にする
548
+ - `--optimize-response`: 各ツールに、返す結果フィールドを選ぶ `fields` パラメータを追加する
550
549
  - `--max-tokens=NUMBER`: レスポンスの最大トークン制限を設定
551
550
  - `--prefix=STRING`: すべてのツール名に付加するオプションの文字列プレフィックス(デフォルト:"")
552
551
  - `--enable-toolsets <toolsets...>`: 有効にするツールセットを指定します(カンマ区切りまたは複数の引数)。デフォルトは "all" です。
package/README.md CHANGED
@@ -17,7 +17,7 @@ A Model Context Protocol (MCP) server for interacting with the Backlog API. This
17
17
  - Wiki page support
18
18
  - Git repository and pull request tools
19
19
  - Notification tools
20
- - GraphQL-style field selection for optimized responses
20
+ - Field selection for optimized responses
21
21
  - Token limiting for large responses
22
22
 
23
23
  ## Getting Started
@@ -215,6 +215,14 @@ The server automatically exposes the following OAuth endpoints when OAuth is ena
215
215
 
216
216
  MCP clients that support the MCP authorization specification will use these endpoints automatically.
217
217
 
218
+ `POST /register` restricts which redirect URIs a client may register. A loopback
219
+ URI (`http://localhost`, `http://127.0.0.1`, `http://[::1]`) is how an app running
220
+ on the user's machine receives the authorization code, and is accepted from a
221
+ client that declares `"application_type": "native"` — or, when the field is
222
+ absent, from one whose redirect URIs are _all_ loopback. A client declaring
223
+ `"application_type": "web"`, or mixing a remote `https:` URI with a loopback one
224
+ without declaring itself, is rejected with `invalid_client_metadata`.
225
+
218
226
  > **Limitations:**
219
227
  >
220
228
  > - OAuth mode currently supports a single Backlog organization. It is not compatible with the multi-organization configuration.
@@ -260,26 +268,6 @@ Using selective toolsets can be helpful if the toolset list is too large for you
260
268
 
261
269
  > 🧩 Tip: `project` toolset is highly recommended, as many other tools rely on project data as an entry point.
262
270
 
263
- ### Dynamic Toolset Discovery (Experimental)
264
-
265
- If you're using the MCP server with AI agents, you can enable dynamic discovery of toolsets at runtime:
266
-
267
- Enabling via CLI:
268
-
269
- ```
270
- --dynamic-toolsets
271
- ```
272
-
273
- Or via environment variable::
274
-
275
- ```
276
- -e DYNAMIC_TOOLSETS=1 \
277
- ```
278
-
279
- With dynamic toolsets enabled, the LLM will be able to list and activate toolsets on demand via tool interface.
280
-
281
- > **Scope over HTTP:** MCP `2026-07-28` has no protocol sessions, so an activated toolset is remembered per **server process**, not per client. On the HTTP transport every connected client shares one toolset state, and it resets when the process restarts. Tool _visibility_ is shared; authorization is not — every call is still authenticated with the caller's own credentials.
282
-
283
271
  ## Available Tools
284
272
 
285
273
  ### Toolset: `space`
@@ -549,7 +537,7 @@ This is especially useful if you're using multiple MCP servers or tools in the s
549
537
 
550
538
  ### Response Optimization & Token Limits
551
539
 
552
- #### Field Selection (GraphQL-style)
540
+ #### Field Selection
553
541
 
554
542
  ```
555
543
  --optimize-response
@@ -561,17 +549,18 @@ Or environment variable:
561
549
  OPTIMIZE_RESPONSE=1
562
550
  ```
563
551
 
564
- Then, request only specific fields:
552
+ Tools that return a **list** then take an optional `fields` parameter: a list of
553
+ top-level field names from that tool's own result, published as an enum so a name
554
+ the tool does not have is rejected rather than ignored. Tools that return a single
555
+ record do not get it — the parameter costs schema on every session, and one record
556
+ has almost nothing to trim.
565
557
 
566
558
  ```
567
- get_project(projectIdOrKey: "PROJECT-KEY", fields: "{ name key description }")
559
+ get_project(projectIdOrKey: "PROJECT-KEY", fields: ["name", "key", "description"])
568
560
  ```
569
561
 
570
- The AI will use field selection to optimize the response:
571
-
572
- ```
573
- get_project(projectIdOrKey: "PROJECT-KEY", fields: "{ name key description }")
574
- ```
562
+ Omitting `fields` returns the whole result. Selection is one level deep: naming an
563
+ object or array field returns it whole.
575
564
 
576
565
  Benefits:
577
566
 
@@ -597,6 +586,22 @@ If a response exceeds the limit, it will be truncated with a warning.
597
586
 
598
587
  > Note: This is a best-effort mitigation, not a guaranteed enforcement.
599
588
 
589
+ ### Logging
590
+
591
+ The server logs to **stderr** (stdout carries the JSON-RPC stream on the stdio transport).
592
+
593
+ | Variable | Description |
594
+ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
595
+ | `LOG_LEVEL` | `fatal`, `error`, `warn`, `info`, `debug`, `trace` or `silent`. Defaults to `error` when `NODE_ENV` is `production` — which is also the default when `NODE_ENV` is unset — and to `debug` otherwise. An unrecognised value is reported and the default is used. |
596
+
597
+ `NODE_ENV` still selects the output *format*: any value other than `production` switches to human-readable `pino-pretty` output when that package is available. Use `LOG_LEVEL`, not `NODE_ENV`, to change how much is logged, so that a deployment keeps structured JSON:
598
+
599
+ `pino-pretty` is a development dependency, so neither the published npm package nor the container image carries a copy. In those, logs are structured JSON whatever `NODE_ENV` says, and `LOG_LEVEL` is the only setting that changes the output.
600
+
601
+ ```
602
+ LOG_LEVEL=info node build/index.js --transport http
603
+ ```
604
+
600
605
  ### Full Custom Configuration Example
601
606
 
602
607
  This section demonstrates advanced configuration using multiple environment variables. These are experimental features and may not be supported across all MCP clients. This is not part of the MCP standard specification and should be used with caution.
@@ -630,8 +635,7 @@ This section demonstrates advanced configuration using multiple environment vari
630
635
  "MAX_TOKENS": "10000",
631
636
  "OPTIMIZE_RESPONSE": "1",
632
637
  "PREFIX": "backlog_",
633
- "ENABLE_TOOLSETS": "space,project,issue",
634
- "ENABLE_DYNAMIC_TOOLSETS": "1"
638
+ "ENABLE_TOOLSETS": "space,project,issue"
635
639
  }
636
640
  }
637
641
  }
@@ -663,7 +667,7 @@ The server supports several command line options:
663
667
  - `--http-allowed-hosts`: Comma-separated allowed `Host` hostnames (port-agnostic). Needed when binding to all interfaces, or on a loopback bind behind a reverse proxy.
664
668
  - `--http-allowed-origins`: Comma-separated allowed `Origin` hostnames for browser-based clients. Defaults to the localhost set on a bare loopback bind, and to no `Origin` check otherwise.
665
669
  - `--export-descriptions`: Export the description keys and values resolved when building the tool list. Was named `--export-translations`; that spelling still works as a deprecated alias and will be removed in a future release
666
- - `--optimize-response`: Enable GraphQL-style field selection
670
+ - `--optimize-response`: Add a `fields` parameter to each tool for selecting which result fields to return
667
671
  - `--max-tokens=NUMBER`: Set maximum token limit for responses
668
672
  - `--prefix=STRING`: Optional string prefix to prepend to all tool names (default: "")
669
673
  - `--enable-toolsets <toolsets...>`: Specify which toolsets to enable (comma-separated or multiple arguments). Defaults to "all".
@@ -15,17 +15,54 @@ function verifyPkce(codeVerifier, codeChallenge) {
15
15
  function oauthError(code, description) {
16
16
  return { error: code, error_description: description };
17
17
  }
18
+ function isLoopbackRedirectUri(uri) {
19
+ try {
20
+ const parsed = new URL(uri);
21
+ return (parsed.protocol === 'http:' && LOCALHOST_HOSTS.includes(parsed.hostname));
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
18
27
  function isValidRedirectUri(uri) {
19
28
  try {
20
29
  const parsed = new URL(uri);
21
30
  if (parsed.protocol === 'https:')
22
31
  return true;
23
- return (parsed.protocol === 'http:' && LOCALHOST_HOSTS.includes(parsed.hostname));
32
+ return isLoopbackRedirectUri(uri);
24
33
  }
25
34
  catch {
26
35
  return false;
27
36
  }
28
37
  }
38
+ /** RFC 7591 defines these two, and defaults to `web` when the field is absent. */
39
+ const APPLICATION_TYPES = ['web', 'native'];
40
+ /**
41
+ * Decides which application type a registration is held to.
42
+ *
43
+ * A loopback redirect URI is the standard way for an app on the user's machine
44
+ * to receive the authorization code (RFC 8252), and the wrong thing entirely for
45
+ * an app that has a domain of its own: nobody owns `http://localhost`, so
46
+ * whichever process is listening on that port receives the code.
47
+ *
48
+ * RFC 7591 defaults the field to `web`, which would reject every local MCP
49
+ * client that does not declare itself — and they do not, because nothing has
50
+ * ever checked. So an undeclared registration is judged by what it asks for: a
51
+ * set of redirect URIs that is entirely loopback can only be a native client,
52
+ * and is treated as one.
53
+ *
54
+ * The inference deliberately requires *all* of them. A registration mixing a
55
+ * remote https URI with a loopback one is the shape this check exists to stop:
56
+ * something that can serve a redirect on its own domain has no need to also
57
+ * collect codes on the user's machine. Mixing is only accepted from a client
58
+ * that declares `native` outright, which RFC 8252 does allow (a native app may
59
+ * claim an https URI as well as a loopback one).
60
+ */
61
+ function resolveApplicationType(declared, redirectUris) {
62
+ if (declared)
63
+ return declared;
64
+ return redirectUris.every(isLoopbackRedirectUri) ? 'native' : 'web';
65
+ }
29
66
  export function createOAuthRoutes(config, store, mcpPath) {
30
67
  const app = new Hono();
31
68
  const { serverBaseUrl } = config;
@@ -76,6 +113,21 @@ export function createOAuthRoutes(config, store, mcpPath) {
76
113
  return c.json(oauthError('invalid_client_metadata', `redirect_uri must use https or http://localhost: ${uri}`), 400);
77
114
  }
78
115
  }
116
+ // `null` counts as not declared. Serialisers that emit null for an absent
117
+ // optional field are common, and rejecting it would break clients this
118
+ // change is meant to leave alone.
119
+ const declaredApplicationType = body.application_type ?? undefined;
120
+ if (declaredApplicationType !== undefined &&
121
+ !APPLICATION_TYPES.includes(declaredApplicationType)) {
122
+ return c.json(oauthError('invalid_client_metadata', `Unsupported application_type: ${JSON.stringify(declaredApplicationType)}. Supported: ${APPLICATION_TYPES.join(', ')}`), 400);
123
+ }
124
+ const applicationType = resolveApplicationType(declaredApplicationType, redirectUris);
125
+ if (applicationType === 'web') {
126
+ const loopback = redirectUris.filter(isLoopbackRedirectUri);
127
+ if (loopback.length > 0) {
128
+ return c.json(oauthError('invalid_client_metadata', `application_type "web" may not use a loopback redirect_uri: ${loopback.join(', ')}. Declare application_type "native" if this client runs on the user's machine.`), 400);
129
+ }
130
+ }
79
131
  const authMethod = typeof body.token_endpoint_auth_method === 'string'
80
132
  ? body.token_endpoint_auth_method
81
133
  : 'client_secret_post';
@@ -49,20 +49,23 @@ export function customFieldFiltersToPayload(customFields) {
49
49
  break;
50
50
  }
51
51
  case 'list': {
52
- if (Array.isArray(field.value)) {
53
- const values = field.value.filter((value) => Number.isFinite(value));
54
- if (values.length > 0) {
55
- result[`${baseKey}[]`] = values;
56
- }
57
- }
58
- else if (Number.isFinite(field.value)) {
59
- result[baseKey] = field.value;
52
+ // Always an array, single value included. Backlog matches a list custom
53
+ // field only on the indexed parameters `customField_1[0]=…`, which
54
+ // `Request.toQueryString` produces from an array under a `customField_`
55
+ // key. A bare `customField_1=…` is not matched, and neither is the
56
+ // `customField_1[][0]=…` that a `[]` suffix on this key would produce.
57
+ // Neither is rejected either — the filter is silently ignored and every
58
+ // issue comes back, so the caller cannot tell a filtered query from an
59
+ // unfiltered one.
60
+ const values = (Array.isArray(field.value) ? field.value : [field.value]).filter((value) => Number.isFinite(value));
61
+ if (values.length > 0) {
62
+ result[baseKey] = values;
60
63
  }
61
64
  break;
62
65
  }
63
66
  default: {
64
67
  const exhaustiveCheck = field;
65
- throw new Error(`Unsupported custom field filter type: ${exhaustiveCheck}`);
68
+ throw new Error(`Unsupported custom field filter type: ${JSON.stringify(exhaustiveCheck)}`);
66
69
  }
67
70
  }
68
71
  }
@@ -12,13 +12,11 @@ export type CreateBacklogMcpServerConfig = {
12
12
  descriptionHelper: DescriptionHelper;
13
13
  enabledToolsets: string[];
14
14
  mcpOption: MCPOptions;
15
- dynamicToolsets: boolean;
16
15
  /**
17
16
  * A toolset group to register from, instead of building one from
18
- * `enabledToolsets`. Callers that produce many servers from one factory pass
19
- * a single shared group: `enable_toolset` mutates it, and under the stateless
20
- * HTTP model — one server per request — a group built per server would throw
21
- * that mutation away the moment the request ends.
17
+ * `enabledToolsets`. Callers that produce many servers from one factory pass a
18
+ * single shared group so the tool tree is built once rather than per request.
19
+ * Nothing mutates it.
22
20
  */
23
21
  toolsetGroup?: ToolsetGroup;
24
22
  };
@@ -26,4 +24,4 @@ export type CreateBacklogMcpServerConfig = {
26
24
  * Builds a fresh MCP server instance with all Backlog tools registered.
27
25
  * Used once per stdio connection; one instance per HTTP request for Streamable HTTP.
28
26
  */
29
- export declare function createBacklogMcpServer({ version, useFields, backlog, clientRegistry, descriptionHelper, enabledToolsets, mcpOption, dynamicToolsets, toolsetGroup: sharedToolsetGroup, }: CreateBacklogMcpServerConfig): BacklogMCPServer;
27
+ export declare function createBacklogMcpServer({ version, useFields, backlog, clientRegistry, descriptionHelper, enabledToolsets, mcpOption, toolsetGroup: sharedToolsetGroup, }: CreateBacklogMcpServerConfig): BacklogMCPServer;
@@ -3,13 +3,10 @@
3
3
  import { McpServer } from '@modelcontextprotocol/server';
4
4
  import { registerDynamicTools, registerTools } from './registerTools.js';
5
5
  import { organizationTools } from './tools/dynamicTools/organizations.js';
6
- import { dynamicTools } from './tools/dynamicTools/toolsets.js';
7
- import { createToolRegistrar } from './utils/toolRegistrar.js';
8
6
  import { buildToolsetGroup } from './utils/toolsetUtils.js';
9
7
  import { wrapServerWithToolRegistry, } from './utils/wrapServerWithToolRegistry.js';
10
- // The tool list is fixed for the process lifetime (it only depends on CLI flags
11
- // and environment), so clients may cache it. With dynamic toolsets the list can
12
- // grow at runtime, so no hint is published in that case.
8
+ // The tool list is fixed for the process lifetime: it only depends on CLI flags
9
+ // and environment, so clients may cache it.
13
10
  const TOOL_LIST_CACHE_HINT = {
14
11
  'tools/list': { ttlMs: 5 * 60 * 1000, cacheScope: 'public' },
15
12
  };
@@ -17,12 +14,12 @@ const TOOL_LIST_CACHE_HINT = {
17
14
  * Builds a fresh MCP server instance with all Backlog tools registered.
18
15
  * Used once per stdio connection; one instance per HTTP request for Streamable HTTP.
19
16
  */
20
- export function createBacklogMcpServer({ version, useFields, backlog, clientRegistry, descriptionHelper, enabledToolsets, mcpOption, dynamicToolsets, toolsetGroup: sharedToolsetGroup, }) {
17
+ export function createBacklogMcpServer({ version, useFields, backlog, clientRegistry, descriptionHelper, enabledToolsets, mcpOption, toolsetGroup: sharedToolsetGroup, }) {
21
18
  const server = wrapServerWithToolRegistry(new McpServer({
22
19
  name: 'backlog',
23
20
  title: useFields ? 'backlog (field selection enabled)' : 'backlog',
24
21
  version,
25
- }, dynamicToolsets ? undefined : { cacheHints: TOOL_LIST_CACHE_HINT }));
22
+ }, { cacheHints: TOOL_LIST_CACHE_HINT }));
26
23
  const toolsetGroup = sharedToolsetGroup ??
27
24
  buildToolsetGroup(backlog, descriptionHelper, enabledToolsets);
28
25
  registerTools(server, toolsetGroup, mcpOption);
@@ -32,10 +29,5 @@ export function createBacklogMcpServer({ version, useFields, backlog, clientRegi
32
29
  if (mcpOption.useOrganization) {
33
30
  registerDynamicTools(server, organizationTools(clientRegistry, descriptionHelper), mcpOption.prefix);
34
31
  }
35
- if (dynamicToolsets) {
36
- const registrar = createToolRegistrar(server, toolsetGroup, mcpOption);
37
- const dynamicToolsetGroup = dynamicTools(registrar, descriptionHelper, toolsetGroup);
38
- registerDynamicTools(server, dynamicToolsetGroup, mcpOption.prefix);
39
- }
40
32
  return server;
41
33
  }
@@ -13,7 +13,7 @@ export interface ComposeOptions {
13
13
  useOrganization?: boolean;
14
14
  }
15
15
  type ComposedInput = {
16
- fields?: string;
16
+ fields?: string[];
17
17
  organization?: string;
18
18
  } & Record<string, unknown>;
19
19
  /**
@@ -5,7 +5,7 @@ import { wrapWithOrganizationContext } from '../transformers/wrapWithOrganizatio
5
5
  import { wrapWithTokenLimit } from '../transformers/wrapWithTokenLimit.js';
6
6
  import { wrapWithToolResult } from '../transformers/wrapWithToolResult.js';
7
7
  import { z } from 'zod';
8
- import { generateFieldsDescription } from '../../utils/generateFieldsDescription.js';
8
+ import { fieldSelectionSchema } from '../../utils/fieldSelection.js';
9
9
  /**
10
10
  * Builds the schema and handler a tool is registered with.
11
11
  *
@@ -16,20 +16,24 @@ import { generateFieldsDescription } from '../../utils/generateFieldsDescription
16
16
  */
17
17
  export function composeToolHandler(tool, options) {
18
18
  const { useFields, errorHandler, maxTokens, useOrganization = false, } = options;
19
- // Step 1: Add `fields` to schema if needed
20
- const fieldDesc = useFields
21
- ? generateFieldsDescription(tool.outputSchema, tool.importantFields ?? [], tool.name)
19
+ // Step 1: Add `fields` to schema if needed.
20
+ //
21
+ // Only on tools that return a list. That is where a response grows without
22
+ // bound and trimming it pays for the schema every client downloads; a tool
23
+ // returning one record saves a few hundred bytes at best.
24
+ const fields = useFields && tool.returnsList
25
+ ? fieldSelectionSchema(tool.outputFields, tool.importantFields ?? [])
22
26
  : undefined;
23
- const schema = extendSchema(tool.schema, fieldDesc, useOrganization);
27
+ const schema = extendSchema(tool.schema, fields, useOrganization);
24
28
  // Step 2: Compose
25
29
  const baseHandler = wrapWithErrorHandling(wrapWithOrganizationContext(tool.handler), errorHandler);
26
- const composed = useFields ? wrapWithFieldPicking(baseHandler) : baseHandler;
30
+ const composed = useFields && fields ? wrapWithFieldPicking(baseHandler) : baseHandler;
27
31
  return {
28
32
  schema,
29
33
  handler: wrapWithToolResult(wrapWithTokenLimit(composed, maxTokens)),
30
34
  };
31
35
  }
32
- function extendSchema(schema, desc, withOrganization = false) {
36
+ function extendSchema(schema, fields, withOrganization = false) {
33
37
  const extension = {};
34
38
  if (withOrganization) {
35
39
  extension.organization = z
@@ -37,8 +41,10 @@ function extendSchema(schema, desc, withOrganization = false) {
37
41
  .optional()
38
42
  .describe('Optional organization name. Use list_organizations to inspect available organizations.');
39
43
  }
40
- if (desc) {
41
- extension.fields = z.string().describe(desc);
44
+ // Optional, as `wrapWithFieldPicking` assumes: it returns the whole result
45
+ // when `fields` is absent.
46
+ if (fields) {
47
+ extension.fields = fields;
42
48
  }
43
49
  // zod v4 reworked the ZodObject shape generics, so `extend()`'s result no
44
50
  // longer overlaps the declared return type enough for a direct cast. The
@@ -1,4 +1,17 @@
1
1
  import { SafeResult } from '../../types/result.js';
2
+ /**
3
+ * Narrows a successful result to the fields the caller asked for.
4
+ *
5
+ * `fields` is a list of top-level names, validated against the tool's own enum
6
+ * before it reaches here, so an unknown name is rejected by the protocol rather
7
+ * than dropped in silence. A missing or empty list returns everything.
8
+ *
9
+ * One level deep on purpose. This used to accept a GraphQL selection set and
10
+ * parse it with the `graphql` package: ~1 MB for a single `parse` call, a grammar
11
+ * that accepted aliases, fragments, arguments and directives and then honoured
12
+ * none of them, and a descent into an array field that returned `{}` and lost the
13
+ * data outright.
14
+ */
2
15
  export declare function wrapWithFieldPicking<I extends {
3
- fields?: string;
16
+ fields?: string[];
4
17
  }, O>(fn: (input: I) => Promise<SafeResult<O>>): (input: I) => Promise<SafeResult<O>>;
@@ -1,55 +1,49 @@
1
- import { parse } from 'graphql';
2
1
  import { isErrorLike } from '../../types/result.js';
2
+ /**
3
+ * Narrows a successful result to the fields the caller asked for.
4
+ *
5
+ * `fields` is a list of top-level names, validated against the tool's own enum
6
+ * before it reaches here, so an unknown name is rejected by the protocol rather
7
+ * than dropped in silence. A missing or empty list returns everything.
8
+ *
9
+ * One level deep on purpose. This used to accept a GraphQL selection set and
10
+ * parse it with the `graphql` package: ~1 MB for a single `parse` call, a grammar
11
+ * that accepted aliases, fragments, arguments and directives and then honoured
12
+ * none of them, and a descent into an array field that returned `{}` and lost the
13
+ * data outright.
14
+ */
3
15
  export function wrapWithFieldPicking(fn) {
4
16
  return async (input) => {
5
17
  const { fields, ...rest } = input;
6
18
  const result = await fn(rest);
7
- if (!fields || isErrorLike(result)) {
19
+ if (!fields || fields.length === 0 || isErrorLike(result)) {
8
20
  return result;
9
21
  }
10
- const selectionSet = parseFieldsSelection(fields);
11
- const resultData = result.data;
12
- if (Array.isArray(resultData)) {
22
+ const data = result.data;
23
+ if (Array.isArray(data)) {
13
24
  return {
14
25
  kind: 'ok',
15
- data: resultData.map((item) => pickFieldsFromData(item, selectionSet)),
26
+ data: data.map((item) => pick(item, fields)),
16
27
  };
17
28
  }
18
- else if (typeof result === 'object' && result !== null) {
29
+ if (typeof data === 'object' && data !== null) {
19
30
  return {
20
31
  kind: 'ok',
21
- data: pickFieldsFromData(resultData, selectionSet),
32
+ data: pick(data, fields),
22
33
  };
23
34
  }
24
- else {
25
- return result;
26
- }
35
+ // A scalar has no fields to narrow.
36
+ return result;
27
37
  };
28
38
  }
29
- function parseFieldsSelection(fieldsString) {
30
- const query = `query Dummy ${fieldsString}`;
31
- const ast = parse(query);
32
- const opDef = ast.definitions[0];
33
- if (opDef.kind !== 'OperationDefinition' || !opDef.selectionSet) {
34
- throw new Error('Invalid GraphQL fields');
35
- }
36
- return opDef.selectionSet;
37
- }
38
- function pickFieldsFromData(data, selectionSet) {
39
- const result = {};
40
- for (const selection of selectionSet.selections) {
41
- if (selection.kind === 'Field') {
42
- const key = selection.name.value;
43
- if (data != null && key in data) {
44
- const value = data[key];
45
- if (selection.selectionSet && value != null) {
46
- result[key] = pickFieldsFromData(data[key], selection.selectionSet);
47
- }
48
- else {
49
- result[key] = data[key];
50
- }
51
- }
52
- }
39
+ function pick(value, fields) {
40
+ if (typeof value !== 'object' || value === null)
41
+ return value;
42
+ const source = value;
43
+ const picked = {};
44
+ for (const field of fields) {
45
+ if (field in source)
46
+ picked[field] = source[field];
53
47
  }
54
- return result;
48
+ return picked;
55
49
  }
@@ -16,6 +16,14 @@ export const runHttpMcpServer = async (options) => {
16
16
  const app = new Hono();
17
17
  const isLocalhostBind = LOCALHOST_BINDS.includes(host);
18
18
  const oauthEnabled = !!(oauthConfig && tokenStore);
19
+ // Registered before the Host and Origin guards so that it stays reachable
20
+ // under an allow list. A load balancer health check addresses the target
21
+ // directly and puts its address in `Host` — AWS ALB gives no way to override
22
+ // it — so a guarded `/health` can only ever answer 403, leaving an operator
23
+ // to choose between dropping the allow list and treating 403 as healthy.
24
+ // Exempting it costs nothing: the response is constant and reaches no state,
25
+ // so there is nothing here for a rebinding page to reach either.
26
+ app.get('/health', (c) => c.json({ status: 'healthy', timestamp: new Date().toISOString(), version }));
19
27
  // DNS rebinding protection. `Host` is the actual defense: a rebinding page
20
28
  // reaches us carrying its own hostname, which the allow list rejects.
21
29
  if (allowedHosts?.length) {
@@ -36,7 +44,6 @@ export const runHttpMcpServer = async (options) => {
36
44
  else if (isLocalhostBind && !allowedHosts?.length) {
37
45
  app.use('*', localhostOriginValidation());
38
46
  }
39
- app.get('/health', (c) => c.json({ status: 'healthy', timestamp: new Date().toISOString(), version }));
40
47
  if (oauthEnabled) {
41
48
  const { createOAuthRoutes } = await import('./auth/oauthRoutes.js');
42
49
  const { createBearerAuthMiddleware } = await import('./auth/bearerAuthMiddleware.js');