backlog-mcp-server 0.20.1 → 0.20.3

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.md CHANGED
@@ -307,6 +307,7 @@ Tools for managing issues, their comments, and related items like priorities, ca
307
307
  - `remove_related_issue`: Removes the relation between an issue and a related issue.
308
308
  - `get_priorities`: Returns list of priorities.
309
309
  - `get_categories`: Returns list of categories for a project.
310
+ - `add_category`: Creates a new category for a project.
310
311
  - `get_custom_fields`: Returns list of custom fields for a project.
311
312
  - `get_issue_types`: Returns list of issue types for a project.
312
313
  - `get_resolutions`: Returns list of issue resolutions.
@@ -1,5 +1,29 @@
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
+ }
3
27
  export declare function buildBacklogAuthorizationUrl(config: BacklogOAuthConfig, redirectUri: string, state: string): string;
4
28
  export declare function exchangeBacklogCode(config: BacklogOAuthConfig, code: string, redirectUri: string): Promise<BacklogTokenData>;
5
29
  export declare function refreshBacklogToken(config: BacklogOAuthConfig, refreshToken: string): Promise<BacklogTokenData>;
@@ -1,5 +1,56 @@
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
+ }
3
54
  export function buildBacklogAuthorizationUrl(config, redirectUri, state) {
4
55
  const params = new URLSearchParams({
5
56
  response_type: 'code',
@@ -35,23 +86,35 @@ export async function refreshBacklogToken(config, refreshToken) {
35
86
  client_secret: config.clientSecret,
36
87
  refresh_token: refreshToken,
37
88
  });
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
- });
89
+ let response;
90
+ try {
91
+ response = await fetch(`https://${config.backlogDomain}/api/v2/oauth2/token`, {
92
+ method: 'POST',
93
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
94
+ body: params.toString(),
95
+ });
96
+ }
97
+ catch (err) {
98
+ throw new BacklogTokenError(`Could not reach Backlog to refresh the token: ${String(err)}`);
99
+ }
43
100
  if (!response.ok) {
44
101
  const text = await response.text();
45
- throw new Error(`Backlog token refresh failed (${response.status}): ${text}`);
102
+ throw new BacklogTokenError(`Backlog token refresh failed (${response.status}): ${text}`, response.status, readOAuthErrorCode(text));
46
103
  }
47
104
  return await response.json();
48
105
  }
49
106
  export async function verifyBacklogToken(domain, accessToken) {
50
- const response = await fetch(`https://${domain}/api/v2/users/myself`, {
51
- headers: { Authorization: `Bearer ${accessToken}` },
52
- });
107
+ let response;
108
+ try {
109
+ response = await fetch(`https://${domain}/api/v2/users/myself`, {
110
+ headers: { Authorization: `Bearer ${accessToken}` },
111
+ });
112
+ }
113
+ catch (err) {
114
+ throw new BacklogTokenError(`Could not reach Backlog to verify the token: ${String(err)}`);
115
+ }
53
116
  if (!response.ok) {
54
- throw new Error(`Backlog token verification failed (${response.status})`);
117
+ throw new BacklogTokenError(`Backlog token verification failed (${response.status})`, response.status);
55
118
  }
56
119
  return await response.json();
57
120
  }
@@ -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 { BacklogTokenError, 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,27 @@ 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. A Backlog outage answered with 401 would send
49
+ // every connected client through the whole authorization flow, and the
50
+ // token that flow produced would fail verification just the same.
51
+ const rejected = err instanceof BacklogTokenError &&
52
+ (err.status === 401 || err.status === 403);
53
+ if (!rejected) {
54
+ logger.error({ err }, 'Could not verify the bearer token with Backlog');
55
+ c.header('Retry-After', '30');
56
+ return c.json({
57
+ error: 'temporarily_unavailable',
58
+ error_description: 'Could not verify the token with Backlog',
59
+ }, 503);
60
+ }
61
+ // A rejection here is the same fact `onAuthError` below acts on — the
62
+ // stored Backlog token is spent — so it gets the same treatment.
63
+ // Dropping the entry is what makes this recoverable: the client's next
64
+ // request fails the `getMcpToken` check above, and it reaches for its
65
+ // refresh token instead of replaying a credential that cannot work.
66
+ logger.warn({ err, clientId: tokenEntry.clientId }, 'Backlog rejected the stored access token during verification; revoking the MCP token');
67
+ store.revokeMcpToken(mcpToken);
48
68
  return unauthorized('Token verification failed');
49
69
  }
50
70
  }
@@ -2,10 +2,12 @@
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 { BacklogTokenError, buildBacklogAuthorizationUrl, exchangeBacklogCode, 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
9
+ /** Seconds left until `expiresAt`, floored at zero so it is never negative. */
10
+ const remainingSeconds = (expiresAt) => Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
9
11
  const LOCALHOST_HOSTS = ['localhost', '127.0.0.1', '[::1]'];
10
12
  const SUPPORTED_AUTH_METHODS = ['client_secret_post', 'none'];
11
13
  function verifyPkce(codeVerifier, codeChallenge) {
@@ -278,6 +280,9 @@ export function createOAuthRoutes(config, store, mcpPath) {
278
280
  store.storeAuthCode(mcpCode, {
279
281
  mcpClientId: pending.mcpClientId,
280
282
  backlogTokens,
283
+ // Resolved here, against the response just received, rather than at
284
+ // `/token` where `expires_in` would be counted from the wrong instant.
285
+ backlogAccessTokenExpiresAt: Date.now() + backlogTokens.expires_in * 1000,
281
286
  codeChallenge: pending.codeChallenge,
282
287
  redirectUri: pending.redirectUri,
283
288
  resource: pending.resource,
@@ -336,7 +341,7 @@ export function createOAuthRoutes(config, store, mcpPath) {
336
341
  store.storeMcpToken(mcpAccessToken, {
337
342
  backlogAccessToken: entry.backlogTokens.access_token,
338
343
  clientId,
339
- expiresAt: Date.now() + entry.backlogTokens.expires_in * 1000,
344
+ expiresAt: entry.backlogAccessTokenExpiresAt,
340
345
  });
341
346
  store.storeMcpRefreshToken(mcpRefreshToken, {
342
347
  backlogRefreshToken: entry.backlogTokens.refresh_token,
@@ -346,7 +351,10 @@ export function createOAuthRoutes(config, store, mcpPath) {
346
351
  return c.json({
347
352
  access_token: mcpAccessToken,
348
353
  token_type: 'bearer',
349
- expires_in: entry.backlogTokens.expires_in,
354
+ // What is left, not what Backlog reported at `/callback`. A client
355
+ // uses this to decide when to refresh, so handing it the original
356
+ // duration would have it wait past the real expiry.
357
+ expires_in: remainingSeconds(entry.backlogAccessTokenExpiresAt),
350
358
  refresh_token: mcpRefreshToken,
351
359
  });
352
360
  }
@@ -384,6 +392,34 @@ export function createOAuthRoutes(config, store, mcpPath) {
384
392
  });
385
393
  }
386
394
  catch (err) {
395
+ // `invalid_grant` is Backlog stating the grant is gone: revoked from
396
+ // the user's settings, or a refresh token it no longer recognises.
397
+ // That is the one answer that moves the client to a fresh
398
+ // authorization. Reported as 503 the same failure asks it to back off
399
+ // and retry a grant that can never come back, so it retries on a
400
+ // schedule forever. The consumed entry stays consumed in this branch:
401
+ // what it holds is a refresh token Backlog has already disowned, and
402
+ // keeping it until its TTL lapses only hands the next attempt the same
403
+ // dead credential.
404
+ //
405
+ // The code is read from the body rather than inferred from the status,
406
+ // because the status cannot separate the two rejections the token
407
+ // endpoint makes: `invalid_client` rejects *this server's* credentials,
408
+ // which is the operator's misconfiguration and not the client's grant,
409
+ // and re-authorizing would fail at the same wall. A bare 400 with no
410
+ // readable code is still treated as a dead grant — that is what the
411
+ // status means on this endpoint when nothing more specific is said.
412
+ //
413
+ // Everything else leaves the grant's fate unknown — unreachable, a
414
+ // timeout, a 5xx, a rejected client secret — so the entry goes back and
415
+ // the client is told to retry.
416
+ const grantIsGone = err instanceof BacklogTokenError &&
417
+ (err.errorCode === 'invalid_grant' ||
418
+ (err.status === 400 && err.errorCode === undefined));
419
+ if (grantIsGone) {
420
+ logger.warn({ err, clientId }, 'Backlog no longer recognizes the refresh grant; the client must authorize again');
421
+ return c.json(oauthError('invalid_grant', 'Backlog no longer recognizes this grant. A new authorization is required.'), 400);
422
+ }
387
423
  logger.error({ err }, 'Failed to refresh Backlog token');
388
424
  store.storeMcpRefreshToken(refreshToken, refreshEntry);
389
425
  return c.json(oauthError('server_error', 'Failed to refresh upstream token'), 503);
@@ -28,6 +28,17 @@ type PendingAuthorization = {
28
28
  type AuthCodeEntry = {
29
29
  mcpClientId: string;
30
30
  backlogTokens: BacklogTokenData;
31
+ /**
32
+ * When the Backlog access token in `backlogTokens` actually expires, in
33
+ * epoch milliseconds, resolved at `/callback`.
34
+ *
35
+ * `backlogTokens.expires_in` is a duration counted from the moment Backlog
36
+ * answered, and this entry then sits here until the client redeems the code
37
+ * — up to `AUTH_CODE_TTL_MS`. Re-basing that duration at `/token` would put
38
+ * the expiry later than the real one by however long the redemption took,
39
+ * leaving a window where this server treats a spent Backlog token as live.
40
+ */
41
+ backlogAccessTokenExpiresAt: number;
31
42
  codeChallenge: string;
32
43
  redirectUri: string;
33
44
  resource?: string;
@@ -0,0 +1,12 @@
1
+ import { z } from 'zod';
2
+ import type { Entity } from 'backlog-js';
3
+ import { Backlog } from 'backlog-js';
4
+ import { ToolDefinition } from '../types/tool.js';
5
+ import { DescriptionHelper } from '../createDescriptionHelper.js';
6
+ declare const addCategorySchema: (t: DescriptionHelper['t']) => {
7
+ projectId: z.ZodOptional<z.ZodNumber>;
8
+ projectKey: z.ZodOptional<z.ZodString>;
9
+ name: z.ZodString;
10
+ };
11
+ export declare const addCategoryTool: (backlog: Backlog, { t }: DescriptionHelper) => ToolDefinition<ReturnType<typeof addCategorySchema>, Entity.Project.Category>;
12
+ export {};
@@ -0,0 +1,39 @@
1
+ import { z } from 'zod';
2
+ import { buildToolSchema } from '../types/tool.js';
3
+ import { outputFields } from '../types/outputFields.js';
4
+ import { resolveIdOrKey } from '../utils/resolveIdOrKey.js';
5
+ const addCategorySchema = buildToolSchema((t) => ({
6
+ projectId: z
7
+ .number()
8
+ .optional()
9
+ .describe(t('TOOL_ADD_CATEGORY_PROJECT_ID', 'The numeric ID of the project (e.g., 12345)')),
10
+ projectKey: z
11
+ .string()
12
+ .optional()
13
+ .describe(t('TOOL_ADD_CATEGORY_PROJECT_KEY', "The key of the project (e.g., 'PROJECT')")),
14
+ name: z
15
+ .string()
16
+ .describe(t('TOOL_ADD_CATEGORY_NAME', 'The name of the category')),
17
+ }));
18
+ export const addCategoryTool = (backlog, { t }) => {
19
+ return {
20
+ name: 'add_category',
21
+ description: t('TOOL_ADD_CATEGORY_DESCRIPTION', 'Creates a new category for a project'),
22
+ schema: z.object(addCategorySchema(t)),
23
+ importantFields: ['id', 'projectId', 'name'],
24
+ returnsList: false,
25
+ outputFields: outputFields()([
26
+ 'id',
27
+ 'projectId',
28
+ 'name',
29
+ 'displayOrder',
30
+ ]),
31
+ handler: async ({ projectId, projectKey, ...params }) => {
32
+ const result = resolveIdOrKey('project', { id: projectId, key: projectKey }, t);
33
+ if (!result.ok) {
34
+ throw result.error;
35
+ }
36
+ return backlog.postCategories(result.value, params);
37
+ },
38
+ };
39
+ };
@@ -7,6 +7,7 @@ import { addWikiTool } from './addWiki.js';
7
7
  import { updateWikiTool } from './updateWiki.js';
8
8
  import { countIssuesTool } from './countIssues.js';
9
9
  import { deleteIssueTool } from './deleteIssue.js';
10
+ import { addCategoryTool } from './addCategory.js';
10
11
  import { getCategoriesTool } from './getCategories.js';
11
12
  import { getCustomFieldsTool } from './getCustomFields.js';
12
13
  import { getGitRepositoriesTool } from './getGitRepositories.js';
@@ -108,6 +109,7 @@ export const allTools = (backlog, helper) => {
108
109
  removeRelatedIssueTool(backlog, helper),
109
110
  getPrioritiesTool(backlog, helper),
110
111
  getCategoriesTool(backlog, helper),
112
+ addCategoryTool(backlog, helper),
111
113
  getCustomFieldsTool(backlog, helper),
112
114
  getIssueTypesTool(backlog, helper),
113
115
  getResolutionsTool(backlog, helper),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backlog-mcp-server",
3
- "version": "0.20.1",
3
+ "version": "0.20.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "backlog-mcp-server": "./build/index.js"
@@ -42,20 +42,20 @@
42
42
  "js-yaml": "^5.4.1",
43
43
  "pino": "^10.3.1",
44
44
  "yargs": "^18.1.0",
45
- "zod": "^4.4.3"
45
+ "zod": "^4.5.4"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/js-yaml": "^4.0.9",
49
- "@types/node": "^26.4.0",
49
+ "@types/node": "^26.4.1",
50
50
  "@types/yargs": "^17.0.35",
51
- "@vitest/coverage-v8": "^4.1.11",
52
- "oxfmt": "^0.65.0",
53
- "oxlint": "^1.80.0",
51
+ "@vitest/coverage-v8": "^5.0.0",
52
+ "oxfmt": "^0.66.0",
53
+ "oxlint": "^1.81.0",
54
54
  "oxlint-tsgolint": "^7.0.2001",
55
55
  "pino-pretty": "^13.1.3",
56
- "tsx": "^4.23.12",
56
+ "tsx": "^4.23.13",
57
57
  "typescript": "^7.0.2",
58
- "vitest": "^4.1.11"
58
+ "vitest": "^5.0.0"
59
59
  },
60
60
  "scripts": {
61
61
  "preinstall": "npx only-allow pnpm",