backlog-mcp-server 0.20.2 → 0.20.4
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.
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
import type { BacklogOAuthConfig } from './backlogOAuthConfig.js';
|
|
2
2
|
import type { BacklogTokenData } from './tokenStore.js';
|
|
3
|
+
/**
|
|
4
|
+
* A failed Backlog token call, carrying what the caller needs to decide what to
|
|
5
|
+
* tell the client.
|
|
6
|
+
*
|
|
7
|
+
* Whether Backlog rejected the credential or could not be reached decides
|
|
8
|
+
* whether the client should authorize again or back off and retry. Folded into
|
|
9
|
+
* a message string the two are the same exception, and the caller is left
|
|
10
|
+
* parsing prose to tell them apart.
|
|
11
|
+
*
|
|
12
|
+
* `status` is absent when the request never produced a response — DNS failure,
|
|
13
|
+
* refused connection, timeout — which is unambiguously the retry case, so "no
|
|
14
|
+
* status" reads as "not a rejection" without having to guess.
|
|
15
|
+
*
|
|
16
|
+
* `errorCode` is the OAuth error code from the response body, which is where
|
|
17
|
+
* RFC 6749 §5.2 puts the reason. It is the more precise of the two: the status
|
|
18
|
+
* alone cannot separate `invalid_grant` from `invalid_client`, and those ask
|
|
19
|
+
* the caller for opposite behaviour. Absent when the body is not an OAuth error
|
|
20
|
+
* object, which is every response outside the token endpoint.
|
|
21
|
+
*/
|
|
22
|
+
export declare class BacklogTokenError extends Error {
|
|
23
|
+
readonly status?: number | undefined;
|
|
24
|
+
readonly errorCode?: string | undefined;
|
|
25
|
+
constructor(message: string, status?: number | undefined, errorCode?: string | undefined);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Whether the failure is Backlog stating the grant is gone.
|
|
29
|
+
*
|
|
30
|
+
* The one answer that means the client should stop retrying and start a fresh
|
|
31
|
+
* authorization. Everything else leaves the grant's fate unknown, and a client
|
|
32
|
+
* told to re-authorize on a transient failure throws away a grant that was
|
|
33
|
+
* still alive.
|
|
34
|
+
*
|
|
35
|
+
* Read from `errorCode` rather than inferred from the status, because the
|
|
36
|
+
* status cannot separate the two rejections a token endpoint makes:
|
|
37
|
+
* `invalid_client` rejects the *server's* own credentials, which is the
|
|
38
|
+
* operator's misconfiguration and not the client's grant — re-authorizing would
|
|
39
|
+
* fail at the same wall. A bare 400 with no readable code is still a dead
|
|
40
|
+
* grant: that is what the status means on this endpoint when nothing more
|
|
41
|
+
* specific is said.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isGrantGone(err: unknown): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Whether Backlog rejected the credential, as opposed to failing to answer.
|
|
46
|
+
*
|
|
47
|
+
* Only a rejection means the caller should authenticate again. An outage
|
|
48
|
+
* treated as a rejection sends every connected client through the whole
|
|
49
|
+
* authorization flow, and the credential that flow produces fails the same way
|
|
50
|
+
* — after the client has already lost the one it had.
|
|
51
|
+
*/
|
|
52
|
+
export declare function isTokenRejected(err: unknown): boolean;
|
|
3
53
|
export declare function buildBacklogAuthorizationUrl(config: BacklogOAuthConfig, redirectUri: string, state: string): string;
|
|
4
54
|
export declare function exchangeBacklogCode(config: BacklogOAuthConfig, code: string, redirectUri: string): Promise<BacklogTokenData>;
|
|
5
55
|
export declare function refreshBacklogToken(config: BacklogOAuthConfig, refreshToken: string): Promise<BacklogTokenData>;
|
|
@@ -1,5 +1,89 @@
|
|
|
1
1
|
// Copyright (c) 2025 Nulab inc.
|
|
2
2
|
// Licensed under the MIT License.
|
|
3
|
+
/**
|
|
4
|
+
* A failed Backlog token call, carrying what the caller needs to decide what to
|
|
5
|
+
* tell the client.
|
|
6
|
+
*
|
|
7
|
+
* Whether Backlog rejected the credential or could not be reached decides
|
|
8
|
+
* whether the client should authorize again or back off and retry. Folded into
|
|
9
|
+
* a message string the two are the same exception, and the caller is left
|
|
10
|
+
* parsing prose to tell them apart.
|
|
11
|
+
*
|
|
12
|
+
* `status` is absent when the request never produced a response — DNS failure,
|
|
13
|
+
* refused connection, timeout — which is unambiguously the retry case, so "no
|
|
14
|
+
* status" reads as "not a rejection" without having to guess.
|
|
15
|
+
*
|
|
16
|
+
* `errorCode` is the OAuth error code from the response body, which is where
|
|
17
|
+
* RFC 6749 §5.2 puts the reason. It is the more precise of the two: the status
|
|
18
|
+
* alone cannot separate `invalid_grant` from `invalid_client`, and those ask
|
|
19
|
+
* the caller for opposite behaviour. Absent when the body is not an OAuth error
|
|
20
|
+
* object, which is every response outside the token endpoint.
|
|
21
|
+
*/
|
|
22
|
+
export class BacklogTokenError extends Error {
|
|
23
|
+
status;
|
|
24
|
+
errorCode;
|
|
25
|
+
constructor(message, status, errorCode) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.status = status;
|
|
28
|
+
this.errorCode = errorCode;
|
|
29
|
+
this.name = 'BacklogTokenError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The `error` field of an RFC 6749 §5.2 error response, if the body is one.
|
|
34
|
+
*
|
|
35
|
+
* Best effort by design: a proxy or a WAF can answer the token endpoint with
|
|
36
|
+
* HTML, and a body that is not an OAuth error object simply carries no code.
|
|
37
|
+
*/
|
|
38
|
+
function readOAuthErrorCode(body) {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(body);
|
|
41
|
+
if (typeof parsed === 'object' &&
|
|
42
|
+
parsed !== null &&
|
|
43
|
+
'error' in parsed &&
|
|
44
|
+
typeof parsed.error === 'string') {
|
|
45
|
+
return parsed.error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Not JSON. Nothing to read, and nothing worth reporting either: the
|
|
50
|
+
// status and the raw text are already in the message.
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Whether the failure is Backlog stating the grant is gone.
|
|
56
|
+
*
|
|
57
|
+
* The one answer that means the client should stop retrying and start a fresh
|
|
58
|
+
* authorization. Everything else leaves the grant's fate unknown, and a client
|
|
59
|
+
* told to re-authorize on a transient failure throws away a grant that was
|
|
60
|
+
* still alive.
|
|
61
|
+
*
|
|
62
|
+
* Read from `errorCode` rather than inferred from the status, because the
|
|
63
|
+
* status cannot separate the two rejections a token endpoint makes:
|
|
64
|
+
* `invalid_client` rejects the *server's* own credentials, which is the
|
|
65
|
+
* operator's misconfiguration and not the client's grant — re-authorizing would
|
|
66
|
+
* fail at the same wall. A bare 400 with no readable code is still a dead
|
|
67
|
+
* grant: that is what the status means on this endpoint when nothing more
|
|
68
|
+
* specific is said.
|
|
69
|
+
*/
|
|
70
|
+
export function isGrantGone(err) {
|
|
71
|
+
return (err instanceof BacklogTokenError &&
|
|
72
|
+
(err.errorCode === 'invalid_grant' ||
|
|
73
|
+
(err.status === 400 && err.errorCode === undefined)));
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Whether Backlog rejected the credential, as opposed to failing to answer.
|
|
77
|
+
*
|
|
78
|
+
* Only a rejection means the caller should authenticate again. An outage
|
|
79
|
+
* treated as a rejection sends every connected client through the whole
|
|
80
|
+
* authorization flow, and the credential that flow produces fails the same way
|
|
81
|
+
* — after the client has already lost the one it had.
|
|
82
|
+
*/
|
|
83
|
+
export function isTokenRejected(err) {
|
|
84
|
+
return (err instanceof BacklogTokenError &&
|
|
85
|
+
(err.status === 401 || err.status === 403));
|
|
86
|
+
}
|
|
3
87
|
export function buildBacklogAuthorizationUrl(config, redirectUri, state) {
|
|
4
88
|
const params = new URLSearchParams({
|
|
5
89
|
response_type: 'code',
|
|
@@ -35,23 +119,35 @@ export async function refreshBacklogToken(config, refreshToken) {
|
|
|
35
119
|
client_secret: config.clientSecret,
|
|
36
120
|
refresh_token: refreshToken,
|
|
37
121
|
});
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
122
|
+
let response;
|
|
123
|
+
try {
|
|
124
|
+
response = await fetch(`https://${config.backlogDomain}/api/v2/oauth2/token`, {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
127
|
+
body: params.toString(),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
throw new BacklogTokenError(`Could not reach Backlog to refresh the token: ${String(err)}`);
|
|
132
|
+
}
|
|
43
133
|
if (!response.ok) {
|
|
44
134
|
const text = await response.text();
|
|
45
|
-
throw new
|
|
135
|
+
throw new BacklogTokenError(`Backlog token refresh failed (${response.status}): ${text}`, response.status, readOAuthErrorCode(text));
|
|
46
136
|
}
|
|
47
137
|
return await response.json();
|
|
48
138
|
}
|
|
49
139
|
export async function verifyBacklogToken(domain, accessToken) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
140
|
+
let response;
|
|
141
|
+
try {
|
|
142
|
+
response = await fetch(`https://${domain}/api/v2/users/myself`, {
|
|
143
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
throw new BacklogTokenError(`Could not reach Backlog to verify the token: ${String(err)}`);
|
|
148
|
+
}
|
|
53
149
|
if (!response.ok) {
|
|
54
|
-
throw new
|
|
150
|
+
throw new BacklogTokenError(`Backlog token verification failed (${response.status})`, response.status);
|
|
55
151
|
}
|
|
56
152
|
return await response.json();
|
|
57
153
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Copyright (c) 2025 Nulab inc.
|
|
2
2
|
// Licensed under the MIT License.
|
|
3
|
-
import { verifyBacklogToken } from './backlogOAuthClient.js';
|
|
3
|
+
import { isTokenRejected, verifyBacklogToken } from './backlogOAuthClient.js';
|
|
4
4
|
import { hasBacklogAuthErrorBeenReported, runWithAccessToken, } from './backlogAuthContext.js';
|
|
5
5
|
import { logger } from '../utils/logger.js';
|
|
6
6
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
@@ -44,7 +44,24 @@ export function createBearerAuthMiddleware(store, config, mcpPath) {
|
|
|
44
44
|
store.cacheVerification(mcpToken, authInfo, CACHE_TTL_MS);
|
|
45
45
|
}
|
|
46
46
|
catch (err) {
|
|
47
|
-
|
|
47
|
+
// Only Backlog rejecting the token means this client should
|
|
48
|
+
// authenticate again; `isTokenRejected` holds why the distinction
|
|
49
|
+
// matters.
|
|
50
|
+
if (!isTokenRejected(err)) {
|
|
51
|
+
logger.error({ err }, 'Could not verify the bearer token with Backlog');
|
|
52
|
+
c.header('Retry-After', '30');
|
|
53
|
+
return c.json({
|
|
54
|
+
error: 'temporarily_unavailable',
|
|
55
|
+
error_description: 'Could not verify the token with Backlog',
|
|
56
|
+
}, 503);
|
|
57
|
+
}
|
|
58
|
+
// A rejection here is the same fact `onAuthError` below acts on — the
|
|
59
|
+
// stored Backlog token is spent — so it gets the same treatment.
|
|
60
|
+
// Dropping the entry is what makes this recoverable: the client's next
|
|
61
|
+
// request fails the `getMcpToken` check above, and it reaches for its
|
|
62
|
+
// refresh token instead of replaying a credential that cannot work.
|
|
63
|
+
logger.warn({ err, clientId: tokenEntry.clientId }, 'Backlog rejected the stored access token during verification; revoking the MCP token');
|
|
64
|
+
store.revokeMcpToken(mcpToken);
|
|
48
65
|
return unauthorized('Token verification failed');
|
|
49
66
|
}
|
|
50
67
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Licensed under the MIT License.
|
|
3
3
|
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
|
4
4
|
import { Hono } from 'hono';
|
|
5
|
-
import { buildBacklogAuthorizationUrl, exchangeBacklogCode, refreshBacklogToken, } from './backlogOAuthClient.js';
|
|
5
|
+
import { buildBacklogAuthorizationUrl, exchangeBacklogCode, isGrantGone, refreshBacklogToken, } from './backlogOAuthClient.js';
|
|
6
6
|
import { logger } from '../utils/logger.js';
|
|
7
7
|
const AUTH_CODE_TTL_MS = 10 * 60 * 1000;
|
|
8
8
|
const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
@@ -392,6 +392,19 @@ export function createOAuthRoutes(config, store, mcpPath) {
|
|
|
392
392
|
});
|
|
393
393
|
}
|
|
394
394
|
catch (err) {
|
|
395
|
+
// A dead grant is the one failure the client can act on, and
|
|
396
|
+
// `isGrantGone` holds the reasoning for which failures those are. The
|
|
397
|
+
// consumed entry stays consumed here: what it holds is a refresh token
|
|
398
|
+
// Backlog has already disowned, and keeping it until its TTL lapses
|
|
399
|
+
// only hands the next attempt the same dead credential.
|
|
400
|
+
//
|
|
401
|
+
// Everything else leaves the grant's fate unknown — unreachable, a
|
|
402
|
+
// timeout, a 5xx, a rejected client secret — so the entry goes back and
|
|
403
|
+
// the client is told to retry.
|
|
404
|
+
if (isGrantGone(err)) {
|
|
405
|
+
logger.warn({ err, clientId }, 'Backlog no longer recognizes the refresh grant; the client must authorize again');
|
|
406
|
+
return c.json(oauthError('invalid_grant', 'Backlog no longer recognizes this grant. A new authorization is required.'), 400);
|
|
407
|
+
}
|
|
395
408
|
logger.error({ err }, 'Failed to refresh Backlog token');
|
|
396
409
|
store.storeMcpRefreshToken(refreshToken, refreshEntry);
|
|
397
410
|
return c.json(oauthError('server_error', 'Failed to refresh upstream token'), 503);
|
package/build/lib.d.ts
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* is the counter-example worth remembering: it reads the override file from disk,
|
|
12
12
|
* so it belongs to the CLI and is deliberately absent below. Consumers on other
|
|
13
13
|
* runtimes pass their own overrides to `createDescriptionHelper`.
|
|
14
|
+
*
|
|
15
|
+
* The OAuth exports are the token calls and the two predicates that say what a
|
|
16
|
+
* failure means. The routes and the middleware are absent: they are built
|
|
17
|
+
* against this package's synchronous `TokenStore` and Hono, so a consumer on
|
|
18
|
+
* its own storage cannot reuse them. The judgement travels, the plumbing does
|
|
19
|
+
* not.
|
|
14
20
|
*/
|
|
15
21
|
export { allTools } from './tools/tools.js';
|
|
16
22
|
export { composeToolHandler } from './handlers/builders/composeToolHandler.js';
|
|
@@ -19,9 +25,12 @@ export { createDescriptionHelper } from './createDescriptionHelper.js';
|
|
|
19
25
|
export { backlogErrorHandler } from './backlog/backlogErrorHandler.js';
|
|
20
26
|
export { buildToolSchema } from './types/tool.js';
|
|
21
27
|
export { isErrorLike } from './types/result.js';
|
|
28
|
+
export { BacklogTokenError, buildBacklogAuthorizationUrl, exchangeBacklogCode, isGrantGone, isTokenRejected, refreshBacklogToken, verifyBacklogToken, } from './auth/backlogOAuthClient.js';
|
|
22
29
|
export type { ComposeOptions } from './handlers/builders/composeToolHandler.js';
|
|
23
30
|
export type { ComposeNativeContentOptions } from './handlers/builders/composeNativeContentToolHandler.js';
|
|
24
31
|
export type { DescriptionHelper } from './createDescriptionHelper.js';
|
|
25
32
|
export type { ToolDefinition, NativeContentToolDefinition, } from './types/tool.js';
|
|
26
33
|
export type { Toolset, ToolsetGroup } from './types/toolsets.js';
|
|
27
34
|
export type { ErrorLike, SafeResult } from './types/result.js';
|
|
35
|
+
export type { BacklogOAuthConfig } from './auth/backlogOAuthConfig.js';
|
|
36
|
+
export type { BacklogTokenData } from './auth/tokenStore.js';
|
package/build/lib.js
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* is the counter-example worth remembering: it reads the override file from disk,
|
|
12
12
|
* so it belongs to the CLI and is deliberately absent below. Consumers on other
|
|
13
13
|
* runtimes pass their own overrides to `createDescriptionHelper`.
|
|
14
|
+
*
|
|
15
|
+
* The OAuth exports are the token calls and the two predicates that say what a
|
|
16
|
+
* failure means. The routes and the middleware are absent: they are built
|
|
17
|
+
* against this package's synchronous `TokenStore` and Hono, so a consumer on
|
|
18
|
+
* its own storage cannot reuse them. The judgement travels, the plumbing does
|
|
19
|
+
* not.
|
|
14
20
|
*/
|
|
15
21
|
export { allTools } from './tools/tools.js';
|
|
16
22
|
export { composeToolHandler } from './handlers/builders/composeToolHandler.js';
|
|
@@ -19,3 +25,4 @@ export { createDescriptionHelper } from './createDescriptionHelper.js';
|
|
|
19
25
|
export { backlogErrorHandler } from './backlog/backlogErrorHandler.js';
|
|
20
26
|
export { buildToolSchema } from './types/tool.js';
|
|
21
27
|
export { isErrorLike } from './types/result.js';
|
|
28
|
+
export { BacklogTokenError, buildBacklogAuthorizationUrl, exchangeBacklogCode, isGrantGone, isTokenRejected, refreshBacklogToken, verifyBacklogToken, } from './auth/backlogOAuthClient.js';
|