backlog-mcp-server 0.11.1 → 0.13.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
@@ -101,8 +101,8 @@ docker pull ghcr.io/nulab/backlog-mcp-server:latest
101
101
  ```bash
102
102
  git clone https://github.com/nulab/backlog-mcp-server.git
103
103
  cd backlog-mcp-server
104
- npm install
105
- npm run build
104
+ pnpm install
105
+ pnpm run build
106
106
  ```
107
107
 
108
108
  2. テンプレートから `.env` を作成し、必須の環境変数を設定します:
@@ -119,7 +119,7 @@ cp .env.example .env
119
119
  3. ローカルで起動します:
120
120
 
121
121
  ```bash
122
- npm run dev
122
+ pnpm run dev
123
123
  ```
124
124
 
125
125
  4. MCPとして使用するJSONを設定します:
@@ -139,6 +139,56 @@ npm run dev
139
139
  }
140
140
  ```
141
141
 
142
+ ### OAuth 2.0 認証(リモートMCP)
143
+
144
+ MCPサーバーをネットワーク経由で公開する場合、OAuth 2.0認証を有効にすることで、共有APIキーではなく各ユーザーが自分のBacklogアカウントで認証できます。
145
+
146
+ サーバーは [MCP Third-Party Authorization Flow](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) を実装しており、MCPクライアント向けのOAuth認可サーバーとBacklog向けのOAuthクライアントの両方として動作します。
147
+
148
+ #### 前提条件
149
+
150
+ 1. Backlogスペースにてアプリケーションを登録します:
151
+ - Backlogスペース → 個人設定 → アプリケーションの登録
152
+ - **リダイレクトURI** を `<MCP_SERVER_BASE_URL>/callback`(例:`https://mcp.example.com/callback`)に設定
153
+ - **Client ID** と **Client Secret** をメモ
154
+
155
+ 2. 以下の環境変数を設定します(`BACKLOG_DOMAIN` に加えて):
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`) |
162
+
163
+ > **注意:** OAuth有効時は `BACKLOG_API_KEY` は**不要**です。各ユーザーが自分のBacklogアカウントで認証します。
164
+
165
+ #### 使用例
166
+
167
+ ```bash
168
+ BACKLOG_DOMAIN=your-space.backlog.com \
169
+ BACKLOG_OAUTH_CLIENT_ID=your-client-id \
170
+ BACKLOG_OAUTH_CLIENT_SECRET=your-client-secret \
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
173
+ ```
174
+
175
+ OAuth有効時、サーバーは以下のOAuthエンドポイントを自動的に公開します:
176
+
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` | トークンエンドポイント(認可コード&リフレッシュトークン) |
185
+
186
+ MCP認可仕様に対応するMCPクライアントは、これらのエンドポイントを自動的に使用します。
187
+
188
+ > **制約事項:**
189
+ > - OAuthモードは現在、単一のBacklog組織のみをサポートしています。複数組織設定との併用はできません。
190
+ > - クライアント登録やトークンはメモリ内に保持されるため、サーバー再起動時に失われます。
191
+
142
192
  ## ツール設定
143
193
 
144
194
  `--enable-toolsets` コマンドラインフラグまたは `ENABLE_TOOLSETS` 環境変数を使用して、特定の **ツールセット** を選択的に有効または無効にすることができます。これにより、AIエージェントが利用できるツールをより細かく制御し、コンテキストサイズを削減するのに役立ちます。
@@ -479,7 +529,7 @@ MAX_TOKENS=10000
479
529
  ### テストの実行
480
530
 
481
531
  ```bash
482
- npm test
532
+ pnpm test
483
533
  ```
484
534
 
485
535
  ### 新しいツールの追加
package/README.md CHANGED
@@ -101,8 +101,8 @@ Replace `your-domain.backlog.com` with your Backlog domain and `your-api-key` wi
101
101
  ```bash
102
102
  git clone https://github.com/nulab/backlog-mcp-server.git
103
103
  cd backlog-mcp-server
104
- npm install
105
- npm run build
104
+ pnpm install
105
+ pnpm run build
106
106
  ```
107
107
 
108
108
  2. Create `.env` from template and set required variables:
@@ -119,7 +119,7 @@ Set the following values in `.env`:
119
119
  3. Run locally:
120
120
 
121
121
  ```bash
122
- npm run dev
122
+ pnpm run dev
123
123
  ```
124
124
 
125
125
  4. Set your json to use as MCP
@@ -144,7 +144,7 @@ npm run dev
144
144
  By default the server uses **stdio**. To run the [MCP Streamable HTTP](https://modelcontextprotocol.io/) transport instead (JSON-RPC over HTTP, same tools as stdio), start with `--transport http` or set `MCP_TRANSPORT=http`.
145
145
 
146
146
  ```bash
147
- npm run build
147
+ pnpm run build
148
148
  MCP_TRANSPORT=http MCP_HTTP_PORT=3333 node build/index.js
149
149
  ```
150
150
 
@@ -154,15 +154,66 @@ MCP_TRANSPORT=http MCP_HTTP_PORT=3333 node build/index.js
154
154
 
155
155
  Environment variables (CLI flags override when both are set):
156
156
 
157
- | Variable | Description |
158
- | -------- | ----------- |
159
- | `MCP_TRANSPORT` | `stdio` (default) or `http` |
160
- | `MCP_HTTP_HOST` | Bind address (default `127.0.0.1`) |
161
- | `MCP_HTTP_PORT` | Port (default `3333`) |
162
- | `MCP_HTTP_PATH` | URL path (default `/mcp`) |
163
- | `MCP_HTTP_JSON_RESPONSE` | `true` to prefer JSON responses over SSE when supported |
157
+ | Variable | Description |
158
+ | ------------------------ | ------------------------------------------------------------------------------------------ |
159
+ | `MCP_TRANSPORT` | `stdio` (default) or `http` |
160
+ | `MCP_HTTP_HOST` | Bind address (default `127.0.0.1`) |
161
+ | `MCP_HTTP_PORT` | Port (default `3333`) |
162
+ | `MCP_HTTP_PATH` | URL path (default `/mcp`) |
163
+ | `MCP_HTTP_JSON_RESPONSE` | `true` to prefer JSON responses over SSE when supported |
164
164
  | `MCP_HTTP_ALLOWED_HOSTS` | Comma-separated allowed `Host` values when binding to `0.0.0.0` (DNS rebinding protection) |
165
165
 
166
+ ### OAuth 2.0 Authentication (Remote MCP)
167
+
168
+ When exposing the MCP server over a network, you can enable OAuth 2.0 authentication so that each user authenticates with their own Backlog account instead of sharing a single API key.
169
+
170
+ The server implements the [MCP Third-Party Authorization Flow](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) by acting as both an OAuth authorization server (for MCP clients) and an OAuth client (for Backlog).
171
+
172
+ #### Prerequisites
173
+
174
+ 1. Register an OAuth application in your Backlog space:
175
+ - Go to your Backlog space → Personal Settings → Register Application
176
+ - Set the **Redirect URI** to `<MCP_SERVER_BASE_URL>/callback` (e.g., `https://mcp.example.com/callback`)
177
+ - Note the **Client ID** and **Client Secret**
178
+
179
+ 2. Set the following environment variables (in addition to `BACKLOG_DOMAIN`):
180
+
181
+ | Variable | Description |
182
+ | ----------------------------- | --------------------------------------------------------------- |
183
+ | `BACKLOG_OAUTH_CLIENT_ID` | OAuth Client ID from your Backlog application |
184
+ | `BACKLOG_OAUTH_CLIENT_SECRET` | OAuth Client Secret from your Backlog application |
185
+ | `MCP_SERVER_BASE_URL` | Public URL of your MCP server (e.g., `https://mcp.example.com`) |
186
+
187
+ > **Note:** `BACKLOG_API_KEY` is **not required** when OAuth is enabled — each user authenticates with their own Backlog account.
188
+
189
+ #### Example
190
+
191
+ ```bash
192
+ BACKLOG_DOMAIN=your-space.backlog.com \
193
+ BACKLOG_OAUTH_CLIENT_ID=your-client-id \
194
+ BACKLOG_OAUTH_CLIENT_SECRET=your-client-secret \
195
+ MCP_SERVER_BASE_URL=https://mcp.example.com \
196
+ node build/index.js --transport http --http-host 0.0.0.0 --http-port 3333
197
+ ```
198
+
199
+ The server automatically exposes the following OAuth endpoints when OAuth is enabled:
200
+
201
+ | Endpoint | Description |
202
+ | ----------------------------------------------- | ----------------------------------------------------------------------------------------------- |
203
+ | `GET /.well-known/oauth-authorization-server` | OAuth Authorization Server Metadata ([RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414)) |
204
+ | `GET /.well-known/oauth-protected-resource/mcp` | OAuth Protected Resource Metadata ([RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)) |
205
+ | `POST /register` | Dynamic Client Registration ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)) |
206
+ | `GET /authorize` | Authorization endpoint (redirects to Backlog OAuth) |
207
+ | `GET /callback` | Backlog OAuth callback |
208
+ | `POST /token` | Token endpoint (authorization code & refresh token) |
209
+
210
+ MCP clients that support the MCP authorization specification will use these endpoints automatically.
211
+
212
+ > **Limitations:**
213
+ >
214
+ > - OAuth mode currently supports a single Backlog organization. It is not compatible with the multi-organization configuration.
215
+ > - Client registrations and tokens are stored in memory and will be lost on server restart.
216
+
166
217
  ## Tool Configuration
167
218
 
168
219
  You can selectively enable or disable specific **toolsets** using the `--enable-toolsets` command-line flag or the `ENABLE_TOOLSETS` environment variable. This allows better control over which tools are available to the AI agent and helps reduce context size.
@@ -238,6 +289,7 @@ Tools for managing projects, categories, custom fields, and issue types.
238
289
  - `get_project_list`: Returns list of projects.
239
290
  - `add_project`: Creates a new project.
240
291
  - `get_project`: Returns information about a specific project.
292
+ - `get_project_users`: Returns list of users in a specific project.
241
293
  - `update_project`: Updates an existing project.
242
294
  - `delete_project`: Deletes a project.
243
295
 
@@ -583,7 +635,7 @@ This section demonstrates advanced configuration using multiple environment vari
583
635
  ### Running Tests
584
636
 
585
637
  ```bash
586
- npm test
638
+ pnpm test
587
639
  ```
588
640
 
589
641
  ### Adding New Tools
@@ -0,0 +1,10 @@
1
+ // Copyright (c) 2025 Nulab inc.
2
+ // Licensed under the MIT License.
3
+ import { AsyncLocalStorage } from 'node:async_hooks';
4
+ const accessTokenStorage = new AsyncLocalStorage();
5
+ export function runWithAccessToken(token, fn) {
6
+ return accessTokenStorage.run(token, fn);
7
+ }
8
+ export function getCurrentAccessToken() {
9
+ return accessTokenStorage.getStore();
10
+ }
@@ -0,0 +1,57 @@
1
+ // Copyright (c) 2025 Nulab inc.
2
+ // Licensed under the MIT License.
3
+ export function buildBacklogAuthorizationUrl(config, redirectUri, state) {
4
+ const params = new URLSearchParams({
5
+ response_type: 'code',
6
+ client_id: config.clientId,
7
+ redirect_uri: redirectUri,
8
+ state,
9
+ });
10
+ return `https://${config.backlogDomain}/OAuth2AccessRequest.action?${params.toString()}`;
11
+ }
12
+ export async function exchangeBacklogCode(config, code, redirectUri) {
13
+ const params = new URLSearchParams({
14
+ grant_type: 'authorization_code',
15
+ code,
16
+ client_id: config.clientId,
17
+ client_secret: config.clientSecret,
18
+ redirect_uri: redirectUri,
19
+ });
20
+ const response = await fetch(`https://${config.backlogDomain}/api/v2/oauth2/token`, {
21
+ method: 'POST',
22
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
23
+ body: params.toString(),
24
+ });
25
+ if (!response.ok) {
26
+ const text = await response.text();
27
+ throw new Error(`Backlog token exchange failed (${response.status}): ${text}`);
28
+ }
29
+ return (await response.json());
30
+ }
31
+ export async function refreshBacklogToken(config, refreshToken) {
32
+ const params = new URLSearchParams({
33
+ grant_type: 'refresh_token',
34
+ client_id: config.clientId,
35
+ client_secret: config.clientSecret,
36
+ refresh_token: refreshToken,
37
+ });
38
+ const response = await fetch(`https://${config.backlogDomain}/api/v2/oauth2/token`, {
39
+ method: 'POST',
40
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
41
+ body: params.toString(),
42
+ });
43
+ if (!response.ok) {
44
+ const text = await response.text();
45
+ throw new Error(`Backlog token refresh failed (${response.status}): ${text}`);
46
+ }
47
+ return (await response.json());
48
+ }
49
+ export async function verifyBacklogToken(domain, accessToken) {
50
+ const response = await fetch(`https://${domain}/api/v2/users/myself`, {
51
+ headers: { Authorization: `Bearer ${accessToken}` },
52
+ });
53
+ if (!response.ok) {
54
+ throw new Error(`Backlog token verification failed (${response.status})`);
55
+ }
56
+ return (await response.json());
57
+ }
@@ -0,0 +1,25 @@
1
+ // Copyright (c) 2025 Nulab inc.
2
+ // Licensed under the MIT License.
3
+ export function getBacklogOAuthConfig(env = process.env) {
4
+ const clientId = env.BACKLOG_OAUTH_CLIENT_ID;
5
+ if (!clientId)
6
+ return undefined;
7
+ const clientSecret = env.BACKLOG_OAUTH_CLIENT_SECRET;
8
+ if (!clientSecret) {
9
+ throw new Error('BACKLOG_OAUTH_CLIENT_SECRET is required when BACKLOG_OAUTH_CLIENT_ID is set.');
10
+ }
11
+ const backlogDomain = env.BACKLOG_DOMAIN;
12
+ if (!backlogDomain) {
13
+ throw new Error('BACKLOG_DOMAIN is required when BACKLOG_OAUTH_CLIENT_ID is set.');
14
+ }
15
+ const serverBaseUrl = env.MCP_SERVER_BASE_URL;
16
+ if (!serverBaseUrl) {
17
+ throw new Error('MCP_SERVER_BASE_URL is required when BACKLOG_OAUTH_CLIENT_ID is set.');
18
+ }
19
+ return {
20
+ clientId,
21
+ clientSecret,
22
+ backlogDomain,
23
+ serverBaseUrl: serverBaseUrl.replace(/\/+$/, ''),
24
+ };
25
+ }
@@ -0,0 +1,58 @@
1
+ // Copyright (c) 2025 Nulab inc.
2
+ // Licensed under the MIT License.
3
+ import { verifyBacklogToken } from './backlogOAuthClient.js';
4
+ import { logger } from '../utils/logger.js';
5
+ const CACHE_TTL_MS = 5 * 60 * 1000;
6
+ export function createBearerAuthMiddleware(store, config, mcpPath) {
7
+ const prmPath = mcpPath === '/' ? '' : mcpPath;
8
+ const resourceMetadataUrl = `${config.serverBaseUrl}/.well-known/oauth-protected-resource${prmPath}`;
9
+ return async (c, next) => {
10
+ const authHeader = c.req.header('authorization');
11
+ if (!authHeader) {
12
+ c.header('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}"`);
13
+ return c.json({
14
+ error: 'invalid_token',
15
+ error_description: 'Missing Authorization header',
16
+ }, 401);
17
+ }
18
+ const [type, mcpToken] = authHeader.split(' ');
19
+ if (type?.toLowerCase() !== 'bearer' || !mcpToken) {
20
+ c.header('WWW-Authenticate', `Bearer error="invalid_token", error_description="Invalid Authorization header format", resource_metadata="${resourceMetadataUrl}"`);
21
+ return c.json({ error: 'invalid_token', error_description: 'Expected Bearer token' }, 401);
22
+ }
23
+ const tokenEntry = store.getMcpToken(mcpToken);
24
+ if (!tokenEntry) {
25
+ c.header('WWW-Authenticate', `Bearer error="invalid_token", error_description="Unknown or expired token", resource_metadata="${resourceMetadataUrl}"`);
26
+ return c.json({
27
+ error: 'invalid_token',
28
+ error_description: 'Unknown or expired token',
29
+ }, 401);
30
+ }
31
+ const cached = store.getCachedVerification(mcpToken);
32
+ if (cached) {
33
+ c.set('authInfo', cached);
34
+ await next();
35
+ return;
36
+ }
37
+ try {
38
+ const user = await verifyBacklogToken(config.backlogDomain, tokenEntry.backlogAccessToken);
39
+ const authInfo = {
40
+ token: tokenEntry.backlogAccessToken,
41
+ clientId: String(user.id),
42
+ scopes: [],
43
+ expiresAt: Math.floor(Date.now() / 1000) + CACHE_TTL_MS / 1000,
44
+ };
45
+ store.cacheVerification(mcpToken, authInfo, CACHE_TTL_MS);
46
+ c.set('authInfo', authInfo);
47
+ await next();
48
+ }
49
+ catch (err) {
50
+ logger.warn({ err }, 'Bearer token verification failed');
51
+ c.header('WWW-Authenticate', `Bearer error="invalid_token", error_description="Token verification failed", resource_metadata="${resourceMetadataUrl}"`);
52
+ return c.json({
53
+ error: 'invalid_token',
54
+ error_description: 'Token verification failed',
55
+ }, 401);
56
+ }
57
+ };
58
+ }
@@ -0,0 +1,336 @@
1
+ // Copyright (c) 2025 Nulab inc.
2
+ // Licensed under the MIT License.
3
+ import { randomUUID, randomBytes, createHash } from 'node:crypto';
4
+ import { Hono } from 'hono';
5
+ import { buildBacklogAuthorizationUrl, exchangeBacklogCode, refreshBacklogToken, } from './backlogOAuthClient.js';
6
+ import { logger } from '../utils/logger.js';
7
+ const AUTH_CODE_TTL_MS = 10 * 60 * 1000;
8
+ const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
9
+ const LOCALHOST_HOSTS = ['localhost', '127.0.0.1', '[::1]'];
10
+ const SUPPORTED_AUTH_METHODS = ['client_secret_post', 'none'];
11
+ function verifyPkce(codeVerifier, codeChallenge) {
12
+ const hash = createHash('sha256').update(codeVerifier).digest('base64url');
13
+ return hash === codeChallenge;
14
+ }
15
+ function oauthError(code, description) {
16
+ return { error: code, error_description: description };
17
+ }
18
+ function isValidRedirectUri(uri) {
19
+ try {
20
+ const parsed = new URL(uri);
21
+ if (parsed.protocol === 'https:')
22
+ return true;
23
+ if (parsed.protocol === 'http:' &&
24
+ LOCALHOST_HOSTS.includes(parsed.hostname))
25
+ return true;
26
+ return false;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ export function createOAuthRoutes(config, store, mcpPath) {
33
+ const app = new Hono();
34
+ const { serverBaseUrl } = config;
35
+ const callbackUrl = `${serverBaseUrl}/callback`;
36
+ const resourceUri = `${serverBaseUrl}${mcpPath}`;
37
+ // RFC 8414 — OAuth Authorization Server Metadata
38
+ app.get('/.well-known/oauth-authorization-server', (c) => {
39
+ c.header('Cache-Control', 'public, max-age=3600');
40
+ return c.json({
41
+ issuer: serverBaseUrl,
42
+ authorization_endpoint: `${serverBaseUrl}/authorize`,
43
+ token_endpoint: `${serverBaseUrl}/token`,
44
+ registration_endpoint: `${serverBaseUrl}/register`,
45
+ response_types_supported: ['code'],
46
+ grant_types_supported: ['authorization_code', 'refresh_token'],
47
+ token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
48
+ code_challenge_methods_supported: ['S256'],
49
+ });
50
+ });
51
+ // RFC 9728 — OAuth Protected Resource Metadata
52
+ const prm = mcpPath === '/' ? '' : mcpPath;
53
+ app.get(`/.well-known/oauth-protected-resource${prm}`, (c) => {
54
+ c.header('Cache-Control', 'public, max-age=3600');
55
+ return c.json({
56
+ resource: resourceUri,
57
+ authorization_servers: [serverBaseUrl],
58
+ });
59
+ });
60
+ // Dynamic Client Registration (RFC 7591)
61
+ app.post('/register', async (c) => {
62
+ let body;
63
+ try {
64
+ body = (await c.req.json());
65
+ }
66
+ catch {
67
+ return c.json(oauthError('invalid_request', 'Invalid JSON body'), 400);
68
+ }
69
+ const redirectUris = body.redirect_uris;
70
+ if (!Array.isArray(redirectUris) ||
71
+ redirectUris.length === 0 ||
72
+ !redirectUris.every((u) => typeof u === 'string')) {
73
+ return c.json(oauthError('invalid_client_metadata', 'redirect_uris must be a non-empty array of strings'), 400);
74
+ }
75
+ for (const uri of redirectUris) {
76
+ if (!isValidRedirectUri(uri)) {
77
+ return c.json(oauthError('invalid_client_metadata', `redirect_uri must use https or http://localhost: ${uri}`), 400);
78
+ }
79
+ }
80
+ const authMethod = typeof body.token_endpoint_auth_method === 'string'
81
+ ? body.token_endpoint_auth_method
82
+ : 'client_secret_post';
83
+ if (!SUPPORTED_AUTH_METHODS.includes(authMethod)) {
84
+ return c.json(oauthError('invalid_client_metadata', `Unsupported token_endpoint_auth_method: ${authMethod}. Supported: ${SUPPORTED_AUTH_METHODS.join(', ')}`), 400);
85
+ }
86
+ const clientId = randomUUID();
87
+ const clientSecret = authMethod === 'none' ? undefined : randomBytes(32).toString('hex');
88
+ const now = Math.floor(Date.now() / 1000);
89
+ const client = {
90
+ client_id: clientId,
91
+ client_secret: clientSecret,
92
+ client_id_issued_at: now,
93
+ client_secret_expires_at: 0,
94
+ redirect_uris: redirectUris,
95
+ client_name: typeof body.client_name === 'string' ? body.client_name : undefined,
96
+ token_endpoint_auth_method: authMethod,
97
+ grant_types: ['authorization_code', 'refresh_token'],
98
+ response_types: ['code'],
99
+ };
100
+ if (!store.registerClient(client)) {
101
+ return c.json(oauthError('server_error', 'Maximum number of registered clients reached'), 503);
102
+ }
103
+ logger.info({ clientId }, 'Registered new OAuth client');
104
+ return c.json(client, 201);
105
+ });
106
+ // Authorization Endpoint
107
+ app.get('/authorize', async (c) => {
108
+ const params = Object.fromEntries(new URL(c.req.url).searchParams.entries());
109
+ const clientId = params.client_id;
110
+ const redirectUri = params.redirect_uri;
111
+ const responseType = params.response_type;
112
+ const codeChallenge = params.code_challenge;
113
+ const codeChallengeMethod = params.code_challenge_method;
114
+ const scope = params.scope;
115
+ const state = params.state;
116
+ const resource = params.resource;
117
+ // Phase 1: Validate client and redirect_uri (errors returned directly)
118
+ if (!clientId) {
119
+ return c.json(oauthError('invalid_request', 'Missing client_id'), 400);
120
+ }
121
+ const client = store.getClient(clientId);
122
+ if (!client) {
123
+ return c.json(oauthError('invalid_client', 'Unknown client_id'), 400);
124
+ }
125
+ if (!redirectUri) {
126
+ if (client.redirect_uris.length !== 1) {
127
+ return c.json(oauthError('invalid_request', 'redirect_uri is required'), 400);
128
+ }
129
+ }
130
+ else if (!client.redirect_uris.includes(redirectUri)) {
131
+ return c.json(oauthError('invalid_request', 'Unregistered redirect_uri'), 400);
132
+ }
133
+ const effectiveRedirectUri = redirectUri ?? client.redirect_uris[0];
134
+ // Phase 2: Validate other params (errors redirected)
135
+ if (responseType !== 'code') {
136
+ const url = new URL(effectiveRedirectUri);
137
+ url.searchParams.set('error', 'unsupported_response_type');
138
+ url.searchParams.set('error_description', 'Only response_type=code is supported');
139
+ if (state)
140
+ url.searchParams.set('state', state);
141
+ return c.redirect(url.href, 302);
142
+ }
143
+ if (!codeChallenge) {
144
+ const url = new URL(effectiveRedirectUri);
145
+ url.searchParams.set('error', 'invalid_request');
146
+ url.searchParams.set('error_description', 'code_challenge is required');
147
+ if (state)
148
+ url.searchParams.set('state', state);
149
+ return c.redirect(url.href, 302);
150
+ }
151
+ if (codeChallengeMethod && codeChallengeMethod !== 'S256') {
152
+ const url = new URL(effectiveRedirectUri);
153
+ url.searchParams.set('error', 'invalid_request');
154
+ url.searchParams.set('error_description', 'Only code_challenge_method=S256 is supported');
155
+ if (state)
156
+ url.searchParams.set('state', state);
157
+ return c.redirect(url.href, 302);
158
+ }
159
+ if (resource && resource !== resourceUri) {
160
+ const url = new URL(effectiveRedirectUri);
161
+ url.searchParams.set('error', 'invalid_target');
162
+ url.searchParams.set('error_description', 'Invalid resource parameter');
163
+ if (state)
164
+ url.searchParams.set('state', state);
165
+ return c.redirect(url.href, 302);
166
+ }
167
+ // Store pending authorization and redirect to Backlog
168
+ const backlogState = randomUUID();
169
+ store.storePendingAuth(backlogState, {
170
+ mcpClientId: clientId,
171
+ codeChallenge,
172
+ redirectUri: effectiveRedirectUri,
173
+ resource: resource ?? resourceUri,
174
+ scopes: scope ? scope.split(' ') : [],
175
+ state,
176
+ createdAt: Date.now(),
177
+ });
178
+ const backlogAuthUrl = buildBacklogAuthorizationUrl(config, callbackUrl, backlogState);
179
+ return c.redirect(backlogAuthUrl, 302);
180
+ });
181
+ // Backlog OAuth Callback
182
+ app.get('/callback', async (c) => {
183
+ const url = new URL(c.req.url);
184
+ const backlogCode = url.searchParams.get('code');
185
+ const backlogState = url.searchParams.get('state');
186
+ const backlogError = url.searchParams.get('error');
187
+ if (!backlogState) {
188
+ return c.text('Missing state parameter from Backlog', 400);
189
+ }
190
+ if (backlogError || !backlogCode) {
191
+ const pending = store.consumePendingAuth(backlogState);
192
+ if (!pending) {
193
+ return c.text('Unknown or expired authorization state. Please start the authorization flow again.', 400);
194
+ }
195
+ const errorUrl = new URL(pending.redirectUri);
196
+ errorUrl.searchParams.set('error', backlogError ?? 'access_denied');
197
+ errorUrl.searchParams.set('error_description', url.searchParams.get('error_description') ??
198
+ 'Authorization was denied by the user');
199
+ if (pending.state)
200
+ errorUrl.searchParams.set('state', pending.state);
201
+ return c.redirect(errorUrl.href, 302);
202
+ }
203
+ const pending = store.consumePendingAuth(backlogState);
204
+ if (!pending) {
205
+ return c.text('Unknown or expired authorization state. Please start the authorization flow again.', 400);
206
+ }
207
+ let backlogTokens;
208
+ try {
209
+ backlogTokens = await exchangeBacklogCode(config, backlogCode, callbackUrl);
210
+ }
211
+ catch (err) {
212
+ logger.error({ err }, 'Failed to exchange Backlog authorization code');
213
+ const url = new URL(pending.redirectUri);
214
+ url.searchParams.set('error', 'server_error');
215
+ url.searchParams.set('error_description', 'Failed to exchange authorization code with Backlog');
216
+ if (pending.state)
217
+ url.searchParams.set('state', pending.state);
218
+ return c.redirect(url.href, 302);
219
+ }
220
+ const mcpCode = randomUUID();
221
+ store.storeAuthCode(mcpCode, {
222
+ mcpClientId: pending.mcpClientId,
223
+ backlogTokens,
224
+ codeChallenge: pending.codeChallenge,
225
+ redirectUri: pending.redirectUri,
226
+ resource: pending.resource,
227
+ expiresAt: Date.now() + AUTH_CODE_TTL_MS,
228
+ });
229
+ const redirectUrl = new URL(pending.redirectUri);
230
+ redirectUrl.searchParams.set('code', mcpCode);
231
+ if (pending.state)
232
+ redirectUrl.searchParams.set('state', pending.state);
233
+ return c.redirect(redirectUrl.href, 302);
234
+ });
235
+ // Token Endpoint
236
+ app.post('/token', async (c) => {
237
+ c.header('Cache-Control', 'no-store');
238
+ const body = (await c.req.parseBody());
239
+ const grantType = body.grant_type;
240
+ const clientId = body.client_id;
241
+ if (!clientId) {
242
+ return c.json(oauthError('invalid_request', 'Missing client_id'), 400);
243
+ }
244
+ const client = store.getClient(clientId);
245
+ if (!client) {
246
+ return c.json(oauthError('invalid_client', 'Unknown client_id'), 401);
247
+ }
248
+ if (client.client_secret && body.client_secret !== client.client_secret) {
249
+ return c.json(oauthError('invalid_client', 'Invalid client_secret'), 401);
250
+ }
251
+ if (grantType === 'authorization_code') {
252
+ const code = body.code;
253
+ const codeVerifier = body.code_verifier;
254
+ const redirectUri = body.redirect_uri;
255
+ const resource = body.resource;
256
+ if (!code) {
257
+ return c.json(oauthError('invalid_request', 'Missing code'), 400);
258
+ }
259
+ const entry = store.consumeAuthCode(code);
260
+ if (!entry) {
261
+ return c.json(oauthError('invalid_grant', 'Invalid or expired authorization code'), 400);
262
+ }
263
+ if (entry.mcpClientId !== clientId) {
264
+ return c.json(oauthError('invalid_grant', 'Authorization code was issued to a different client'), 400);
265
+ }
266
+ if (!redirectUri || redirectUri !== entry.redirectUri) {
267
+ return c.json(oauthError('invalid_grant', !redirectUri ? 'Missing redirect_uri' : 'redirect_uri mismatch'), 400);
268
+ }
269
+ if (resource && resource !== entry.resource) {
270
+ return c.json(oauthError('invalid_grant', 'resource mismatch'), 400);
271
+ }
272
+ if (!codeVerifier || !verifyPkce(codeVerifier, entry.codeChallenge)) {
273
+ return c.json(oauthError('invalid_grant', 'Invalid code_verifier'), 400);
274
+ }
275
+ const mcpAccessToken = randomBytes(32).toString('hex');
276
+ const mcpRefreshToken = randomBytes(32).toString('hex');
277
+ store.storeMcpToken(mcpAccessToken, {
278
+ backlogAccessToken: entry.backlogTokens.access_token,
279
+ clientId,
280
+ expiresAt: Date.now() + entry.backlogTokens.expires_in * 1000,
281
+ });
282
+ store.storeMcpRefreshToken(mcpRefreshToken, {
283
+ backlogRefreshToken: entry.backlogTokens.refresh_token,
284
+ clientId,
285
+ expiresAt: Date.now() + REFRESH_TOKEN_TTL_MS,
286
+ });
287
+ return c.json({
288
+ access_token: mcpAccessToken,
289
+ token_type: 'bearer',
290
+ expires_in: entry.backlogTokens.expires_in,
291
+ refresh_token: mcpRefreshToken,
292
+ });
293
+ }
294
+ if (grantType === 'refresh_token') {
295
+ const refreshToken = body.refresh_token;
296
+ if (!refreshToken) {
297
+ return c.json(oauthError('invalid_request', 'Missing refresh_token'), 400);
298
+ }
299
+ const refreshEntry = store.consumeMcpRefreshToken(refreshToken);
300
+ if (!refreshEntry) {
301
+ return c.json(oauthError('invalid_grant', 'Invalid or expired refresh token'), 400);
302
+ }
303
+ if (refreshEntry.clientId !== clientId) {
304
+ return c.json(oauthError('invalid_grant', 'Refresh token was issued to a different client'), 400);
305
+ }
306
+ try {
307
+ const tokens = await refreshBacklogToken(config, refreshEntry.backlogRefreshToken);
308
+ const mcpAccessToken = randomBytes(32).toString('hex');
309
+ const mcpRefreshToken = randomBytes(32).toString('hex');
310
+ store.storeMcpToken(mcpAccessToken, {
311
+ backlogAccessToken: tokens.access_token,
312
+ clientId,
313
+ expiresAt: Date.now() + tokens.expires_in * 1000,
314
+ });
315
+ store.storeMcpRefreshToken(mcpRefreshToken, {
316
+ backlogRefreshToken: tokens.refresh_token,
317
+ clientId,
318
+ expiresAt: Date.now() + REFRESH_TOKEN_TTL_MS,
319
+ });
320
+ return c.json({
321
+ access_token: mcpAccessToken,
322
+ token_type: 'bearer',
323
+ expires_in: tokens.expires_in,
324
+ refresh_token: mcpRefreshToken,
325
+ });
326
+ }
327
+ catch (err) {
328
+ logger.error({ err }, 'Failed to refresh Backlog token');
329
+ store.storeMcpRefreshToken(refreshToken, refreshEntry);
330
+ return c.json(oauthError('server_error', 'Failed to refresh upstream token'), 503);
331
+ }
332
+ }
333
+ return c.json(oauthError('unsupported_grant_type', `Unsupported grant_type: ${grantType}`), 400);
334
+ });
335
+ return app;
336
+ }
@@ -0,0 +1,129 @@
1
+ // Copyright (c) 2025 Nulab inc.
2
+ // Licensed under the MIT License.
3
+ const PENDING_AUTH_TTL_MS = 10 * 60 * 1000;
4
+ const CLIENT_TTL_MS = 90 * 24 * 60 * 60 * 1000; // 90 days
5
+ const MAX_CLIENTS = 1000;
6
+ export function createTokenStore() {
7
+ const pendingAuthorizations = new Map();
8
+ const authorizationCodes = new Map();
9
+ const clients = new Map();
10
+ const verificationCache = new Map();
11
+ const mcpAccessTokens = new Map();
12
+ const mcpRefreshTokens = new Map();
13
+ const evictOldestClient = () => {
14
+ const now = Math.floor(Date.now() / 1000);
15
+ for (const [id, client] of clients) {
16
+ if (now - client.client_id_issued_at > CLIENT_TTL_MS / 1000) {
17
+ clients.delete(id);
18
+ return;
19
+ }
20
+ }
21
+ };
22
+ return {
23
+ storePendingAuth(backlogState, pending) {
24
+ pendingAuthorizations.set(backlogState, pending);
25
+ },
26
+ consumePendingAuth(backlogState) {
27
+ const entry = pendingAuthorizations.get(backlogState);
28
+ if (!entry)
29
+ return undefined;
30
+ pendingAuthorizations.delete(backlogState);
31
+ if (Date.now() - entry.createdAt > PENDING_AUTH_TTL_MS)
32
+ return undefined;
33
+ return entry;
34
+ },
35
+ storeAuthCode(code, entry) {
36
+ authorizationCodes.set(code, entry);
37
+ },
38
+ consumeAuthCode(code) {
39
+ const entry = authorizationCodes.get(code);
40
+ if (!entry)
41
+ return undefined;
42
+ authorizationCodes.delete(code);
43
+ if (Date.now() > entry.expiresAt)
44
+ return undefined;
45
+ return entry;
46
+ },
47
+ getClient(clientId) {
48
+ return clients.get(clientId);
49
+ },
50
+ registerClient(client) {
51
+ if (clients.size >= MAX_CLIENTS) {
52
+ evictOldestClient();
53
+ if (clients.size >= MAX_CLIENTS)
54
+ return false;
55
+ }
56
+ clients.set(client.client_id, client);
57
+ return true;
58
+ },
59
+ getCachedVerification(token) {
60
+ const cached = verificationCache.get(token);
61
+ if (!cached)
62
+ return undefined;
63
+ if (Date.now() > cached.expiresAt) {
64
+ verificationCache.delete(token);
65
+ return undefined;
66
+ }
67
+ return cached.authInfo;
68
+ },
69
+ cacheVerification(token, authInfo, ttlMs) {
70
+ verificationCache.set(token, {
71
+ authInfo,
72
+ expiresAt: Date.now() + ttlMs,
73
+ });
74
+ },
75
+ storeMcpToken(mcpToken, entry) {
76
+ mcpAccessTokens.set(mcpToken, entry);
77
+ },
78
+ getMcpToken(mcpToken) {
79
+ const entry = mcpAccessTokens.get(mcpToken);
80
+ if (!entry)
81
+ return undefined;
82
+ if (Date.now() > entry.expiresAt) {
83
+ mcpAccessTokens.delete(mcpToken);
84
+ return undefined;
85
+ }
86
+ return entry;
87
+ },
88
+ storeMcpRefreshToken(mcpRefreshToken, entry) {
89
+ mcpRefreshTokens.set(mcpRefreshToken, entry);
90
+ },
91
+ consumeMcpRefreshToken(mcpRefreshToken) {
92
+ const entry = mcpRefreshTokens.get(mcpRefreshToken);
93
+ if (!entry)
94
+ return undefined;
95
+ mcpRefreshTokens.delete(mcpRefreshToken);
96
+ if (Date.now() > entry.expiresAt)
97
+ return undefined;
98
+ return entry;
99
+ },
100
+ cleanup() {
101
+ const now = Date.now();
102
+ for (const [key, entry] of pendingAuthorizations) {
103
+ if (now - entry.createdAt > PENDING_AUTH_TTL_MS)
104
+ pendingAuthorizations.delete(key);
105
+ }
106
+ for (const [key, entry] of authorizationCodes) {
107
+ if (now > entry.expiresAt)
108
+ authorizationCodes.delete(key);
109
+ }
110
+ for (const [key, cached] of verificationCache) {
111
+ if (now > cached.expiresAt)
112
+ verificationCache.delete(key);
113
+ }
114
+ for (const [key, entry] of mcpAccessTokens) {
115
+ if (now > entry.expiresAt)
116
+ mcpAccessTokens.delete(key);
117
+ }
118
+ for (const [key, entry] of mcpRefreshTokens) {
119
+ if (now > entry.expiresAt)
120
+ mcpRefreshTokens.delete(key);
121
+ }
122
+ const nowSec = Math.floor(now / 1000);
123
+ for (const [key, client] of clients) {
124
+ if (nowSec - client.client_id_issued_at > CLIENT_TTL_MS / 1000)
125
+ clients.delete(key);
126
+ }
127
+ },
128
+ };
129
+ }
@@ -5,6 +5,7 @@ import { serve } from '@hono/node-server';
5
5
  import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
6
6
  import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
7
7
  import { Hono } from 'hono';
8
+ import { runWithAccessToken } from './auth/backlogAuthContext.js';
8
9
  import { logger } from './utils/logger.js';
9
10
  const jsonRpcError = (code, message) => {
10
11
  return { jsonrpc: '2.0', error: { code, message }, id: null };
@@ -39,7 +40,7 @@ const checkHostHeader = (hostHeader, allowedHostnames) => {
39
40
  ? null
40
41
  : jsonRpcError(-32000, `Invalid Host: ${hostname}`);
41
42
  };
42
- const startNewSession = async (req, body, enableJsonResponse, transports, createServer) => {
43
+ const startNewSession = async (req, body, enableJsonResponse, transports, createServer, authInfo) => {
43
44
  const transport = new WebStandardStreamableHTTPServerTransport({
44
45
  sessionIdGenerator: () => randomUUID(),
45
46
  enableJsonResponse,
@@ -53,10 +54,10 @@ const startNewSession = async (req, body, enableJsonResponse, transports, create
53
54
  delete transports[sid];
54
55
  };
55
56
  await createServer().connect(transport);
56
- return transport.handleRequest(req, { parsedBody: body });
57
+ return transport.handleRequest(req, { parsedBody: body, authInfo });
57
58
  };
58
59
  export const runHttpMcpServer = async (options) => {
59
- const { host, port, path: mcpPath, version, enableJsonResponse, allowedHosts, createServer, } = options;
60
+ const { host, port, path: mcpPath, version, enableJsonResponse, allowedHosts, createServer, oauthConfig, tokenStore, } = options;
60
61
  if ((host === '0.0.0.0' || host === '::') && !allowedHosts?.length) {
61
62
  logger.warn('Binding to all interfaces without --http-allowed-hosts. ' +
62
63
  'Set allowed Host values to prevent DNS rebinding attacks.');
@@ -64,18 +65,35 @@ export const runHttpMcpServer = async (options) => {
64
65
  const app = new Hono();
65
66
  const transports = {};
66
67
  const allowedHostnames = buildAllowedHostnames(host, allowedHosts);
68
+ 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
+ });
76
+ }
67
77
  app.get('/health', (c) => c.json({ status: 'healthy', timestamp: new Date().toISOString(), version }));
78
+ if (oauthEnabled) {
79
+ const { createOAuthRoutes } = await import('./auth/oauthRoutes.js');
80
+ const { createBearerAuthMiddleware } = await import('./auth/bearerAuthMiddleware.js');
81
+ app.route('/', createOAuthRoutes(oauthConfig, tokenStore, mcpPath));
82
+ app.use(mcpPath, createBearerAuthMiddleware(tokenStore, oauthConfig, mcpPath));
83
+ }
68
84
  app.all(mcpPath, async (c) => {
69
85
  const req = c.req.raw;
70
- if (allowedHostnames) {
71
- const hostError = checkHostHeader(req.headers.get('host'), allowedHostnames);
72
- if (hostError)
73
- return c.json(hostError, 403);
74
- }
86
+ const authInfo = oauthEnabled
87
+ ? c.get('authInfo')
88
+ : undefined;
89
+ const accessToken = authInfo?.token;
75
90
  const sessionId = req.headers.get('mcp-session-id');
76
91
  try {
77
92
  if (sessionId && transports[sessionId]) {
78
- return transports[sessionId].handleRequest(req);
93
+ const handleExisting = () => transports[sessionId].handleRequest(req, { authInfo });
94
+ return accessToken
95
+ ? runWithAccessToken(accessToken, handleExisting)
96
+ : handleExisting();
79
97
  }
80
98
  if (sessionId) {
81
99
  return c.json(jsonRpcError(-32000, 'Bad Request: Unknown or expired session ID. Send a new initialize request without mcp-session-id.'), 400);
@@ -92,7 +110,10 @@ export const runHttpMcpServer = async (options) => {
92
110
  const err = jsonRpcError(-32000, 'Bad Request: No mcp-session-id header and body is not an initialize request.');
93
111
  return c.json(Array.isArray(body) ? [err] : err, 400);
94
112
  }
95
- return startNewSession(req, body, enableJsonResponse, transports, createServer);
113
+ const handleNew = () => startNewSession(req, body, enableJsonResponse, transports, createServer, authInfo);
114
+ return accessToken
115
+ ? runWithAccessToken(accessToken, handleNew)
116
+ : handleNew();
96
117
  }
97
118
  catch (error) {
98
119
  logger.error({ err: error }, 'Error handling MCP request');
package/build/index.js CHANGED
@@ -2,14 +2,15 @@
2
2
  // Copyright (c) 2025 Nulab inc.
3
3
  // Licensed under the MIT License.
4
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
- import dotenv from 'dotenv';
6
5
  import { default as env } from 'env-var';
7
6
  import yargs from 'yargs';
8
7
  import { hideBin } from 'yargs/helpers';
8
+ import { getBacklogOAuthConfig } from './auth/backlogOAuthConfig.js';
9
+ import { createTokenStore } from './auth/tokenStore.js';
9
10
  import { createTranslationHelper } from './createTranslationHelper.js';
10
11
  import { createBacklogMcpServer } from './createBacklogMcpServer.js';
11
12
  import { runHttpMcpServer } from './httpMcpServer.js';
12
- import { createBacklogClientRegistry } from './utils/backlogClientRegistry.js';
13
+ import { createBacklogClientRegistry, createOAuthBacklogClientRegistry, } from './utils/backlogClientRegistry.js';
13
14
  import { logger } from './utils/logger.js';
14
15
  import packageJson from '../package.json' with { type: 'json' };
15
16
  const { version } = packageJson;
@@ -18,11 +19,11 @@ const { version } = packageJson;
18
19
  // as an error event on stdout/stderr streams — both must be handled.
19
20
  process.on('SIGPIPE', () => { });
20
21
  process.stdout.on('error', (err) => {
21
- if (err.code !== 'EPIPE')
22
+ if (!('code' in err) || err.code !== 'EPIPE')
22
23
  throw err;
23
24
  });
24
25
  process.stderr.on('error', (err) => {
25
- if (err.code !== 'EPIPE')
26
+ if (!('code' in err) || err.code !== 'EPIPE')
26
27
  throw err;
27
28
  });
28
29
  process.on('uncaughtException', (error) => {
@@ -33,7 +34,13 @@ process.on('unhandledRejection', (reason) => {
33
34
  logger.error({ err: reason }, 'Unhandled rejection');
34
35
  process.exit(1);
35
36
  });
36
- dotenv.config();
37
+ try {
38
+ process.loadEnvFile();
39
+ }
40
+ catch {
41
+ // .env file is optional
42
+ }
43
+ const oauthConfig = getBacklogOAuthConfig();
37
44
  const argv = yargs(hideBin(process.argv))
38
45
  .option('transport', {
39
46
  type: 'string',
@@ -107,8 +114,16 @@ Available toolsets:
107
114
  default: env.get('ENABLE_DYNAMIC_TOOLSETS').default('false').asBool(),
108
115
  })
109
116
  .parseSync();
110
- const clientRegistry = createBacklogClientRegistry();
117
+ const clientRegistry = oauthConfig
118
+ ? createOAuthBacklogClientRegistry(oauthConfig.backlogDomain)
119
+ : createBacklogClientRegistry();
111
120
  const backlog = clientRegistry.createScopedClient();
121
+ const tokenStore = oauthConfig ? createTokenStore() : undefined;
122
+ let cleanupTimer;
123
+ if (tokenStore) {
124
+ cleanupTimer = setInterval(() => tokenStore.cleanup(), 5 * 60 * 1000);
125
+ cleanupTimer.unref();
126
+ }
112
127
  const useFields = argv.optimizeResponse;
113
128
  const transHelper = createTranslationHelper();
114
129
  const maxTokens = argv.maxTokens;
@@ -142,6 +157,9 @@ function normalizeHttpPath(p) {
142
157
  return p;
143
158
  }
144
159
  async function main() {
160
+ if (oauthConfig && argv.transport === 'stdio') {
161
+ logger.warn('OAuth is configured but transport is stdio. OAuth is only available with HTTP transport.');
162
+ }
145
163
  if (argv.transport === 'http') {
146
164
  const httpPath = normalizeHttpPath(argv.httpPath);
147
165
  const allowedHostsRaw = argv.httpAllowedHosts;
@@ -159,6 +177,8 @@ async function main() {
159
177
  enableJsonResponse: argv.httpJsonResponse,
160
178
  allowedHosts,
161
179
  createServer,
180
+ oauthConfig,
181
+ tokenStore,
162
182
  });
163
183
  process.once('SIGINT', () => {
164
184
  void shutdown()
@@ -175,7 +195,10 @@ async function main() {
175
195
  host: argv.httpHost,
176
196
  port: argv.httpPort,
177
197
  path: httpPath,
178
- }, 'Backlog MCP Server listening (Streamable HTTP)');
198
+ oauth: !!oauthConfig,
199
+ }, oauthConfig
200
+ ? 'Backlog MCP Server listening (Streamable HTTP + OAuth)'
201
+ : 'Backlog MCP Server listening (Streamable HTTP)');
179
202
  return;
180
203
  }
181
204
  const server = createServer();
@@ -0,0 +1,30 @@
1
+ import { z } from 'zod';
2
+ import { buildToolSchema } from '../types/tool.js';
3
+ import { UserSchema } from '../types/zod/backlogOutputDefinition.js';
4
+ import { resolveIdOrKey } from '../utils/resolveIdOrKey.js';
5
+ const getProjectUsersSchema = buildToolSchema((t) => ({
6
+ projectId: z
7
+ .number()
8
+ .optional()
9
+ .describe(t('TOOL_GET_PROJECT_USERS_PROJECT_ID', 'The numeric ID of the project (e.g., 12345)')),
10
+ projectKey: z
11
+ .string()
12
+ .optional()
13
+ .describe(t('TOOL_GET_PROJECT_USERS_PROJECT_KEY', "The key of the project (e.g., 'PROJECT')")),
14
+ }));
15
+ export const getProjectUsersTool = (backlog, { t }) => {
16
+ return {
17
+ name: 'get_project_users',
18
+ description: t('TOOL_GET_PROJECT_USERS_DESCRIPTION', 'Returns list of users in a specific project'),
19
+ schema: z.object(getProjectUsersSchema(t)),
20
+ outputSchema: UserSchema,
21
+ importantFields: ['userId', 'name', 'roleType', 'lang'],
22
+ handler: async ({ projectId, projectKey }) => {
23
+ const result = resolveIdOrKey('project', { id: projectId, key: projectKey }, t);
24
+ if (!result.ok) {
25
+ throw result.error;
26
+ }
27
+ return backlog.getProjectUsers(result.value);
28
+ },
29
+ };
30
+ };
@@ -22,6 +22,7 @@ import { getNotificationsCountTool } from './getNotificationsCount.js';
22
22
  import { getPrioritiesTool } from './getPriorities.js';
23
23
  import { getProjectTool } from './getProject.js';
24
24
  import { getProjectListTool } from './getProjectList.js';
25
+ import { getProjectUsersTool } from './getProjectUsers.js';
25
26
  import { getPullRequestTool } from './getPullRequest.js';
26
27
  import { getPullRequestCommentsTool } from './getPullRequestComments.js';
27
28
  import { getPullRequestsTool } from './getPullRequests.js';
@@ -79,6 +80,7 @@ export const allTools = (backlog, helper) => {
79
80
  getProjectListTool(backlog, helper),
80
81
  addProjectTool(backlog, helper),
81
82
  getProjectTool(backlog, helper),
83
+ getProjectUsersTool(backlog, helper),
82
84
  updateProjectTool(backlog, helper),
83
85
  deleteProjectTool(backlog, helper),
84
86
  ],
@@ -1,4 +1,5 @@
1
1
  import { Backlog } from 'backlog-js';
2
+ import { getCurrentAccessToken } from '../auth/backlogAuthContext.js';
2
3
  import { getCurrentOrganization } from './backlogOrganizationContext.js';
3
4
  export function createBacklogClientRegistry(input = {}) {
4
5
  const env = input.env ?? process.env;
@@ -118,6 +119,27 @@ function resolveKnownClient(clients, organization) {
118
119
  }
119
120
  return client;
120
121
  }
122
+ export function createOAuthBacklogClientRegistry(domain) {
123
+ const defaultName = 'default';
124
+ const info = {
125
+ name: defaultName,
126
+ domain,
127
+ isDefault: true,
128
+ };
129
+ const resolveOAuthClient = () => {
130
+ const token = getCurrentAccessToken();
131
+ if (!token) {
132
+ throw new Error('No OAuth access token in current request context');
133
+ }
134
+ return new Backlog({ host: domain, accessToken: token });
135
+ };
136
+ return {
137
+ resolveClient: () => resolveOAuthClient(),
138
+ createScopedClient: () => createBacklogClientProxy(resolveOAuthClient),
139
+ listOrganizations: () => [info],
140
+ getDefaultOrganization: () => defaultName,
141
+ };
142
+ }
121
143
  function createBacklogClientProxy(resolveClient) {
122
144
  return new Proxy({}, {
123
145
  get(_target, prop) {
package/package.json CHANGED
@@ -1,16 +1,61 @@
1
1
  {
2
2
  "name": "backlog-mcp-server",
3
- "version": "0.11.1",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "backlog-mcp-server": "./build/index.js"
7
7
  },
8
+ "engines": {
9
+ "node": ">=22"
10
+ },
11
+ "devEngines": {
12
+ "runtime": {
13
+ "name": "node",
14
+ "version": ">=22",
15
+ "onFail": "warn"
16
+ }
17
+ },
8
18
  "license": "MIT",
9
19
  "repository": {
10
20
  "type": "git",
11
21
  "url": "git+https://github.com/nulab/backlog-mcp-server.git"
12
22
  },
23
+ "files": [
24
+ "build"
25
+ ],
26
+ "dependencies": {
27
+ "@hono/node-server": "^2.0.4",
28
+ "@modelcontextprotocol/sdk": "^1.29.0",
29
+ "backlog-js": "^0.16.0",
30
+ "cosmiconfig": "^9.0.1",
31
+ "env-var": "^7.5.0",
32
+ "graphql": "^16.14.1",
33
+ "hono": "^4.12.25",
34
+ "pino": "^10.3.1",
35
+ "pino-pretty": "^13.1.3",
36
+ "yargs": "^18.0.0",
37
+ "zod": "^3.24.3"
38
+ },
39
+ "devDependencies": {
40
+ "@eslint/js": "^10.0.1",
41
+ "@release-it/conventional-changelog": "^11.0.1",
42
+ "@types/node": "^25.9.2",
43
+ "@types/yargs": "^17.0.35",
44
+ "@typescript-eslint/eslint-plugin": "^8.60.1",
45
+ "@typescript-eslint/parser": "^8.60.1",
46
+ "@typescript-eslint/utils": "^8.60.1",
47
+ "@vitest/coverage-v8": "^4.1.8",
48
+ "eslint": "^10.4.1",
49
+ "eslint-config-prettier": "^10.1.8",
50
+ "eslint-plugin-prettier": "^5.5.6",
51
+ "prettier": "^3.8.3",
52
+ "release-it": "^20.2.0",
53
+ "tsx": "^4.22.4",
54
+ "typescript": "^6.0.3",
55
+ "vitest": "^4.1.8"
56
+ },
13
57
  "scripts": {
58
+ "preinstall": "npx only-allow pnpm",
14
59
  "dev": "tsx src/index.ts",
15
60
  "build": "tsc && chmod 755 build/index.js",
16
61
  "test": "vitest run",
@@ -22,40 +67,5 @@
22
67
  "format:fix": "prettier --write \"**/*.{ts,tsx}\"",
23
68
  "typecheck": "tsc --noEmit",
24
69
  "typecheck:all": "tsc --noEmit --project tsconfig.test.json"
25
- },
26
- "files": [
27
- "build"
28
- ],
29
- "dependencies": {
30
- "@hono/node-server": "^1.19.14",
31
- "@modelcontextprotocol/sdk": "^1.26.0",
32
- "backlog-js": "^0.16.0",
33
- "cosmiconfig": "^9.0.0",
34
- "dotenv": "^16.5.0",
35
- "env-var": "^7.5.0",
36
- "graphql": "^16.11.0",
37
- "hono": "^4.12.18",
38
- "pino": "^9.9.0",
39
- "pino-pretty": "^13.1.1",
40
- "yargs": "^18.0.0",
41
- "zod": "^3.24.3"
42
- },
43
- "devDependencies": {
44
- "@eslint/js": "^9.24.0",
45
- "@release-it/conventional-changelog": "^10.0.2",
46
- "@types/node": "^22.14.1",
47
- "@types/yargs": "^17.0.33",
48
- "@typescript-eslint/eslint-plugin": "^8.30.1",
49
- "@typescript-eslint/parser": "^8.30.1",
50
- "@typescript-eslint/utils": "^8.30.1",
51
- "@vitest/coverage-v8": "^4.0.0",
52
- "eslint": "^9.24.0",
53
- "eslint-config-prettier": "^10.1.2",
54
- "eslint-plugin-prettier": "^5.2.6",
55
- "prettier": "^3.5.3",
56
- "release-it": "^19.0.0",
57
- "tsx": "^4.20.6",
58
- "typescript": "^5.8.3",
59
- "vitest": "^4.0.0"
60
70
  }
61
- }
71
+ }