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 +54 -4
- package/README.md +64 -12
- package/build/auth/backlogAuthContext.js +10 -0
- package/build/auth/backlogOAuthClient.js +57 -0
- package/build/auth/backlogOAuthConfig.js +25 -0
- package/build/auth/bearerAuthMiddleware.js +58 -0
- package/build/auth/oauthRoutes.js +336 -0
- package/build/auth/tokenStore.js +129 -0
- package/build/httpMcpServer.js +31 -10
- package/build/index.js +30 -7
- package/build/tools/getProjectUsers.js +30 -0
- package/build/tools/tools.js +2 -0
- package/build/utils/backlogClientRegistry.js +22 -0
- package/package.json +47 -37
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
158
|
-
|
|
|
159
|
-
| `MCP_TRANSPORT`
|
|
160
|
-
| `MCP_HTTP_HOST`
|
|
161
|
-
| `MCP_HTTP_PORT`
|
|
162
|
-
| `MCP_HTTP_PATH`
|
|
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
|
-
|
|
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
|
+
}
|
package/build/httpMcpServer.js
CHANGED
|
@@ -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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
};
|
package/build/tools/tools.js
CHANGED
|
@@ -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.
|
|
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
|
+
}
|