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
- 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
- });
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 Error(`Backlog token refresh failed (${response.status}): ${text}`);
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
- const response = await fetch(`https://${domain}/api/v2/users/myself`, {
51
- headers: { Authorization: `Bearer ${accessToken}` },
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 Error(`Backlog token verification failed (${response.status})`);
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
- logger.warn({ err }, 'Bearer token verification failed');
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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backlog-mcp-server",
3
- "version": "0.20.2",
3
+ "version": "0.20.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "backlog-mcp-server": "./build/index.js"