backlog-mcp-server 0.11.0 → 0.12.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 +50 -0
- package/README.md +50 -0
- 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/handlers/builders/composeToolHandler.js +2 -4
- package/build/httpMcpServer.js +31 -10
- package/build/index.js +25 -8
- package/build/tools/addIssue.js +7 -2
- package/build/tools/updateIssue.js +7 -2
- package/build/utils/backlogClientRegistry.js +22 -0
- package/package.json +4 -4
package/README.ja.md
CHANGED
|
@@ -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エージェントが利用できるツールをより細かく制御し、コンテキストサイズを削減するのに役立ちます。
|
package/README.md
CHANGED
|
@@ -163,6 +163,56 @@ Environment variables (CLI flags override when both are set):
|
|
|
163
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
|
+
> - OAuth mode currently supports a single Backlog organization. It is not compatible with the multi-organization configuration.
|
|
214
|
+
> - Client registrations and tokens are stored in memory and will be lost on server restart.
|
|
215
|
+
|
|
166
216
|
## Tool Configuration
|
|
167
217
|
|
|
168
218
|
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.
|
|
@@ -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
|
+
}
|
|
@@ -14,10 +14,8 @@ export function composeToolHandler(tool, options) {
|
|
|
14
14
|
: undefined;
|
|
15
15
|
tool.schema = extendSchema(tool.schema, fieldDesc);
|
|
16
16
|
// Step 2: Compose
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
handler = wrapWithFieldPicking(handler);
|
|
20
|
-
}
|
|
17
|
+
const baseHandler = wrapWithErrorHandling(wrapWithOrganizationContext(tool.handler), errorHandler);
|
|
18
|
+
const handler = useFields ? wrapWithFieldPicking(baseHandler) : baseHandler;
|
|
21
19
|
return wrapWithToolResult(wrapWithTokenLimit(handler, maxTokens));
|
|
22
20
|
}
|
|
23
21
|
function extendSchema(schema, desc) {
|
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
|
@@ -6,10 +6,12 @@ import dotenv from 'dotenv';
|
|
|
6
6
|
import { default as env } from 'env-var';
|
|
7
7
|
import yargs from 'yargs';
|
|
8
8
|
import { hideBin } from 'yargs/helpers';
|
|
9
|
+
import { getBacklogOAuthConfig } from './auth/backlogOAuthConfig.js';
|
|
10
|
+
import { createTokenStore } from './auth/tokenStore.js';
|
|
9
11
|
import { createTranslationHelper } from './createTranslationHelper.js';
|
|
10
12
|
import { createBacklogMcpServer } from './createBacklogMcpServer.js';
|
|
11
13
|
import { runHttpMcpServer } from './httpMcpServer.js';
|
|
12
|
-
import { createBacklogClientRegistry } from './utils/backlogClientRegistry.js';
|
|
14
|
+
import { createBacklogClientRegistry, createOAuthBacklogClientRegistry, } from './utils/backlogClientRegistry.js';
|
|
13
15
|
import { logger } from './utils/logger.js';
|
|
14
16
|
import packageJson from '../package.json' with { type: 'json' };
|
|
15
17
|
const { version } = packageJson;
|
|
@@ -34,6 +36,7 @@ process.on('unhandledRejection', (reason) => {
|
|
|
34
36
|
process.exit(1);
|
|
35
37
|
});
|
|
36
38
|
dotenv.config();
|
|
39
|
+
const oauthConfig = getBacklogOAuthConfig();
|
|
37
40
|
const argv = yargs(hideBin(process.argv))
|
|
38
41
|
.option('transport', {
|
|
39
42
|
type: 'string',
|
|
@@ -107,17 +110,23 @@ Available toolsets:
|
|
|
107
110
|
default: env.get('ENABLE_DYNAMIC_TOOLSETS').default('false').asBool(),
|
|
108
111
|
})
|
|
109
112
|
.parseSync();
|
|
110
|
-
const clientRegistry =
|
|
113
|
+
const clientRegistry = oauthConfig
|
|
114
|
+
? createOAuthBacklogClientRegistry(oauthConfig.backlogDomain)
|
|
115
|
+
: createBacklogClientRegistry();
|
|
111
116
|
const backlog = clientRegistry.createScopedClient();
|
|
117
|
+
const tokenStore = oauthConfig ? createTokenStore() : undefined;
|
|
118
|
+
let cleanupTimer;
|
|
119
|
+
if (tokenStore) {
|
|
120
|
+
cleanupTimer = setInterval(() => tokenStore.cleanup(), 5 * 60 * 1000);
|
|
121
|
+
cleanupTimer.unref();
|
|
122
|
+
}
|
|
112
123
|
const useFields = argv.optimizeResponse;
|
|
113
124
|
const transHelper = createTranslationHelper();
|
|
114
125
|
const maxTokens = argv.maxTokens;
|
|
115
126
|
const prefix = argv.prefix;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
enabledToolsets = enabledToolsets.filter((a) => a !== 'all');
|
|
120
|
-
}
|
|
127
|
+
const enabledToolsets = argv.dynamicToolsets
|
|
128
|
+
? argv.enableToolsets.filter((a) => a !== 'all')
|
|
129
|
+
: argv.enableToolsets;
|
|
121
130
|
const mcpOption = { useFields: useFields, maxTokens, prefix };
|
|
122
131
|
// Factory: creates a fresh MCP server with all tools registered.
|
|
123
132
|
// Used once for stdio; one fresh instance per HTTP session for Streamable HTTP.
|
|
@@ -144,6 +153,9 @@ function normalizeHttpPath(p) {
|
|
|
144
153
|
return p;
|
|
145
154
|
}
|
|
146
155
|
async function main() {
|
|
156
|
+
if (oauthConfig && argv.transport === 'stdio') {
|
|
157
|
+
logger.warn('OAuth is configured but transport is stdio. OAuth is only available with HTTP transport.');
|
|
158
|
+
}
|
|
147
159
|
if (argv.transport === 'http') {
|
|
148
160
|
const httpPath = normalizeHttpPath(argv.httpPath);
|
|
149
161
|
const allowedHostsRaw = argv.httpAllowedHosts;
|
|
@@ -161,6 +173,8 @@ async function main() {
|
|
|
161
173
|
enableJsonResponse: argv.httpJsonResponse,
|
|
162
174
|
allowedHosts,
|
|
163
175
|
createServer,
|
|
176
|
+
oauthConfig,
|
|
177
|
+
tokenStore,
|
|
164
178
|
});
|
|
165
179
|
process.once('SIGINT', () => {
|
|
166
180
|
void shutdown()
|
|
@@ -177,7 +191,10 @@ async function main() {
|
|
|
177
191
|
host: argv.httpHost,
|
|
178
192
|
port: argv.httpPort,
|
|
179
193
|
path: httpPath,
|
|
180
|
-
|
|
194
|
+
oauth: !!oauthConfig,
|
|
195
|
+
}, oauthConfig
|
|
196
|
+
? 'Backlog MCP Server listening (Streamable HTTP + OAuth)'
|
|
197
|
+
: 'Backlog MCP Server listening (Streamable HTTP)');
|
|
181
198
|
return;
|
|
182
199
|
}
|
|
183
200
|
const server = createServer();
|
package/build/tools/addIssue.js
CHANGED
|
@@ -67,9 +67,14 @@ const addIssueSchema = buildToolSchema((t) => ({
|
|
|
67
67
|
.number()
|
|
68
68
|
.describe(t('TOOL_ADD_ISSUE_CUSTOM_FIELD_ID', 'The ID of the custom field (e.g., 12345)')),
|
|
69
69
|
value: z
|
|
70
|
-
.union([
|
|
70
|
+
.union([
|
|
71
|
+
z.string(),
|
|
72
|
+
z.number(),
|
|
73
|
+
z.array(z.string()),
|
|
74
|
+
z.array(z.number()),
|
|
75
|
+
])
|
|
71
76
|
.optional()
|
|
72
|
-
.describe('
|
|
77
|
+
.describe(t('TOOL_ADD_ISSUE_CUSTOM_FIELD_VALUE', 'Value of the custom field. For text/date fields, provide a string. For numeric fields, provide a number. For list fields, provide an array of strings or numbers.')),
|
|
73
78
|
otherValue: z
|
|
74
79
|
.string()
|
|
75
80
|
.optional()
|
|
@@ -86,9 +86,14 @@ const updateIssueSchema = buildToolSchema((t) => ({
|
|
|
86
86
|
.number()
|
|
87
87
|
.describe(t('TOOL_UPDATE_ISSUE_CUSTOM_FIELD_ID', 'The ID of the custom field (e.g., 12345)')),
|
|
88
88
|
value: z
|
|
89
|
-
.union([
|
|
89
|
+
.union([
|
|
90
|
+
z.string(),
|
|
91
|
+
z.number(),
|
|
92
|
+
z.array(z.string()),
|
|
93
|
+
z.array(z.number()),
|
|
94
|
+
])
|
|
90
95
|
.optional()
|
|
91
|
-
.describe('
|
|
96
|
+
.describe(t('TOOL_UPDATE_ISSUE_CUSTOM_FIELD_VALUE', 'Value of the custom field. For text/date fields, provide a string. For numeric fields, provide a number. For list fields, provide an array of strings or numbers.')),
|
|
92
97
|
otherValue: z
|
|
93
98
|
.string()
|
|
94
99
|
.optional()
|
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backlog-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"backlog-mcp-server": "./build/index.js"
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"dotenv": "^16.5.0",
|
|
35
35
|
"env-var": "^7.5.0",
|
|
36
36
|
"graphql": "^16.11.0",
|
|
37
|
-
"hono": "^4.12.
|
|
37
|
+
"hono": "^4.12.23",
|
|
38
38
|
"pino": "^9.9.0",
|
|
39
39
|
"pino-pretty": "^13.1.1",
|
|
40
40
|
"yargs": "^18.0.0",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@eslint/js": "^9.24.0",
|
|
45
|
-
"@release-it/conventional-changelog": "^
|
|
45
|
+
"@release-it/conventional-changelog": "^11.0.0",
|
|
46
46
|
"@types/node": "^22.14.1",
|
|
47
47
|
"@types/yargs": "^17.0.33",
|
|
48
48
|
"@typescript-eslint/eslint-plugin": "^8.30.1",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"eslint-config-prettier": "^10.1.2",
|
|
54
54
|
"eslint-plugin-prettier": "^5.2.6",
|
|
55
55
|
"prettier": "^3.5.3",
|
|
56
|
-
"release-it": "^
|
|
56
|
+
"release-it": "^20.0.1",
|
|
57
57
|
"tsx": "^4.20.6",
|
|
58
58
|
"typescript": "^5.8.3",
|
|
59
59
|
"vitest": "^4.0.0"
|