backlog-mcp-server 0.13.5 → 0.15.0
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.
- package/README.ja.md +20 -14
- package/README.md +25 -14
- package/build/auth/backlogOAuthClient.js +3 -3
- package/build/auth/oauthRoutes.js +12 -5
- package/build/createBacklogMcpServer.js +12 -5
- package/build/handlers/builders/composeToolHandler.js +3 -0
- package/build/httpMcpServer.js +46 -100
- package/build/index.js +25 -8
- package/build/registerTools.js +1 -1
- package/build/tools/addRelatedIssue.js +32 -0
- package/build/tools/getRelatedIssues.js +30 -0
- package/build/tools/removeRelatedIssue.js +32 -0
- package/build/tools/tools.js +6 -0
- package/build/tools/updateIssue.js +31 -4
- package/build/types/zod/backlogOutputDefinition.js +7 -0
- package/build/utils/generateFieldsDescription.js +3 -0
- package/build/utils/wrapServerWithToolRegistry.js +1 -1
- package/package.json +5 -4
package/README.ja.md
CHANGED
|
@@ -154,11 +154,11 @@ MCPサーバーをネットワーク経由で公開する場合、OAuth 2.0認
|
|
|
154
154
|
|
|
155
155
|
2. 以下の環境変数を設定します(`BACKLOG_DOMAIN` に加えて):
|
|
156
156
|
|
|
157
|
-
| 変数
|
|
158
|
-
|
|
|
159
|
-
| `BACKLOG_OAUTH_CLIENT_ID`
|
|
160
|
-
| `BACKLOG_OAUTH_CLIENT_SECRET` | Backlogアプリケーションの OAuth Client Secret
|
|
161
|
-
| `MCP_SERVER_BASE_URL`
|
|
157
|
+
| 変数 | 説明 |
|
|
158
|
+
| ----------------------------- | ----------------------------------------------------- |
|
|
159
|
+
| `BACKLOG_OAUTH_CLIENT_ID` | Backlogアプリケーションの OAuth Client ID |
|
|
160
|
+
| `BACKLOG_OAUTH_CLIENT_SECRET` | Backlogアプリケーションの OAuth Client Secret |
|
|
161
|
+
| `MCP_SERVER_BASE_URL` | MCPサーバーの公開URL(例:`https://mcp.example.com`) |
|
|
162
162
|
|
|
163
163
|
> **注意:** OAuth有効時は `BACKLOG_API_KEY` は**不要**です。各ユーザーが自分のBacklogアカウントで認証します。
|
|
164
164
|
|
|
@@ -169,23 +169,27 @@ BACKLOG_DOMAIN=your-space.backlog.com \
|
|
|
169
169
|
BACKLOG_OAUTH_CLIENT_ID=your-client-id \
|
|
170
170
|
BACKLOG_OAUTH_CLIENT_SECRET=your-client-secret \
|
|
171
171
|
MCP_SERVER_BASE_URL=https://mcp.example.com \
|
|
172
|
-
node build/index.js --transport http --http-host 0.0.0.0 --http-port 3333
|
|
172
|
+
node build/index.js --transport http --http-host 0.0.0.0 --http-port 3333 \
|
|
173
|
+
--http-allowed-hosts mcp.example.com
|
|
173
174
|
```
|
|
174
175
|
|
|
176
|
+
`0.0.0.0` にバインドする場合、`--http-allowed-hosts` は実質必須です。指定しないとDNSリバインディング保護が一切効かず、起動時に警告がログ出力されます。
|
|
177
|
+
|
|
175
178
|
OAuth有効時、サーバーは以下のOAuthエンドポイントを自動的に公開します:
|
|
176
179
|
|
|
177
|
-
| エンドポイント
|
|
178
|
-
|
|
|
179
|
-
| `GET /.well-known/oauth-authorization-server`
|
|
180
|
-
| `GET /.well-known/oauth-protected-resource/mcp` | OAuthリソースメタデータ([RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728))
|
|
181
|
-
| `POST /register`
|
|
182
|
-
| `GET /authorize`
|
|
183
|
-
| `GET /callback`
|
|
184
|
-
| `POST /token`
|
|
180
|
+
| エンドポイント | 説明 |
|
|
181
|
+
| ----------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
182
|
+
| `GET /.well-known/oauth-authorization-server` | OAuth認可サーバーメタデータ([RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414)) |
|
|
183
|
+
| `GET /.well-known/oauth-protected-resource/mcp` | OAuthリソースメタデータ([RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)) |
|
|
184
|
+
| `POST /register` | 動的クライアント登録([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)) |
|
|
185
|
+
| `GET /authorize` | 認可エンドポイント(Backlog OAuthへリダイレクト) |
|
|
186
|
+
| `GET /callback` | Backlog OAuthコールバック |
|
|
187
|
+
| `POST /token` | トークンエンドポイント(認可コード&リフレッシュトークン) |
|
|
185
188
|
|
|
186
189
|
MCP認可仕様に対応するMCPクライアントは、これらのエンドポイントを自動的に使用します。
|
|
187
190
|
|
|
188
191
|
> **制約事項:**
|
|
192
|
+
>
|
|
189
193
|
> - OAuthモードは現在、単一のBacklog組織のみをサポートしています。複数組織設定との併用はできません。
|
|
190
194
|
> - クライアント登録やトークンはメモリ内に保持されるため、サーバー再起動時に失われます。
|
|
191
195
|
|
|
@@ -247,6 +251,8 @@ CLI経由での有効化:
|
|
|
247
251
|
|
|
248
252
|
動的ツールセットを有効にすると、LLMはツールインターフェースを介してオンデマンドでツールセットを一覧表示およびアクティブ化できるようになります。
|
|
249
253
|
|
|
254
|
+
> **HTTPでのスコープ:** MCP `2026-07-28` にはプロトコルセッションがないため、アクティブ化したツールセットはクライアント単位ではなく**サーバープロセス単位**で保持されます。HTTPトランスポートでは接続中の全クライアントが1つのツールセット状態を共有し、プロセス再起動でリセットされます。共有されるのはツールの*可視性*のみで、認可は共有されません(各呼び出しは呼び出し元自身の認証情報で認証されます)。
|
|
255
|
+
|
|
250
256
|
## 利用可能なツール
|
|
251
257
|
|
|
252
258
|
以下のような Backlog 機能に対応するツールを提供しています:
|
package/README.md
CHANGED
|
@@ -149,20 +149,22 @@ pnpm run build
|
|
|
149
149
|
MCP_TRANSPORT=http MCP_HTTP_PORT=3333 node build/index.js
|
|
150
150
|
```
|
|
151
151
|
|
|
152
|
-
- **Endpoint:** `POST
|
|
153
|
-
- **
|
|
154
|
-
- **
|
|
152
|
+
- **Endpoint:** `POST` (and `GET` for server-initiated streams) on `http://<host>:<port><path>` (default path `/mcp`).
|
|
153
|
+
- **Protocol:** MCP `2026-07-28`. The protocol is stateless: there is no `initialize` handshake and no `mcp-session-id` header. Clients send their metadata in `_meta` on every request and discover capabilities via `server/discover`. Streamable HTTP also requires the `Mcp-Method` header (and `Mcp-Name` on `tools/call`).
|
|
154
|
+
- **Backward compatibility:** Clients on `2025-11-25` and earlier are still served over the same endpoint, statelessly. Because no session is kept, the 2025 session operations (`GET` / `DELETE` with an `mcp-session-id`) answer `405`.
|
|
155
|
+
- **Security:** Default bind is `127.0.0.1`. On a bare loopback bind, `Host` and `Origin` are both validated against the localhost set (DNS rebinding protection). Behind a reverse proxy, set `--http-allowed-hosts` to the public hostname; that turns off the localhost `Origin` default, since a browser client's `Origin` is its own site and never this server's hostname. Add `--http-allowed-origins` to restrict which client origins may reach the server. Do not expose the HTTP port to untrusted networks without authentication and TLS; it allows full use of your Backlog API key via MCP tools.
|
|
155
156
|
|
|
156
157
|
Environment variables (CLI flags override when both are set):
|
|
157
158
|
|
|
158
|
-
| Variable
|
|
159
|
-
|
|
|
160
|
-
| `MCP_TRANSPORT`
|
|
161
|
-
| `MCP_HTTP_HOST`
|
|
162
|
-
| `MCP_HTTP_PORT`
|
|
163
|
-
| `MCP_HTTP_PATH`
|
|
164
|
-
| `MCP_HTTP_JSON_RESPONSE`
|
|
165
|
-
| `MCP_HTTP_ALLOWED_HOSTS`
|
|
159
|
+
| Variable | Description |
|
|
160
|
+
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
161
|
+
| `MCP_TRANSPORT` | `stdio` (default) or `http` |
|
|
162
|
+
| `MCP_HTTP_HOST` | Bind address (default `127.0.0.1`) |
|
|
163
|
+
| `MCP_HTTP_PORT` | Port (default `3333`) |
|
|
164
|
+
| `MCP_HTTP_PATH` | URL path (default `/mcp`) |
|
|
165
|
+
| `MCP_HTTP_JSON_RESPONSE` | `true` to prefer JSON responses over SSE (applies to `2026-07-28` clients only) |
|
|
166
|
+
| `MCP_HTTP_ALLOWED_HOSTS` | Comma-separated allowed `Host` hostnames (port-agnostic). Required when binding to `0.0.0.0`; also the escape hatch for a loopback bind behind a proxy (DNS rebinding protection) |
|
|
167
|
+
| `MCP_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 |
|
|
166
168
|
|
|
167
169
|
### OAuth 2.0 Authentication (Remote MCP)
|
|
168
170
|
|
|
@@ -194,9 +196,12 @@ BACKLOG_DOMAIN=your-space.backlog.com \
|
|
|
194
196
|
BACKLOG_OAUTH_CLIENT_ID=your-client-id \
|
|
195
197
|
BACKLOG_OAUTH_CLIENT_SECRET=your-client-secret \
|
|
196
198
|
MCP_SERVER_BASE_URL=https://mcp.example.com \
|
|
197
|
-
node build/index.js --transport http --http-host 0.0.0.0 --http-port 3333
|
|
199
|
+
node build/index.js --transport http --http-host 0.0.0.0 --http-port 3333 \
|
|
200
|
+
--http-allowed-hosts mcp.example.com
|
|
198
201
|
```
|
|
199
202
|
|
|
203
|
+
`--http-allowed-hosts` is required in practice when binding to `0.0.0.0`: without it there is no DNS rebinding protection, and the server logs a warning at startup.
|
|
204
|
+
|
|
200
205
|
The server automatically exposes the following OAuth endpoints when OAuth is enabled:
|
|
201
206
|
|
|
202
207
|
| Endpoint | Description |
|
|
@@ -273,6 +278,8 @@ Or via environment variable::
|
|
|
273
278
|
|
|
274
279
|
With dynamic toolsets enabled, the LLM will be able to list and activate toolsets on demand via tool interface.
|
|
275
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
|
+
|
|
276
283
|
## Available Tools
|
|
277
284
|
|
|
278
285
|
### Toolset: `space`
|
|
@@ -307,6 +314,9 @@ Tools for managing issues, their comments, and related items like priorities, ca
|
|
|
307
314
|
- `get_issue_comments`: Returns list of comments for an issue.
|
|
308
315
|
- `add_issue_comment`: Adds a comment to an issue.
|
|
309
316
|
- `update_issue_comment`: Updates a comment on an issue.
|
|
317
|
+
- `get_related_issues`: Returns list of issues related to a specific issue.
|
|
318
|
+
- `add_related_issue`: Relates an issue to another issue.
|
|
319
|
+
- `remove_related_issue`: Removes the relation between an issue and a related issue.
|
|
310
320
|
- `get_priorities`: Returns list of priorities.
|
|
311
321
|
- `get_categories`: Returns list of categories for a project.
|
|
312
322
|
- `get_custom_fields`: Returns list of custom fields for a project.
|
|
@@ -641,8 +651,9 @@ The server supports several command line options:
|
|
|
641
651
|
|
|
642
652
|
- `--transport stdio|http`: MCP transport (default: stdio). Use `http` for Streamable HTTP.
|
|
643
653
|
- `--http-host`, `--http-port`, `--http-path`: HTTP bind address, port, and path (defaults: `127.0.0.1`, `3333`, `/mcp`).
|
|
644
|
-
- `--http-json-response`: Prefer JSON responses over SSE
|
|
645
|
-
- `--http-allowed-hosts`: Comma-separated allowed `Host`
|
|
654
|
+
- `--http-json-response`: Prefer JSON responses over SSE. Applies to `2026-07-28` clients only; the backward-compatible `2025-11-25` path is served with the SDK's default response shaping.
|
|
655
|
+
- `--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.
|
|
656
|
+
- `--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.
|
|
646
657
|
- `--export-translations`: Export all translation keys and values
|
|
647
658
|
- `--optimize-response`: Enable GraphQL-style field selection
|
|
648
659
|
- `--max-tokens=NUMBER`: Set maximum token limit for responses
|
|
@@ -26,7 +26,7 @@ export async function exchangeBacklogCode(config, code, redirectUri) {
|
|
|
26
26
|
const text = await response.text();
|
|
27
27
|
throw new Error(`Backlog token exchange failed (${response.status}): ${text}`);
|
|
28
28
|
}
|
|
29
|
-
return
|
|
29
|
+
return await response.json();
|
|
30
30
|
}
|
|
31
31
|
export async function refreshBacklogToken(config, refreshToken) {
|
|
32
32
|
const params = new URLSearchParams({
|
|
@@ -44,7 +44,7 @@ export async function refreshBacklogToken(config, refreshToken) {
|
|
|
44
44
|
const text = await response.text();
|
|
45
45
|
throw new Error(`Backlog token refresh failed (${response.status}): ${text}`);
|
|
46
46
|
}
|
|
47
|
-
return
|
|
47
|
+
return await response.json();
|
|
48
48
|
}
|
|
49
49
|
export async function verifyBacklogToken(domain, accessToken) {
|
|
50
50
|
const response = await fetch(`https://${domain}/api/v2/users/myself`, {
|
|
@@ -53,5 +53,5 @@ export async function verifyBacklogToken(domain, accessToken) {
|
|
|
53
53
|
if (!response.ok) {
|
|
54
54
|
throw new Error(`Backlog token verification failed (${response.status})`);
|
|
55
55
|
}
|
|
56
|
-
return
|
|
56
|
+
return await response.json();
|
|
57
57
|
}
|
|
@@ -20,10 +20,7 @@ function isValidRedirectUri(uri) {
|
|
|
20
20
|
const parsed = new URL(uri);
|
|
21
21
|
if (parsed.protocol === 'https:')
|
|
22
22
|
return true;
|
|
23
|
-
|
|
24
|
-
LOCALHOST_HOSTS.includes(parsed.hostname))
|
|
25
|
-
return true;
|
|
26
|
-
return false;
|
|
23
|
+
return (parsed.protocol === 'http:' && LOCALHOST_HOSTS.includes(parsed.hostname));
|
|
27
24
|
}
|
|
28
25
|
catch {
|
|
29
26
|
return false;
|
|
@@ -46,6 +43,8 @@ export function createOAuthRoutes(config, store, mcpPath) {
|
|
|
46
43
|
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
47
44
|
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
|
|
48
45
|
code_challenge_methods_supported: ['S256'],
|
|
46
|
+
// RFC 9207 — every authorization response carries `iss`
|
|
47
|
+
authorization_response_iss_parameter_supported: true,
|
|
49
48
|
});
|
|
50
49
|
});
|
|
51
50
|
// RFC 9728 — OAuth Protected Resource Metadata
|
|
@@ -61,7 +60,7 @@ export function createOAuthRoutes(config, store, mcpPath) {
|
|
|
61
60
|
app.post('/register', async (c) => {
|
|
62
61
|
let body;
|
|
63
62
|
try {
|
|
64
|
-
body =
|
|
63
|
+
body = await c.req.json();
|
|
65
64
|
}
|
|
66
65
|
catch {
|
|
67
66
|
return c.json(oauthError('invalid_request', 'Invalid JSON body'), 400);
|
|
@@ -136,6 +135,7 @@ export function createOAuthRoutes(config, store, mcpPath) {
|
|
|
136
135
|
const url = new URL(effectiveRedirectUri);
|
|
137
136
|
url.searchParams.set('error', 'unsupported_response_type');
|
|
138
137
|
url.searchParams.set('error_description', 'Only response_type=code is supported');
|
|
138
|
+
url.searchParams.set('iss', serverBaseUrl);
|
|
139
139
|
if (state)
|
|
140
140
|
url.searchParams.set('state', state);
|
|
141
141
|
return c.redirect(url.href, 302);
|
|
@@ -144,6 +144,7 @@ export function createOAuthRoutes(config, store, mcpPath) {
|
|
|
144
144
|
const url = new URL(effectiveRedirectUri);
|
|
145
145
|
url.searchParams.set('error', 'invalid_request');
|
|
146
146
|
url.searchParams.set('error_description', 'code_challenge is required');
|
|
147
|
+
url.searchParams.set('iss', serverBaseUrl);
|
|
147
148
|
if (state)
|
|
148
149
|
url.searchParams.set('state', state);
|
|
149
150
|
return c.redirect(url.href, 302);
|
|
@@ -152,6 +153,7 @@ export function createOAuthRoutes(config, store, mcpPath) {
|
|
|
152
153
|
const url = new URL(effectiveRedirectUri);
|
|
153
154
|
url.searchParams.set('error', 'invalid_request');
|
|
154
155
|
url.searchParams.set('error_description', 'Only code_challenge_method=S256 is supported');
|
|
156
|
+
url.searchParams.set('iss', serverBaseUrl);
|
|
155
157
|
if (state)
|
|
156
158
|
url.searchParams.set('state', state);
|
|
157
159
|
return c.redirect(url.href, 302);
|
|
@@ -160,6 +162,7 @@ export function createOAuthRoutes(config, store, mcpPath) {
|
|
|
160
162
|
const url = new URL(effectiveRedirectUri);
|
|
161
163
|
url.searchParams.set('error', 'invalid_target');
|
|
162
164
|
url.searchParams.set('error_description', 'Invalid resource parameter');
|
|
165
|
+
url.searchParams.set('iss', serverBaseUrl);
|
|
163
166
|
if (state)
|
|
164
167
|
url.searchParams.set('state', state);
|
|
165
168
|
return c.redirect(url.href, 302);
|
|
@@ -196,6 +199,7 @@ export function createOAuthRoutes(config, store, mcpPath) {
|
|
|
196
199
|
errorUrl.searchParams.set('error', backlogError ?? 'access_denied');
|
|
197
200
|
errorUrl.searchParams.set('error_description', url.searchParams.get('error_description') ??
|
|
198
201
|
'Authorization was denied by the user');
|
|
202
|
+
errorUrl.searchParams.set('iss', serverBaseUrl);
|
|
199
203
|
if (pending.state)
|
|
200
204
|
errorUrl.searchParams.set('state', pending.state);
|
|
201
205
|
return c.redirect(errorUrl.href, 302);
|
|
@@ -213,6 +217,7 @@ export function createOAuthRoutes(config, store, mcpPath) {
|
|
|
213
217
|
const url = new URL(pending.redirectUri);
|
|
214
218
|
url.searchParams.set('error', 'server_error');
|
|
215
219
|
url.searchParams.set('error_description', 'Failed to exchange authorization code with Backlog');
|
|
220
|
+
url.searchParams.set('iss', serverBaseUrl);
|
|
216
221
|
if (pending.state)
|
|
217
222
|
url.searchParams.set('state', pending.state);
|
|
218
223
|
return c.redirect(url.href, 302);
|
|
@@ -228,6 +233,8 @@ export function createOAuthRoutes(config, store, mcpPath) {
|
|
|
228
233
|
});
|
|
229
234
|
const redirectUrl = new URL(pending.redirectUri);
|
|
230
235
|
redirectUrl.searchParams.set('code', mcpCode);
|
|
236
|
+
// RFC 9207 — let the client confirm which AS answered
|
|
237
|
+
redirectUrl.searchParams.set('iss', serverBaseUrl);
|
|
231
238
|
if (pending.state)
|
|
232
239
|
redirectUrl.searchParams.set('state', pending.state);
|
|
233
240
|
return c.redirect(redirectUrl.href, 302);
|
|
@@ -1,23 +1,30 @@
|
|
|
1
1
|
// Copyright (c) 2025 Nulab inc.
|
|
2
2
|
// Licensed under the MIT License.
|
|
3
|
-
import { McpServer } from '@modelcontextprotocol/
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/server';
|
|
4
4
|
import { registerDynamicTools, registerTools } from './registerTools.js';
|
|
5
5
|
import { organizationTools } from './tools/dynamicTools/organizations.js';
|
|
6
6
|
import { dynamicTools } from './tools/dynamicTools/toolsets.js';
|
|
7
7
|
import { createToolRegistrar } from './utils/toolRegistrar.js';
|
|
8
8
|
import { buildToolsetGroup } from './utils/toolsetUtils.js';
|
|
9
9
|
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.
|
|
13
|
+
const TOOL_LIST_CACHE_HINT = {
|
|
14
|
+
'tools/list': { ttlMs: 5 * 60 * 1000, cacheScope: 'public' },
|
|
15
|
+
};
|
|
10
16
|
/**
|
|
11
17
|
* Builds a fresh MCP server instance with all Backlog tools registered.
|
|
12
|
-
* Used once
|
|
18
|
+
* Used once per stdio connection; one instance per HTTP request for Streamable HTTP.
|
|
13
19
|
*/
|
|
14
|
-
export function createBacklogMcpServer({ version, useFields, backlog, clientRegistry, transHelper, enabledToolsets, mcpOption, dynamicToolsets, }) {
|
|
20
|
+
export function createBacklogMcpServer({ version, useFields, backlog, clientRegistry, transHelper, enabledToolsets, mcpOption, dynamicToolsets, toolsetGroup: sharedToolsetGroup, }) {
|
|
15
21
|
const server = wrapServerWithToolRegistry(new McpServer({
|
|
16
22
|
name: 'backlog',
|
|
17
23
|
title: useFields ? 'backlog (field selection enabled)' : 'backlog',
|
|
18
24
|
version,
|
|
19
|
-
}));
|
|
20
|
-
const toolsetGroup =
|
|
25
|
+
}, dynamicToolsets ? undefined : { cacheHints: TOOL_LIST_CACHE_HINT }));
|
|
26
|
+
const toolsetGroup = sharedToolsetGroup ??
|
|
27
|
+
buildToolsetGroup(backlog, transHelper, enabledToolsets);
|
|
21
28
|
registerTools(server, toolsetGroup, mcpOption);
|
|
22
29
|
registerDynamicTools(server, organizationTools(clientRegistry, transHelper), mcpOption.prefix);
|
|
23
30
|
if (dynamicToolsets) {
|
|
@@ -28,5 +28,8 @@ function extendSchema(schema, desc) {
|
|
|
28
28
|
if (desc) {
|
|
29
29
|
extension.fields = z.string().describe(desc);
|
|
30
30
|
}
|
|
31
|
+
// zod v4 reworked the ZodObject shape generics, so `extend()`'s result no
|
|
32
|
+
// longer overlaps the declared return type enough for a direct cast. The
|
|
33
|
+
// shape is correct at runtime; route through `unknown` to keep the assertion.
|
|
31
34
|
return schema.extend(extension);
|
|
32
35
|
}
|
package/build/httpMcpServer.js
CHANGED
|
@@ -1,78 +1,40 @@
|
|
|
1
1
|
// Copyright (c) 2025 Nulab inc.
|
|
2
2
|
// Licensed under the MIT License.
|
|
3
|
-
import { randomUUID } from 'node:crypto';
|
|
4
3
|
import { serve } from '@hono/node-server';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { hostHeaderValidation, localhostHostValidation, localhostOriginValidation, originValidation, } from '@modelcontextprotocol/hono';
|
|
5
|
+
import { createMcpHandler } from '@modelcontextprotocol/server';
|
|
7
6
|
import { Hono } from 'hono';
|
|
8
7
|
import { runWithAccessToken } from './auth/backlogAuthContext.js';
|
|
9
8
|
import { logger } from './utils/logger.js';
|
|
10
|
-
const
|
|
11
|
-
return { jsonrpc: '2.0', error: { code, message }, id: null };
|
|
12
|
-
};
|
|
13
|
-
const bodyContainsInitialize = (body) => {
|
|
14
|
-
return (Array.isArray(body) ? body : [body]).some(isInitializeRequest);
|
|
15
|
-
};
|
|
16
|
-
const buildAllowedHostnames = (host, allowedHosts) => {
|
|
17
|
-
if (allowedHosts?.length)
|
|
18
|
-
return allowedHosts;
|
|
19
|
-
const localhostHosts = ['127.0.0.1', 'localhost', '::1'];
|
|
20
|
-
return localhostHosts.includes(host)
|
|
21
|
-
? ['localhost', '127.0.0.1', '[::1]']
|
|
22
|
-
: undefined;
|
|
23
|
-
};
|
|
24
|
-
const parseHostname = (hostHeader) => {
|
|
25
|
-
try {
|
|
26
|
-
return new URL(`http://${hostHeader}`).hostname;
|
|
27
|
-
}
|
|
28
|
-
catch {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
};
|
|
32
|
-
const checkHostHeader = (hostHeader, allowedHostnames) => {
|
|
33
|
-
if (!hostHeader)
|
|
34
|
-
return jsonRpcError(-32000, 'Missing Host header');
|
|
35
|
-
const hostname = parseHostname(hostHeader);
|
|
36
|
-
if (hostname === null) {
|
|
37
|
-
return jsonRpcError(-32000, `Invalid Host header: ${hostHeader}`);
|
|
38
|
-
}
|
|
39
|
-
return allowedHostnames.includes(hostname)
|
|
40
|
-
? null
|
|
41
|
-
: jsonRpcError(-32000, `Invalid Host: ${hostname}`);
|
|
42
|
-
};
|
|
43
|
-
const startNewSession = async (req, body, enableJsonResponse, transports, createServer, authInfo) => {
|
|
44
|
-
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
45
|
-
sessionIdGenerator: () => randomUUID(),
|
|
46
|
-
enableJsonResponse,
|
|
47
|
-
onsessioninitialized: (sid) => {
|
|
48
|
-
transports[sid] = transport;
|
|
49
|
-
},
|
|
50
|
-
});
|
|
51
|
-
transport.onclose = () => {
|
|
52
|
-
const sid = transport.sessionId;
|
|
53
|
-
if (sid)
|
|
54
|
-
delete transports[sid];
|
|
55
|
-
};
|
|
56
|
-
await createServer().connect(transport);
|
|
57
|
-
return transport.handleRequest(req, { parsedBody: body, authInfo });
|
|
58
|
-
};
|
|
9
|
+
const LOCALHOST_BINDS = ['127.0.0.1', 'localhost', '::1'];
|
|
59
10
|
export const runHttpMcpServer = async (options) => {
|
|
60
|
-
const { host, port, path: mcpPath, version, enableJsonResponse, allowedHosts, createServer, oauthConfig, tokenStore, } = options;
|
|
11
|
+
const { host, port, path: mcpPath, version, enableJsonResponse, allowedHosts, allowedOrigins, createServer, oauthConfig, tokenStore, } = options;
|
|
61
12
|
if ((host === '0.0.0.0' || host === '::') && !allowedHosts?.length) {
|
|
62
13
|
logger.warn('Binding to all interfaces without --http-allowed-hosts. ' +
|
|
63
14
|
'Set allowed Host values to prevent DNS rebinding attacks.');
|
|
64
15
|
}
|
|
65
16
|
const app = new Hono();
|
|
66
|
-
const
|
|
67
|
-
const allowedHostnames = buildAllowedHostnames(host, allowedHosts);
|
|
17
|
+
const isLocalhostBind = LOCALHOST_BINDS.includes(host);
|
|
68
18
|
const oauthEnabled = !!(oauthConfig && tokenStore);
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
19
|
+
// DNS rebinding protection. `Host` is the actual defense: a rebinding page
|
|
20
|
+
// reaches us carrying its own hostname, which the allow list rejects.
|
|
21
|
+
if (allowedHosts?.length) {
|
|
22
|
+
app.use('*', hostHeaderValidation(allowedHosts));
|
|
23
|
+
}
|
|
24
|
+
else if (isLocalhostBind) {
|
|
25
|
+
app.use('*', localhostHostValidation());
|
|
26
|
+
}
|
|
27
|
+
// `Origin` is a separate axis and cannot be derived from the allow list: a
|
|
28
|
+
// browser client's Origin is its own site, never this server's hostname, so
|
|
29
|
+
// validating one against the other would reject every legitimate remote
|
|
30
|
+
// client. Default to the localhost set only for a bare loopback bind (the
|
|
31
|
+
// desktop case, where a drive-by page is the threat); a deployment that
|
|
32
|
+
// declares its hosts opts out unless it names its client origins too.
|
|
33
|
+
if (allowedOrigins?.length) {
|
|
34
|
+
app.use('*', originValidation(allowedOrigins));
|
|
35
|
+
}
|
|
36
|
+
else if (isLocalhostBind && !allowedHosts?.length) {
|
|
37
|
+
app.use('*', localhostOriginValidation());
|
|
76
38
|
}
|
|
77
39
|
app.get('/health', (c) => c.json({ status: 'healthy', timestamp: new Date().toISOString(), version }));
|
|
78
40
|
if (oauthEnabled) {
|
|
@@ -81,43 +43,30 @@ export const runHttpMcpServer = async (options) => {
|
|
|
81
43
|
app.route('/', createOAuthRoutes(oauthConfig, tokenStore, mcpPath));
|
|
82
44
|
app.use(mcpPath, createBearerAuthMiddleware(tokenStore, oauthConfig, mcpPath));
|
|
83
45
|
}
|
|
46
|
+
// MCP 2026-07-28 removed protocol sessions: every request is self-contained,
|
|
47
|
+
// so the handler builds a fresh server per request instead of keeping a
|
|
48
|
+
// transport map keyed by mcp-session-id. `legacy` defaults to 'stateless',
|
|
49
|
+
// which keeps 2025-era clients working over the same endpoint.
|
|
50
|
+
const mcpHandler = createMcpHandler(() => createServer(), {
|
|
51
|
+
responseMode: enableJsonResponse ? 'json' : 'auto',
|
|
52
|
+
onerror: (err) => logger.error({ err }, 'MCP handler error'),
|
|
53
|
+
});
|
|
84
54
|
app.all(mcpPath, async (c) => {
|
|
85
|
-
const
|
|
86
|
-
const authInfo = oauthEnabled
|
|
87
|
-
? c.get('authInfo')
|
|
88
|
-
: undefined;
|
|
55
|
+
const authInfo = oauthEnabled ? c.get('authInfo') : undefined;
|
|
89
56
|
const accessToken = authInfo?.token;
|
|
90
|
-
const sessionId = req.headers.get('mcp-session-id');
|
|
91
57
|
try {
|
|
92
|
-
|
|
93
|
-
const handleExisting = () => transports[sessionId].handleRequest(req, { authInfo });
|
|
94
|
-
return accessToken
|
|
95
|
-
? runWithAccessToken(accessToken, handleExisting)
|
|
96
|
-
: handleExisting();
|
|
97
|
-
}
|
|
98
|
-
if (sessionId) {
|
|
99
|
-
return c.json(jsonRpcError(-32000, 'Bad Request: Unknown or expired session ID. Send a new initialize request without mcp-session-id.'), 400);
|
|
100
|
-
}
|
|
101
|
-
if (req.method !== 'POST') {
|
|
102
|
-
return c.json(jsonRpcError(-32000, 'Bad Request: No mcp-session-id header.'), 400);
|
|
103
|
-
}
|
|
104
|
-
const parsed = await req.json().then((body) => ({ body }), () => null);
|
|
105
|
-
if (!parsed) {
|
|
106
|
-
return c.json(jsonRpcError(-32700, 'Parse error: Invalid JSON'), 400);
|
|
107
|
-
}
|
|
108
|
-
const { body } = parsed;
|
|
109
|
-
if (!bodyContainsInitialize(body)) {
|
|
110
|
-
const err = jsonRpcError(-32000, 'Bad Request: No mcp-session-id header and body is not an initialize request.');
|
|
111
|
-
return c.json(Array.isArray(body) ? [err] : err, 400);
|
|
112
|
-
}
|
|
113
|
-
const handleNew = () => startNewSession(req, body, enableJsonResponse, transports, createServer, authInfo);
|
|
58
|
+
const dispatch = () => mcpHandler.fetch(c.req.raw, { authInfo });
|
|
114
59
|
return accessToken
|
|
115
|
-
? runWithAccessToken(accessToken,
|
|
116
|
-
:
|
|
60
|
+
? await runWithAccessToken(accessToken, dispatch)
|
|
61
|
+
: await dispatch();
|
|
117
62
|
}
|
|
118
63
|
catch (error) {
|
|
119
64
|
logger.error({ err: error }, 'Error handling MCP request');
|
|
120
|
-
return c.json(
|
|
65
|
+
return c.json({
|
|
66
|
+
jsonrpc: '2.0',
|
|
67
|
+
error: { code: -32603, message: 'Internal server error' },
|
|
68
|
+
id: null,
|
|
69
|
+
}, 500);
|
|
121
70
|
}
|
|
122
71
|
});
|
|
123
72
|
const httpServer = await new Promise((resolve, reject) => {
|
|
@@ -125,14 +74,11 @@ export const runHttpMcpServer = async (options) => {
|
|
|
125
74
|
srv.on('error', reject);
|
|
126
75
|
});
|
|
127
76
|
const shutdown = async () => {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
/* ignore */
|
|
134
|
-
}
|
|
135
|
-
delete transports[sid];
|
|
77
|
+
try {
|
|
78
|
+
await mcpHandler.close();
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* ignore */
|
|
136
82
|
}
|
|
137
83
|
httpServer.closeAllConnections();
|
|
138
84
|
await new Promise((resolve) => httpServer.close(() => resolve()));
|
package/build/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Copyright (c) 2025 Nulab inc.
|
|
3
3
|
// Licensed under the MIT License.
|
|
4
|
-
import {
|
|
4
|
+
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
|
5
5
|
import { default as env } from 'env-var';
|
|
6
6
|
import yargs from 'yargs';
|
|
7
7
|
import { hideBin } from 'yargs/helpers';
|
|
@@ -12,6 +12,7 @@ import { createBacklogMcpServer } from './createBacklogMcpServer.js';
|
|
|
12
12
|
import { runHttpMcpServer } from './httpMcpServer.js';
|
|
13
13
|
import { createBacklogClientRegistry, createOAuthBacklogClientRegistry, } from './utils/backlogClientRegistry.js';
|
|
14
14
|
import { logger } from './utils/logger.js';
|
|
15
|
+
import { buildToolsetGroup } from './utils/toolsetUtils.js';
|
|
15
16
|
import packageJson from '../package.json' with { type: 'json' };
|
|
16
17
|
const { version } = packageJson;
|
|
17
18
|
// Swallow SIGPIPE and stdout/stderr EPIPE so the process doesn't crash when a
|
|
@@ -75,6 +76,11 @@ const argv = yargs(hideBin(process.argv))
|
|
|
75
76
|
type: 'string',
|
|
76
77
|
describe: 'Comma-separated allowed Host header values when binding to all interfaces (recommended with 0.0.0.0)',
|
|
77
78
|
default: env.get('MCP_HTTP_ALLOWED_HOSTS').default('').asString(),
|
|
79
|
+
})
|
|
80
|
+
.option('http-allowed-origins', {
|
|
81
|
+
type: 'string',
|
|
82
|
+
describe: 'Comma-separated allowed Origin header hostnames for browser-based clients. Defaults to the localhost set on a loopback bind, and to no Origin check otherwise',
|
|
83
|
+
default: env.get('MCP_HTTP_ALLOWED_ORIGINS').default('').asString(),
|
|
78
84
|
})
|
|
79
85
|
.option('max-tokens', {
|
|
80
86
|
type: 'number',
|
|
@@ -132,8 +138,14 @@ const enabledToolsets = argv.dynamicToolsets
|
|
|
132
138
|
? argv.enableToolsets.filter((a) => a !== 'all')
|
|
133
139
|
: argv.enableToolsets;
|
|
134
140
|
const mcpOption = { useFields: useFields, maxTokens, prefix };
|
|
141
|
+
// Built once and shared by every server the factory produces. `enable_toolset`
|
|
142
|
+
// mutates this group, and the stateless HTTP model discards its server after
|
|
143
|
+
// each request — so a per-server group would lose the enablement immediately.
|
|
144
|
+
// Sharing it makes toolset state process-wide, which is the only scope left now
|
|
145
|
+
// that the protocol has no sessions.
|
|
146
|
+
const sharedToolsetGroup = buildToolsetGroup(backlog, transHelper, enabledToolsets);
|
|
135
147
|
// Factory: creates a fresh MCP server with all tools registered.
|
|
136
|
-
// Used once
|
|
148
|
+
// Used once per stdio connection; one fresh instance per HTTP request.
|
|
137
149
|
const createServer = () => createBacklogMcpServer({
|
|
138
150
|
version,
|
|
139
151
|
useFields,
|
|
@@ -143,6 +155,7 @@ const createServer = () => createBacklogMcpServer({
|
|
|
143
155
|
enabledToolsets,
|
|
144
156
|
mcpOption,
|
|
145
157
|
dynamicToolsets: argv.dynamicToolsets,
|
|
158
|
+
toolsetGroup: sharedToolsetGroup,
|
|
146
159
|
});
|
|
147
160
|
if (argv.exportTranslations) {
|
|
148
161
|
// Translation keys are only recorded once a tool asks for them, so build a
|
|
@@ -175,13 +188,14 @@ async function main() {
|
|
|
175
188
|
}
|
|
176
189
|
if (argv.transport === 'http') {
|
|
177
190
|
const httpPath = normalizeHttpPath(argv.httpPath);
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
? allowedHostsRaw
|
|
191
|
+
const parseHostList = (raw) => raw && raw.trim().length > 0
|
|
192
|
+
? raw
|
|
181
193
|
.split(',')
|
|
182
194
|
.map((h) => h.trim())
|
|
183
195
|
.filter(Boolean)
|
|
184
196
|
: undefined;
|
|
197
|
+
const allowedHosts = parseHostList(argv.httpAllowedHosts);
|
|
198
|
+
const allowedOrigins = parseHostList(argv.httpAllowedOrigins);
|
|
185
199
|
const { shutdown } = await runHttpMcpServer({
|
|
186
200
|
host: argv.httpHost,
|
|
187
201
|
port: argv.httpPort,
|
|
@@ -189,6 +203,7 @@ async function main() {
|
|
|
189
203
|
version,
|
|
190
204
|
enableJsonResponse: argv.httpJsonResponse,
|
|
191
205
|
allowedHosts,
|
|
206
|
+
allowedOrigins,
|
|
192
207
|
createServer,
|
|
193
208
|
oauthConfig,
|
|
194
209
|
tokenStore,
|
|
@@ -214,9 +229,11 @@ async function main() {
|
|
|
214
229
|
: 'Backlog MCP Server listening (Streamable HTTP)');
|
|
215
230
|
return;
|
|
216
231
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
232
|
+
// serveStdio owns the era decision for the connection: it pins one instance
|
|
233
|
+
// from the factory, serving both 2026-07-28 and 2025-era clients.
|
|
234
|
+
serveStdio(createServer, {
|
|
235
|
+
onerror: (err) => logger.error({ err }, 'MCP stdio error'),
|
|
236
|
+
});
|
|
220
237
|
logger.info('Backlog MCP Server running on stdio');
|
|
221
238
|
}
|
|
222
239
|
main().catch((error) => {
|
package/build/registerTools.js
CHANGED
|
@@ -31,7 +31,7 @@ function registerToolsets({ server, toolsetGroup, prefix, handlerStrategy, }) {
|
|
|
31
31
|
for (const tool of toolset.tools) {
|
|
32
32
|
const toolNameWithPrefix = `${prefix}${tool.name}`;
|
|
33
33
|
const handler = handlerStrategy(tool);
|
|
34
|
-
server.registerOnce(toolNameWithPrefix, tool.description, tool.schema
|
|
34
|
+
server.registerOnce(toolNameWithPrefix, tool.description, tool.schema, handler);
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { buildToolSchema } from '../types/tool.js';
|
|
3
|
+
import { RelatedIssueSchema } from '../types/zod/backlogOutputDefinition.js';
|
|
4
|
+
import { resolveIdOrKey } from '../utils/resolveIdOrKey.js';
|
|
5
|
+
const addRelatedIssueSchema = buildToolSchema((t) => ({
|
|
6
|
+
issueId: z
|
|
7
|
+
.number()
|
|
8
|
+
.optional()
|
|
9
|
+
.describe(t('TOOL_ADD_RELATED_ISSUE_ISSUE_ID', 'The numeric ID of the issue (e.g., 12345)')),
|
|
10
|
+
issueKey: z
|
|
11
|
+
.string()
|
|
12
|
+
.optional()
|
|
13
|
+
.describe(t('TOOL_ADD_RELATED_ISSUE_ISSUE_KEY', "The key of the issue (e.g., 'PROJ-123')")),
|
|
14
|
+
targetIssueId: z
|
|
15
|
+
.number()
|
|
16
|
+
.describe(t('TOOL_ADD_RELATED_ISSUE_TARGET_ISSUE_ID', 'The numeric ID of the issue to relate to (e.g., 12346)')),
|
|
17
|
+
}));
|
|
18
|
+
export const addRelatedIssueTool = (backlog, { t }) => {
|
|
19
|
+
return {
|
|
20
|
+
name: 'add_related_issue',
|
|
21
|
+
description: t('TOOL_ADD_RELATED_ISSUE_DESCRIPTION', 'Relates an issue to another issue'),
|
|
22
|
+
schema: z.object(addRelatedIssueSchema(t)),
|
|
23
|
+
outputSchema: RelatedIssueSchema,
|
|
24
|
+
handler: async ({ issueId, issueKey, targetIssueId }) => {
|
|
25
|
+
const result = resolveIdOrKey('issue', { id: issueId, key: issueKey }, t);
|
|
26
|
+
if (!result.ok) {
|
|
27
|
+
throw result.error;
|
|
28
|
+
}
|
|
29
|
+
return backlog.addRelatedIssue(result.value, { targetIssueId });
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { buildToolSchema } from '../types/tool.js';
|
|
3
|
+
import { RelatedIssueSchema } from '../types/zod/backlogOutputDefinition.js';
|
|
4
|
+
import { resolveIdOrKey } from '../utils/resolveIdOrKey.js';
|
|
5
|
+
const getRelatedIssuesSchema = buildToolSchema((t) => ({
|
|
6
|
+
issueId: z
|
|
7
|
+
.number()
|
|
8
|
+
.optional()
|
|
9
|
+
.describe(t('TOOL_GET_RELATED_ISSUES_ISSUE_ID', 'The numeric ID of the issue (e.g., 12345)')),
|
|
10
|
+
issueKey: z
|
|
11
|
+
.string()
|
|
12
|
+
.optional()
|
|
13
|
+
.describe(t('TOOL_GET_RELATED_ISSUES_ISSUE_KEY', "The key of the issue (e.g., 'PROJ-123')")),
|
|
14
|
+
}));
|
|
15
|
+
export const getRelatedIssuesTool = (backlog, { t }) => {
|
|
16
|
+
return {
|
|
17
|
+
name: 'get_related_issues',
|
|
18
|
+
description: t('TOOL_GET_RELATED_ISSUES_DESCRIPTION', 'Returns list of issues related to a specific issue'),
|
|
19
|
+
schema: z.object(getRelatedIssuesSchema(t)),
|
|
20
|
+
importantFields: ['issueKey', 'summary', 'status', 'type'],
|
|
21
|
+
outputSchema: RelatedIssueSchema,
|
|
22
|
+
handler: async ({ issueId, issueKey }) => {
|
|
23
|
+
const result = resolveIdOrKey('issue', { id: issueId, key: issueKey }, t);
|
|
24
|
+
if (!result.ok) {
|
|
25
|
+
throw result.error;
|
|
26
|
+
}
|
|
27
|
+
return backlog.getRelatedIssues(result.value);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { buildToolSchema } from '../types/tool.js';
|
|
3
|
+
import { RelatedIssueSchema } from '../types/zod/backlogOutputDefinition.js';
|
|
4
|
+
import { resolveIdOrKey } from '../utils/resolveIdOrKey.js';
|
|
5
|
+
const removeRelatedIssueSchema = buildToolSchema((t) => ({
|
|
6
|
+
issueId: z
|
|
7
|
+
.number()
|
|
8
|
+
.optional()
|
|
9
|
+
.describe(t('TOOL_REMOVE_RELATED_ISSUE_ISSUE_ID', 'The numeric ID of the issue (e.g., 12345)')),
|
|
10
|
+
issueKey: z
|
|
11
|
+
.string()
|
|
12
|
+
.optional()
|
|
13
|
+
.describe(t('TOOL_REMOVE_RELATED_ISSUE_ISSUE_KEY', "The key of the issue (e.g., 'PROJ-123')")),
|
|
14
|
+
relatedIssueId: z
|
|
15
|
+
.number()
|
|
16
|
+
.describe(t('TOOL_REMOVE_RELATED_ISSUE_RELATED_ISSUE_ID', 'The numeric ID of the related issue to unlink (e.g., 12346)')),
|
|
17
|
+
}));
|
|
18
|
+
export const removeRelatedIssueTool = (backlog, { t }) => {
|
|
19
|
+
return {
|
|
20
|
+
name: 'remove_related_issue',
|
|
21
|
+
description: t('TOOL_REMOVE_RELATED_ISSUE_DESCRIPTION', 'Removes the relation between an issue and a related issue'),
|
|
22
|
+
schema: z.object(removeRelatedIssueSchema(t)),
|
|
23
|
+
outputSchema: RelatedIssueSchema,
|
|
24
|
+
handler: async ({ issueId, issueKey, relatedIssueId }) => {
|
|
25
|
+
const result = resolveIdOrKey('issue', { id: issueId, key: issueKey }, t);
|
|
26
|
+
if (!result.ok) {
|
|
27
|
+
throw result.error;
|
|
28
|
+
}
|
|
29
|
+
return backlog.removeRelatedIssue(result.value, relatedIssueId);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
};
|
package/build/tools/tools.js
CHANGED
|
@@ -15,6 +15,9 @@ import { getGitRepositoryTool } from './getGitRepository.js';
|
|
|
15
15
|
import { getIssueTool } from './getIssue.js';
|
|
16
16
|
import { getIssueCommentsTool } from './getIssueComments.js';
|
|
17
17
|
import { getIssuesTool } from './getIssues.js';
|
|
18
|
+
import { getRelatedIssuesTool } from './getRelatedIssues.js';
|
|
19
|
+
import { addRelatedIssueTool } from './addRelatedIssue.js';
|
|
20
|
+
import { removeRelatedIssueTool } from './removeRelatedIssue.js';
|
|
18
21
|
import { getIssueTypesTool } from './getIssueTypes.js';
|
|
19
22
|
import { getMyselfTool } from './getMyself.js';
|
|
20
23
|
import { getNotificationsTool } from './getNotifications.js';
|
|
@@ -100,6 +103,9 @@ export const allTools = (backlog, helper) => {
|
|
|
100
103
|
getIssueCommentsTool(backlog, helper),
|
|
101
104
|
addIssueCommentTool(backlog, helper),
|
|
102
105
|
updateIssueCommentTool(backlog, helper),
|
|
106
|
+
getRelatedIssuesTool(backlog, helper),
|
|
107
|
+
addRelatedIssueTool(backlog, helper),
|
|
108
|
+
removeRelatedIssueTool(backlog, helper),
|
|
103
109
|
getPrioritiesTool(backlog, helper),
|
|
104
110
|
getCategoriesTool(backlog, helper),
|
|
105
111
|
getCustomFieldsTool(backlog, helper),
|
|
@@ -47,15 +47,15 @@ const updateIssueSchema = buildToolSchema((t) => ({
|
|
|
47
47
|
categoryId: z
|
|
48
48
|
.array(z.number())
|
|
49
49
|
.optional()
|
|
50
|
-
.describe(t('TOOL_UPDATE_ISSUE_CATEGORY_ID', 'Category IDs')),
|
|
50
|
+
.describe(t('TOOL_UPDATE_ISSUE_CATEGORY_ID', 'Category IDs. Pass an empty array to clear all categories. Omit this field to leave the current categories unchanged.')),
|
|
51
51
|
versionId: z
|
|
52
52
|
.array(z.number())
|
|
53
53
|
.optional()
|
|
54
|
-
.describe(t('TOOL_UPDATE_ISSUE_VERSION_ID', 'Version IDs')),
|
|
54
|
+
.describe(t('TOOL_UPDATE_ISSUE_VERSION_ID', 'Version IDs. Pass an empty array to clear all versions. Omit this field to leave the current versions unchanged.')),
|
|
55
55
|
milestoneId: z
|
|
56
56
|
.array(z.number())
|
|
57
57
|
.optional()
|
|
58
|
-
.describe(t('TOOL_UPDATE_ISSUE_MILESTONE_ID', 'Milestone IDs')),
|
|
58
|
+
.describe(t('TOOL_UPDATE_ISSUE_MILESTONE_ID', 'Milestone IDs. Pass an empty array to clear all milestones. Omit this field to leave the current milestones unchanged.')),
|
|
59
59
|
statusId: z
|
|
60
60
|
.number()
|
|
61
61
|
.optional()
|
|
@@ -106,6 +106,30 @@ const updateIssueSchema = buildToolSchema((t) => ({
|
|
|
106
106
|
.optional()
|
|
107
107
|
.describe(t('TOOL_UPDATE_ISSUE_CUSTOM_FIELDS', 'List of custom fields to set on the issue')),
|
|
108
108
|
}));
|
|
109
|
+
// Backlog API clears a stored array field (category/version/milestone) only when
|
|
110
|
+
// the parameter is sent as `field[]=` (empty value). backlog-js serializes
|
|
111
|
+
// params via qs with `arrayFormat: 'brackets'`, and an empty array `[]` produces
|
|
112
|
+
// no query string at all, so the API silently ignores it. Converting `[]` to
|
|
113
|
+
// `['']` makes qs emit `field[]=`, which the API interprets as "clear".
|
|
114
|
+
//
|
|
115
|
+
// Only these three are persistent, clearable array fields. notifiedUserId (a
|
|
116
|
+
// one-off notification target) and attachmentId (attachments to add) are action
|
|
117
|
+
// parameters, not stored state, so they are intentionally excluded.
|
|
118
|
+
const CLEARABLE_ARRAY_FIELDS = [
|
|
119
|
+
'categoryId',
|
|
120
|
+
'versionId',
|
|
121
|
+
'milestoneId',
|
|
122
|
+
];
|
|
123
|
+
const clearEmptyArrayFields = (params) => {
|
|
124
|
+
const next = { ...params };
|
|
125
|
+
for (const field of CLEARABLE_ARRAY_FIELDS) {
|
|
126
|
+
const value = next[field];
|
|
127
|
+
if (Array.isArray(value) && value.length === 0) {
|
|
128
|
+
next[field] = [''];
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return next;
|
|
132
|
+
};
|
|
109
133
|
export const updateIssueTool = (backlog, { t }) => {
|
|
110
134
|
return {
|
|
111
135
|
name: 'update_issue',
|
|
@@ -119,9 +143,12 @@ export const updateIssueTool = (backlog, { t }) => {
|
|
|
119
143
|
}
|
|
120
144
|
const customFieldPayload = customFieldsToPayload(customFields);
|
|
121
145
|
const finalPayload = {
|
|
122
|
-
...params,
|
|
146
|
+
...clearEmptyArrayFields(params),
|
|
123
147
|
...customFieldPayload,
|
|
124
148
|
};
|
|
149
|
+
// The `['']` clear-sentinel is not representable in backlog-js's
|
|
150
|
+
// `number[]` param types, so cast at this single boundary. qs serializes
|
|
151
|
+
// it to `field[]=`, which the Backlog API accepts as "clear this field".
|
|
125
152
|
return backlog.patchIssue(result.value, finalPayload);
|
|
126
153
|
},
|
|
127
154
|
};
|
|
@@ -211,6 +211,10 @@ export const IssueSchema = z.object({
|
|
|
211
211
|
sharedFiles: z.array(SharedFileSchema),
|
|
212
212
|
stars: z.array(StarSchema),
|
|
213
213
|
});
|
|
214
|
+
// An issue plus the relation type that links it to the issue it was fetched from.
|
|
215
|
+
export const RelatedIssueSchema = IssueSchema.extend({
|
|
216
|
+
type: z.string(),
|
|
217
|
+
});
|
|
214
218
|
export const ProjectSchema = z.object({
|
|
215
219
|
id: z.number(),
|
|
216
220
|
projectKey: z.string(),
|
|
@@ -484,6 +488,9 @@ export const ActiveTrashTreeSchema = z.object({
|
|
|
484
488
|
id: z.string(),
|
|
485
489
|
children: z.array(DocumentTreeNodeSchema),
|
|
486
490
|
});
|
|
491
|
+
// No `: z.ZodRawShape` annotation here: it erases the concrete shape, so
|
|
492
|
+
// `z.infer<typeof DocumentTreeFullSchemaZ>` collapses to Record<string, unknown>
|
|
493
|
+
// and backlog-js's DocumentTree (no index signature) stops being assignable.
|
|
487
494
|
export const DocumentTreeFullSchema = {
|
|
488
495
|
projectId: z.number(),
|
|
489
496
|
activeTree: ActiveTrashTreeSchema.optional(),
|
|
@@ -36,6 +36,9 @@ function mapZodTypeToGraphQLType(zodType) {
|
|
|
36
36
|
return 'Int!';
|
|
37
37
|
if (zodType instanceof z.ZodBoolean)
|
|
38
38
|
return 'Boolean!';
|
|
39
|
+
// zod v4 types `.unwrap()` as the core `$ZodType` rather than the classic
|
|
40
|
+
// `ZodType`, so the recursive call needs a cast. The runtime value is a
|
|
41
|
+
// classic schema either way - only the declared type narrowed.
|
|
39
42
|
if (zodType instanceof z.ZodNullable)
|
|
40
43
|
return mapZodTypeToGraphQLType(zodType.unwrap()).replace(/!$/, '');
|
|
41
44
|
if (zodType instanceof z.ZodOptional)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backlog-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"backlog-mcp-server": "./build/index.js"
|
|
@@ -25,8 +25,9 @@
|
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@hono/node-server": "^2.0.10",
|
|
28
|
-
"@modelcontextprotocol/
|
|
29
|
-
"
|
|
28
|
+
"@modelcontextprotocol/hono": "^2.0.0",
|
|
29
|
+
"@modelcontextprotocol/server": "^2.0.0",
|
|
30
|
+
"backlog-js": "^0.19.1",
|
|
30
31
|
"cosmiconfig": "^9.0.1",
|
|
31
32
|
"env-var": "^7.5.0",
|
|
32
33
|
"graphql": "^16.14.1",
|
|
@@ -34,7 +35,7 @@
|
|
|
34
35
|
"pino": "^10.3.1",
|
|
35
36
|
"pino-pretty": "^13.1.3",
|
|
36
37
|
"yargs": "^18.0.0",
|
|
37
|
-
"zod": "^
|
|
38
|
+
"zod": "^4.4.3"
|
|
38
39
|
},
|
|
39
40
|
"devDependencies": {
|
|
40
41
|
"@eslint/js": "^10.0.1",
|