backlog-mcp-server 0.14.0 → 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 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` | Backlogアプリケーションの OAuth Client ID |
160
- | `BACKLOG_OAUTH_CLIENT_SECRET` | Backlogアプリケーションの OAuth Client Secret |
161
- | `MCP_SERVER_BASE_URL` | MCPサーバーの公開URL(例:`https://mcp.example.com`) |
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` | OAuth認可サーバーメタデータ([RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414)) |
180
- | `GET /.well-known/oauth-protected-resource/mcp` | OAuthリソースメタデータ([RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)) |
181
- | `POST /register` | 動的クライアント登録([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)) |
182
- | `GET /authorize` | 認可エンドポイント(Backlog OAuthへリダイレクト) |
183
- | `GET /callback` | Backlog OAuthコールバック |
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`, `GET`, and `DELETE` on `http://<host>:<port><path>` (default path `/mcp`).
153
- - **Session:** After `initialize`, clients must send the `mcp-session-id` header on later requests (as returned by the server).
154
- - **Security:** Default bind is `127.0.0.1`. 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.
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 | Description |
159
- | ------------------------ | ------------------------------------------------------------------------------------------ |
160
- | `MCP_TRANSPORT` | `stdio` (default) or `http` |
161
- | `MCP_HTTP_HOST` | Bind address (default `127.0.0.1`) |
162
- | `MCP_HTTP_PORT` | Port (default `3333`) |
163
- | `MCP_HTTP_PATH` | URL path (default `/mcp`) |
164
- | `MCP_HTTP_JSON_RESPONSE` | `true` to prefer JSON responses over SSE when supported |
165
- | `MCP_HTTP_ALLOWED_HOSTS` | Comma-separated allowed `Host` values when binding to `0.0.0.0` (DNS rebinding protection) |
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`
@@ -644,8 +651,9 @@ The server supports several command line options:
644
651
 
645
652
  - `--transport stdio|http`: MCP transport (default: stdio). Use `http` for Streamable HTTP.
646
653
  - `--http-host`, `--http-port`, `--http-path`: HTTP bind address, port, and path (defaults: `127.0.0.1`, `3333`, `/mcp`).
647
- - `--http-json-response`: Prefer JSON responses over SSE when the transport supports it.
648
- - `--http-allowed-hosts`: Comma-separated allowed `Host` headers when binding to all interfaces.
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.
649
657
  - `--export-translations`: Export all translation keys and values
650
658
  - `--optimize-response`: Enable GraphQL-style field selection
651
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 (await response.json());
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 (await response.json());
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 (await response.json());
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
- if (parsed.protocol === 'http:' &&
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 = (await c.req.json());
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/sdk/server/mcp.js';
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 for stdio; one instance per HTTP session for Streamable HTTP.
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 = buildToolsetGroup(backlog, transHelper, enabledToolsets);
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
  }
@@ -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 { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
6
- import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
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 jsonRpcError = (code, message) => {
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 transports = {};
67
- const allowedHostnames = buildAllowedHostnames(host, allowedHosts);
17
+ const isLocalhostBind = LOCALHOST_BINDS.includes(host);
68
18
  const oauthEnabled = !!(oauthConfig && tokenStore);
69
- if (allowedHostnames) {
70
- app.use('*', async (c, next) => {
71
- const hostError = checkHostHeader(c.req.raw.headers.get('host'), allowedHostnames);
72
- if (hostError)
73
- return c.json(hostError, 403);
74
- await next();
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 req = c.req.raw;
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
- if (sessionId && transports[sessionId]) {
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, handleNew)
116
- : handleNew();
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(jsonRpcError(-32603, 'Internal server error'), 500);
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
- for (const sid of Object.keys(transports)) {
129
- try {
130
- await transports[sid].close();
131
- }
132
- catch {
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 { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
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 for stdio; one fresh instance per HTTP session for Streamable HTTP.
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 allowedHostsRaw = argv.httpAllowedHosts;
179
- const allowedHosts = allowedHostsRaw && allowedHostsRaw.trim().length > 0
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
- const server = createServer();
218
- const transport = new StdioServerTransport();
219
- await server.connect(transport);
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) => {
@@ -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.shape, handler);
34
+ server.registerOnce(toolNameWithPrefix, tool.description, tool.schema, handler);
35
35
  }
36
36
  }
37
37
  }
@@ -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
  };
@@ -488,6 +488,9 @@ export const ActiveTrashTreeSchema = z.object({
488
488
  id: z.string(),
489
489
  children: z.array(DocumentTreeNodeSchema),
490
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.
491
494
  export const DocumentTreeFullSchema = {
492
495
  projectId: z.number(),
493
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)
@@ -10,7 +10,7 @@ export function wrapServerWithToolRegistry(server) {
10
10
  return;
11
11
  }
12
12
  s.__registeredToolNames.add(name);
13
- s.tool(name, description, schema, handler);
13
+ s.registerTool(name, { description, inputSchema: schema }, handler);
14
14
  };
15
15
  return s;
16
16
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backlog-mcp-server",
3
- "version": "0.14.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/sdk": "^1.29.0",
29
- "backlog-js": "^0.19.0",
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": "^3.24.3"
38
+ "zod": "^4.4.3"
38
39
  },
39
40
  "devDependencies": {
40
41
  "@eslint/js": "^10.0.1",