backlog-mcp-server 0.14.0 → 0.15.1

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.
Files changed (123) hide show
  1. package/README.ja.md +24 -16
  2. package/README.md +26 -16
  3. package/build/auth/backlogAuthContext.d.ts +2 -0
  4. package/build/auth/backlogOAuthClient.d.ts +10 -0
  5. package/build/auth/backlogOAuthClient.js +3 -3
  6. package/build/auth/backlogOAuthConfig.d.ts +9 -0
  7. package/build/auth/bearerAuthMiddleware.d.ts +4 -0
  8. package/build/auth/oauthRoutes.d.ts +4 -0
  9. package/build/auth/oauthRoutes.js +12 -5
  10. package/build/auth/tokenStore.d.ts +62 -0
  11. package/build/backlog/backlogErrorHandler.d.ts +2 -0
  12. package/build/backlog/customFields.d.ts +29 -0
  13. package/build/backlog/parseBacklogAPIError.d.ts +8 -0
  14. package/build/createBacklogMcpServer.d.ts +29 -0
  15. package/build/createBacklogMcpServer.js +18 -6
  16. package/build/createTranslationHelper.d.ts +16 -0
  17. package/build/createTranslationHelper.js +7 -11
  18. package/build/handlers/builders/composeToolHandler.d.ts +31 -0
  19. package/build/handlers/builders/composeToolHandler.js +24 -9
  20. package/build/handlers/transformers/wrapWithErrorHandling.d.ts +2 -0
  21. package/build/handlers/transformers/wrapWithFieldPicking.d.ts +4 -0
  22. package/build/handlers/transformers/wrapWithOrganizationContext.d.ts +3 -0
  23. package/build/handlers/transformers/wrapWithTokenLimit.d.ts +2 -0
  24. package/build/handlers/transformers/wrapWithToolResult.d.ts +6 -0
  25. package/build/httpMcpServer.d.ts +22 -0
  26. package/build/httpMcpServer.js +46 -100
  27. package/build/index.d.ts +2 -0
  28. package/build/index.js +33 -10
  29. package/build/lib.d.ts +25 -0
  30. package/build/lib.js +20 -0
  31. package/build/loadTranslationOverrides.d.ts +17 -0
  32. package/build/loadTranslationOverrides.js +24 -0
  33. package/build/registerTools.d.ts +5 -0
  34. package/build/registerTools.js +7 -6
  35. package/build/tools/addDocument.d.ts +15 -0
  36. package/build/tools/addIssue.d.ts +30 -0
  37. package/build/tools/addIssueComment.d.ts +14 -0
  38. package/build/tools/addProject.d.ts +18 -0
  39. package/build/tools/addPullRequest.d.ts +20 -0
  40. package/build/tools/addPullRequestComment.d.ts +16 -0
  41. package/build/tools/addRelatedIssue.d.ts +12 -0
  42. package/build/tools/addVersionMilestone.d.ts +15 -0
  43. package/build/tools/addWatching.d.ts +11 -0
  44. package/build/tools/addWiki.d.ts +13 -0
  45. package/build/tools/countIssues.d.ts +48 -0
  46. package/build/tools/deleteIssue.d.ts +11 -0
  47. package/build/tools/deleteProject.d.ts +11 -0
  48. package/build/tools/deleteVersion.d.ts +12 -0
  49. package/build/tools/deleteWatching.d.ts +10 -0
  50. package/build/tools/dynamicTools/organizations.d.ts +6 -0
  51. package/build/tools/dynamicTools/toolsets.d.ts +15 -0
  52. package/build/tools/getCategories.d.ts +11 -0
  53. package/build/tools/getCustomFields.d.ts +12 -0
  54. package/build/tools/getDocument.d.ts +10 -0
  55. package/build/tools/getDocumentTree.d.ts +10 -0
  56. package/build/tools/getDocuments.d.ts +11 -0
  57. package/build/tools/getGitRepositories.d.ts +11 -0
  58. package/build/tools/getGitRepository.d.ts +13 -0
  59. package/build/tools/getIssue.d.ts +11 -0
  60. package/build/tools/getIssueComments.d.ts +18 -0
  61. package/build/tools/getIssueTypes.d.ts +11 -0
  62. package/build/tools/getIssues.d.ts +75 -0
  63. package/build/tools/getMyself.d.ts +7 -0
  64. package/build/tools/getNotifications.d.ts +16 -0
  65. package/build/tools/getNotificationsCount.d.ts +11 -0
  66. package/build/tools/getPriorities.d.ts +7 -0
  67. package/build/tools/getProject.d.ts +11 -0
  68. package/build/tools/getProjectList.d.ts +11 -0
  69. package/build/tools/getProjectUsers.d.ts +11 -0
  70. package/build/tools/getPullRequest.d.ts +14 -0
  71. package/build/tools/getPullRequestComments.d.ts +21 -0
  72. package/build/tools/getPullRequests.d.ts +19 -0
  73. package/build/tools/getPullRequestsCount.d.ts +17 -0
  74. package/build/tools/getRelatedIssues.d.ts +11 -0
  75. package/build/tools/getResolutions.d.ts +7 -0
  76. package/build/tools/getSpace.d.ts +7 -0
  77. package/build/tools/getSpaceActivities.d.ts +45 -0
  78. package/build/tools/getUserRecentUpdates.d.ts +46 -0
  79. package/build/tools/getUserStarsCount.d.ts +12 -0
  80. package/build/tools/getUsers.d.ts +7 -0
  81. package/build/tools/getVersionMilestoneList.d.ts +11 -0
  82. package/build/tools/getWatchingListCount.d.ts +10 -0
  83. package/build/tools/getWatchingListItems.d.ts +10 -0
  84. package/build/tools/getWiki.d.ts +10 -0
  85. package/build/tools/getWikiPages.d.ts +12 -0
  86. package/build/tools/getWikisCount.d.ts +11 -0
  87. package/build/tools/markNotificationAsRead.d.ts +13 -0
  88. package/build/tools/markWatchingAsRead.d.ts +13 -0
  89. package/build/tools/removeRelatedIssue.d.ts +12 -0
  90. package/build/tools/resetUnreadNotificationCount.d.ts +7 -0
  91. package/build/tools/shared/customFieldFiltersSchema.d.ts +21 -0
  92. package/build/tools/tools.d.ts +4 -0
  93. package/build/tools/updateIssue.d.ts +34 -0
  94. package/build/tools/updateIssue.js +31 -4
  95. package/build/tools/updateIssueComment.d.ts +13 -0
  96. package/build/tools/updateProject.d.ts +21 -0
  97. package/build/tools/updatePullRequest.d.ts +20 -0
  98. package/build/tools/updatePullRequestComment.d.ts +16 -0
  99. package/build/tools/updateVersionMilestone.d.ts +17 -0
  100. package/build/tools/updateWatching.d.ts +11 -0
  101. package/build/tools/updateWiki.d.ts +13 -0
  102. package/build/types/mcp.d.ts +10 -0
  103. package/build/types/result.d.ts +10 -0
  104. package/build/types/tool.d.ts +24 -0
  105. package/build/types/toolsets.d.ts +16 -0
  106. package/build/types/zod/backlogOutputDefinition.d.ts +3334 -0
  107. package/build/types/zod/backlogOutputDefinition.js +3 -16
  108. package/build/utils/backlogClientRegistry.d.ts +28 -0
  109. package/build/utils/backlogClientRegistry.js +4 -0
  110. package/build/utils/backlogOrganizationContext.d.ts +2 -0
  111. package/build/utils/generateFieldsDescription.d.ts +5 -0
  112. package/build/utils/generateFieldsDescription.js +3 -0
  113. package/build/utils/logger.d.ts +2 -0
  114. package/build/utils/logger.js +27 -14
  115. package/build/utils/resolveIdOrKey.d.ts +18 -0
  116. package/build/utils/runToolSafely.d.ts +6 -0
  117. package/build/utils/tokenCounter.d.ts +1 -0
  118. package/build/utils/toolRegistrar.d.ts +5 -0
  119. package/build/utils/toolsetUtils.d.ts +6 -0
  120. package/build/utils/toolsetUtils.js +0 -20
  121. package/build/utils/wrapServerWithToolRegistry.d.ts +6 -0
  122. package/build/utils/wrapServerWithToolRegistry.js +1 -1
  123. package/package.json +15 -5
@@ -6,27 +6,42 @@ import { wrapWithTokenLimit } from '../transformers/wrapWithTokenLimit.js';
6
6
  import { wrapWithToolResult } from '../transformers/wrapWithToolResult.js';
7
7
  import { z } from 'zod';
8
8
  import { generateFieldsDescription } from '../../utils/generateFieldsDescription.js';
9
+ /**
10
+ * Builds the schema and handler a tool is registered with.
11
+ *
12
+ * The returned schema is a fresh object: the tool definition is never mutated.
13
+ * That matters under the stateless HTTP model, where one toolset group is shared
14
+ * by every per-request server — an in-place extension would be re-applied on
15
+ * every request, against a definition other requests are reading concurrently.
16
+ */
9
17
  export function composeToolHandler(tool, options) {
10
- const { useFields, errorHandler, maxTokens } = options;
18
+ const { useFields, errorHandler, maxTokens, useOrganization = false, } = options;
11
19
  // Step 1: Add `fields` to schema if needed
12
20
  const fieldDesc = useFields
13
21
  ? generateFieldsDescription(tool.outputSchema, tool.importantFields ?? [], tool.name)
14
22
  : undefined;
15
- tool.schema = extendSchema(tool.schema, fieldDesc);
23
+ const schema = extendSchema(tool.schema, fieldDesc, useOrganization);
16
24
  // Step 2: Compose
17
25
  const baseHandler = wrapWithErrorHandling(wrapWithOrganizationContext(tool.handler), errorHandler);
18
- const handler = useFields ? wrapWithFieldPicking(baseHandler) : baseHandler;
19
- return wrapWithToolResult(wrapWithTokenLimit(handler, maxTokens));
26
+ const composed = useFields ? wrapWithFieldPicking(baseHandler) : baseHandler;
27
+ return {
28
+ schema,
29
+ handler: wrapWithToolResult(wrapWithTokenLimit(composed, maxTokens)),
30
+ };
20
31
  }
21
- function extendSchema(schema, desc) {
22
- const extension = {
23
- organization: z
32
+ function extendSchema(schema, desc, withOrganization = false) {
33
+ const extension = {};
34
+ if (withOrganization) {
35
+ extension.organization = z
24
36
  .string()
25
37
  .optional()
26
- .describe('Optional organization name. Use list_organizations to inspect available organizations.'),
27
- };
38
+ .describe('Optional organization name. Use list_organizations to inspect available organizations.');
39
+ }
28
40
  if (desc) {
29
41
  extension.fields = z.string().describe(desc);
30
42
  }
43
+ // zod v4 reworked the ZodObject shape generics, so `extend()`'s result no
44
+ // longer overlaps the declared return type enough for a direct cast. The
45
+ // shape is correct at runtime; route through `unknown` to keep the assertion.
31
46
  return schema.extend(extension);
32
47
  }
@@ -0,0 +1,2 @@
1
+ import { ErrorLike, SafeResult } from '../../types/result.js';
2
+ export declare function wrapWithErrorHandling<I, O>(fn: (input: I) => Promise<O>, onError?: (err: unknown) => ErrorLike): (input: I) => Promise<SafeResult<O>>;
@@ -0,0 +1,4 @@
1
+ import { SafeResult } from '../../types/result.js';
2
+ export declare function wrapWithFieldPicking<I extends {
3
+ fields?: string;
4
+ }, O>(fn: (input: I) => Promise<SafeResult<O>>): (input: I) => Promise<SafeResult<O>>;
@@ -0,0 +1,3 @@
1
+ export declare function wrapWithOrganizationContext<I extends {
2
+ organization?: string;
3
+ }, O>(fn: (input: Omit<I, 'organization'>) => Promise<O>): (input: I) => Promise<O>;
@@ -0,0 +1,2 @@
1
+ import { SafeResult } from '../../types/result.js';
2
+ export declare function wrapWithTokenLimit<I, O>(fn: (input: I) => Promise<SafeResult<O>>, maxTokens: number): (input: I) => Promise<SafeResult<string>>;
@@ -0,0 +1,6 @@
1
+ import { CallToolResult, ServerContext } from '@modelcontextprotocol/server';
2
+ import { SafeResult } from '../../types/result.js';
3
+ /**
4
+ * Convert SafeResult<T> to CallToolResult
5
+ */
6
+ export declare function wrapWithToolResult<I, T>(fn: (input: I) => Promise<SafeResult<string | T>>): (input: I, extra: ServerContext) => Promise<CallToolResult>;
@@ -0,0 +1,22 @@
1
+ import type { Server } from 'node:http';
2
+ import type { BacklogOAuthConfig } from './auth/backlogOAuthConfig.js';
3
+ import type { TokenStore } from './auth/tokenStore.js';
4
+ import type { BacklogMCPServer } from './utils/wrapServerWithToolRegistry.js';
5
+ type RunHttpMcpServerOptions = {
6
+ host: string;
7
+ port: number;
8
+ path: string;
9
+ version: string;
10
+ enableJsonResponse: boolean;
11
+ allowedHosts?: string[];
12
+ allowedOrigins?: string[];
13
+ createServer: () => BacklogMCPServer;
14
+ oauthConfig?: BacklogOAuthConfig;
15
+ tokenStore?: TokenStore;
16
+ };
17
+ type HttpMcpServerHandle = {
18
+ httpServer: Server;
19
+ shutdown: () => Promise<void>;
20
+ };
21
+ export declare const runHttpMcpServer: (options: RunHttpMcpServerOptions) => Promise<HttpMcpServerHandle>;
22
+ export {};
@@ -1,78 +1,40 @@
1
1
  // Copyright (c) 2025 Nulab inc.
2
2
  // Licensed under the MIT License.
3
- import { randomUUID } from 'node:crypto';
4
3
  import { serve } from '@hono/node-server';
5
- import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
6
- import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
4
+ import { hostHeaderValidation, localhostHostValidation, localhostOriginValidation, originValidation, } from '@modelcontextprotocol/hono';
5
+ import { createMcpHandler } from '@modelcontextprotocol/server';
7
6
  import { Hono } from 'hono';
8
7
  import { runWithAccessToken } from './auth/backlogAuthContext.js';
9
8
  import { logger } from './utils/logger.js';
10
- const jsonRpcError = (code, message) => {
11
- return { jsonrpc: '2.0', error: { code, message }, id: null };
12
- };
13
- const bodyContainsInitialize = (body) => {
14
- return (Array.isArray(body) ? body : [body]).some(isInitializeRequest);
15
- };
16
- const buildAllowedHostnames = (host, allowedHosts) => {
17
- if (allowedHosts?.length)
18
- return allowedHosts;
19
- const localhostHosts = ['127.0.0.1', 'localhost', '::1'];
20
- return localhostHosts.includes(host)
21
- ? ['localhost', '127.0.0.1', '[::1]']
22
- : undefined;
23
- };
24
- const parseHostname = (hostHeader) => {
25
- try {
26
- return new URL(`http://${hostHeader}`).hostname;
27
- }
28
- catch {
29
- return null;
30
- }
31
- };
32
- const checkHostHeader = (hostHeader, allowedHostnames) => {
33
- if (!hostHeader)
34
- return jsonRpcError(-32000, 'Missing Host header');
35
- const hostname = parseHostname(hostHeader);
36
- if (hostname === null) {
37
- return jsonRpcError(-32000, `Invalid Host header: ${hostHeader}`);
38
- }
39
- return allowedHostnames.includes(hostname)
40
- ? null
41
- : jsonRpcError(-32000, `Invalid Host: ${hostname}`);
42
- };
43
- const startNewSession = async (req, body, enableJsonResponse, transports, createServer, authInfo) => {
44
- const transport = new WebStandardStreamableHTTPServerTransport({
45
- sessionIdGenerator: () => randomUUID(),
46
- enableJsonResponse,
47
- onsessioninitialized: (sid) => {
48
- transports[sid] = transport;
49
- },
50
- });
51
- transport.onclose = () => {
52
- const sid = transport.sessionId;
53
- if (sid)
54
- delete transports[sid];
55
- };
56
- await createServer().connect(transport);
57
- return transport.handleRequest(req, { parsedBody: body, authInfo });
58
- };
9
+ const LOCALHOST_BINDS = ['127.0.0.1', 'localhost', '::1'];
59
10
  export const runHttpMcpServer = async (options) => {
60
- const { host, port, path: mcpPath, version, enableJsonResponse, allowedHosts, createServer, oauthConfig, tokenStore, } = options;
11
+ const { host, port, path: mcpPath, version, enableJsonResponse, allowedHosts, allowedOrigins, createServer, oauthConfig, tokenStore, } = options;
61
12
  if ((host === '0.0.0.0' || host === '::') && !allowedHosts?.length) {
62
13
  logger.warn('Binding to all interfaces without --http-allowed-hosts. ' +
63
14
  'Set allowed Host values to prevent DNS rebinding attacks.');
64
15
  }
65
16
  const app = new Hono();
66
- const transports = {};
67
- const allowedHostnames = buildAllowedHostnames(host, allowedHosts);
17
+ const isLocalhostBind = LOCALHOST_BINDS.includes(host);
68
18
  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
- });
19
+ // DNS rebinding protection. `Host` is the actual defense: a rebinding page
20
+ // reaches us carrying its own hostname, which the allow list rejects.
21
+ if (allowedHosts?.length) {
22
+ app.use('*', hostHeaderValidation(allowedHosts));
23
+ }
24
+ else if (isLocalhostBind) {
25
+ app.use('*', localhostHostValidation());
26
+ }
27
+ // `Origin` is a separate axis and cannot be derived from the allow list: a
28
+ // browser client's Origin is its own site, never this server's hostname, so
29
+ // validating one against the other would reject every legitimate remote
30
+ // client. Default to the localhost set only for a bare loopback bind (the
31
+ // desktop case, where a drive-by page is the threat); a deployment that
32
+ // declares its hosts opts out unless it names its client origins too.
33
+ if (allowedOrigins?.length) {
34
+ app.use('*', originValidation(allowedOrigins));
35
+ }
36
+ else if (isLocalhostBind && !allowedHosts?.length) {
37
+ app.use('*', localhostOriginValidation());
76
38
  }
77
39
  app.get('/health', (c) => c.json({ status: 'healthy', timestamp: new Date().toISOString(), version }));
78
40
  if (oauthEnabled) {
@@ -81,43 +43,30 @@ export const runHttpMcpServer = async (options) => {
81
43
  app.route('/', createOAuthRoutes(oauthConfig, tokenStore, mcpPath));
82
44
  app.use(mcpPath, createBearerAuthMiddleware(tokenStore, oauthConfig, mcpPath));
83
45
  }
46
+ // MCP 2026-07-28 removed protocol sessions: every request is self-contained,
47
+ // so the handler builds a fresh server per request instead of keeping a
48
+ // transport map keyed by mcp-session-id. `legacy` defaults to 'stateless',
49
+ // which keeps 2025-era clients working over the same endpoint.
50
+ const mcpHandler = createMcpHandler(() => createServer(), {
51
+ responseMode: enableJsonResponse ? 'json' : 'auto',
52
+ onerror: (err) => logger.error({ err }, 'MCP handler error'),
53
+ });
84
54
  app.all(mcpPath, async (c) => {
85
- const req = c.req.raw;
86
- const authInfo = oauthEnabled
87
- ? c.get('authInfo')
88
- : undefined;
55
+ const authInfo = oauthEnabled ? c.get('authInfo') : undefined;
89
56
  const accessToken = authInfo?.token;
90
- const sessionId = req.headers.get('mcp-session-id');
91
57
  try {
92
- if (sessionId && transports[sessionId]) {
93
- const handleExisting = () => transports[sessionId].handleRequest(req, { authInfo });
94
- return accessToken
95
- ? runWithAccessToken(accessToken, handleExisting)
96
- : handleExisting();
97
- }
98
- if (sessionId) {
99
- return c.json(jsonRpcError(-32000, 'Bad Request: Unknown or expired session ID. Send a new initialize request without mcp-session-id.'), 400);
100
- }
101
- if (req.method !== 'POST') {
102
- return c.json(jsonRpcError(-32000, 'Bad Request: No mcp-session-id header.'), 400);
103
- }
104
- const parsed = await req.json().then((body) => ({ body }), () => null);
105
- if (!parsed) {
106
- return c.json(jsonRpcError(-32700, 'Parse error: Invalid JSON'), 400);
107
- }
108
- const { body } = parsed;
109
- if (!bodyContainsInitialize(body)) {
110
- const err = jsonRpcError(-32000, 'Bad Request: No mcp-session-id header and body is not an initialize request.');
111
- return c.json(Array.isArray(body) ? [err] : err, 400);
112
- }
113
- const handleNew = () => startNewSession(req, body, enableJsonResponse, transports, createServer, authInfo);
58
+ const dispatch = () => mcpHandler.fetch(c.req.raw, { authInfo });
114
59
  return accessToken
115
- ? runWithAccessToken(accessToken, handleNew)
116
- : handleNew();
60
+ ? await runWithAccessToken(accessToken, dispatch)
61
+ : await dispatch();
117
62
  }
118
63
  catch (error) {
119
64
  logger.error({ err: error }, 'Error handling MCP request');
120
- return c.json(jsonRpcError(-32603, 'Internal server error'), 500);
65
+ return c.json({
66
+ jsonrpc: '2.0',
67
+ error: { code: -32603, message: 'Internal server error' },
68
+ id: null,
69
+ }, 500);
121
70
  }
122
71
  });
123
72
  const httpServer = await new Promise((resolve, reject) => {
@@ -125,14 +74,11 @@ export const runHttpMcpServer = async (options) => {
125
74
  srv.on('error', reject);
126
75
  });
127
76
  const shutdown = async () => {
128
- for (const sid of Object.keys(transports)) {
129
- try {
130
- await transports[sid].close();
131
- }
132
- catch {
133
- /* ignore */
134
- }
135
- delete transports[sid];
77
+ try {
78
+ await mcpHandler.close();
79
+ }
80
+ catch {
81
+ /* ignore */
136
82
  }
137
83
  httpServer.closeAllConnections();
138
84
  await new Promise((resolve) => httpServer.close(() => resolve()));
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/build/index.js CHANGED
@@ -1,17 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  // Copyright (c) 2025 Nulab inc.
3
3
  // Licensed under the MIT License.
4
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { serveStdio } from '@modelcontextprotocol/server/stdio';
5
5
  import { default as env } from 'env-var';
6
6
  import yargs from 'yargs';
7
7
  import { hideBin } from 'yargs/helpers';
8
8
  import { getBacklogOAuthConfig } from './auth/backlogOAuthConfig.js';
9
9
  import { createTokenStore } from './auth/tokenStore.js';
10
10
  import { createTranslationHelper } from './createTranslationHelper.js';
11
+ import { loadTranslationOverrides } from './loadTranslationOverrides.js';
11
12
  import { createBacklogMcpServer } from './createBacklogMcpServer.js';
12
13
  import { runHttpMcpServer } from './httpMcpServer.js';
13
14
  import { createBacklogClientRegistry, createOAuthBacklogClientRegistry, } from './utils/backlogClientRegistry.js';
14
15
  import { logger } from './utils/logger.js';
16
+ import { buildToolsetGroup } from './utils/toolsetUtils.js';
15
17
  import packageJson from '../package.json' with { type: 'json' };
16
18
  const { version } = packageJson;
17
19
  // Swallow SIGPIPE and stdout/stderr EPIPE so the process doesn't crash when a
@@ -75,6 +77,11 @@ const argv = yargs(hideBin(process.argv))
75
77
  type: 'string',
76
78
  describe: 'Comma-separated allowed Host header values when binding to all interfaces (recommended with 0.0.0.0)',
77
79
  default: env.get('MCP_HTTP_ALLOWED_HOSTS').default('').asString(),
80
+ })
81
+ .option('http-allowed-origins', {
82
+ type: 'string',
83
+ describe: 'Comma-separated allowed Origin header hostnames for browser-based clients. Defaults to the localhost set on a loopback bind, and to no Origin check otherwise',
84
+ default: env.get('MCP_HTTP_ALLOWED_ORIGINS').default('').asString(),
78
85
  })
79
86
  .option('max-tokens', {
80
87
  type: 'number',
@@ -125,15 +132,26 @@ if (tokenStore) {
125
132
  cleanupTimer.unref();
126
133
  }
127
134
  const useFields = argv.optimizeResponse;
128
- const transHelper = createTranslationHelper();
135
+ const transHelper = createTranslationHelper(loadTranslationOverrides());
129
136
  const maxTokens = argv.maxTokens;
130
137
  const prefix = argv.prefix;
131
138
  const enabledToolsets = argv.dynamicToolsets
132
139
  ? argv.enableToolsets.filter((a) => a !== 'all')
133
140
  : argv.enableToolsets;
134
- const mcpOption = { useFields: useFields, maxTokens, prefix };
141
+ const mcpOption = {
142
+ useFields: useFields,
143
+ maxTokens,
144
+ prefix,
145
+ useOrganization: clientRegistry.isMultiOrganization,
146
+ };
147
+ // Built once and shared by every server the factory produces. `enable_toolset`
148
+ // mutates this group, and the stateless HTTP model discards its server after
149
+ // each request — so a per-server group would lose the enablement immediately.
150
+ // Sharing it makes toolset state process-wide, which is the only scope left now
151
+ // that the protocol has no sessions.
152
+ const sharedToolsetGroup = buildToolsetGroup(backlog, transHelper, enabledToolsets);
135
153
  // Factory: creates a fresh MCP server with all tools registered.
136
- // Used once for stdio; one fresh instance per HTTP session for Streamable HTTP.
154
+ // Used once per stdio connection; one fresh instance per HTTP request.
137
155
  const createServer = () => createBacklogMcpServer({
138
156
  version,
139
157
  useFields,
@@ -143,6 +161,7 @@ const createServer = () => createBacklogMcpServer({
143
161
  enabledToolsets,
144
162
  mcpOption,
145
163
  dynamicToolsets: argv.dynamicToolsets,
164
+ toolsetGroup: sharedToolsetGroup,
146
165
  });
147
166
  if (argv.exportTranslations) {
148
167
  // Translation keys are only recorded once a tool asks for them, so build a
@@ -175,13 +194,14 @@ async function main() {
175
194
  }
176
195
  if (argv.transport === 'http') {
177
196
  const httpPath = normalizeHttpPath(argv.httpPath);
178
- const allowedHostsRaw = argv.httpAllowedHosts;
179
- const allowedHosts = allowedHostsRaw && allowedHostsRaw.trim().length > 0
180
- ? allowedHostsRaw
197
+ const parseHostList = (raw) => raw && raw.trim().length > 0
198
+ ? raw
181
199
  .split(',')
182
200
  .map((h) => h.trim())
183
201
  .filter(Boolean)
184
202
  : undefined;
203
+ const allowedHosts = parseHostList(argv.httpAllowedHosts);
204
+ const allowedOrigins = parseHostList(argv.httpAllowedOrigins);
185
205
  const { shutdown } = await runHttpMcpServer({
186
206
  host: argv.httpHost,
187
207
  port: argv.httpPort,
@@ -189,6 +209,7 @@ async function main() {
189
209
  version,
190
210
  enableJsonResponse: argv.httpJsonResponse,
191
211
  allowedHosts,
212
+ allowedOrigins,
192
213
  createServer,
193
214
  oauthConfig,
194
215
  tokenStore,
@@ -214,9 +235,11 @@ async function main() {
214
235
  : 'Backlog MCP Server listening (Streamable HTTP)');
215
236
  return;
216
237
  }
217
- const server = createServer();
218
- const transport = new StdioServerTransport();
219
- await server.connect(transport);
238
+ // serveStdio owns the era decision for the connection: it pins one instance
239
+ // from the factory, serving both 2026-07-28 and 2025-era clients.
240
+ serveStdio(createServer, {
241
+ onerror: (err) => logger.error({ err }, 'MCP stdio error'),
242
+ });
220
243
  logger.info('Backlog MCP Server running on stdio');
221
244
  }
222
245
  main().catch((error) => {
package/build/lib.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Library entry point.
3
+ *
4
+ * `src/index.ts` is the CLI: it parses argv, reads config files and starts a
5
+ * transport. Consumers that only want the tool layer — to host the same tools on
6
+ * a different runtime, for example — cannot import it without running all of that.
7
+ * This module exposes the pieces needed to build a server, and nothing that runs
8
+ * on import.
9
+ *
10
+ * Nothing reachable from here may touch a Node built-in. `loadTranslationOverrides`
11
+ * is the counter-example worth remembering: it reads the override file from disk,
12
+ * so it belongs to the CLI and is deliberately absent below. Consumers on other
13
+ * runtimes pass their own overrides to `createTranslationHelper`.
14
+ */
15
+ export { allTools } from './tools/tools.js';
16
+ export { composeToolHandler } from './handlers/builders/composeToolHandler.js';
17
+ export { createTranslationHelper } from './createTranslationHelper.js';
18
+ export { backlogErrorHandler } from './backlog/backlogErrorHandler.js';
19
+ export { buildToolSchema } from './types/tool.js';
20
+ export { isErrorLike } from './types/result.js';
21
+ export type { ComposeOptions } from './handlers/builders/composeToolHandler.js';
22
+ export type { TranslationHelper } from './createTranslationHelper.js';
23
+ export type { ToolDefinition, DynamicToolDefinition, ToolRegistrar, } from './types/tool.js';
24
+ export type { Toolset, ToolsetGroup, DynamicToolset, DynamicToolsetGroup, } from './types/toolsets.js';
25
+ export type { ErrorLike, SafeResult } from './types/result.js';
package/build/lib.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Library entry point.
3
+ *
4
+ * `src/index.ts` is the CLI: it parses argv, reads config files and starts a
5
+ * transport. Consumers that only want the tool layer — to host the same tools on
6
+ * a different runtime, for example — cannot import it without running all of that.
7
+ * This module exposes the pieces needed to build a server, and nothing that runs
8
+ * on import.
9
+ *
10
+ * Nothing reachable from here may touch a Node built-in. `loadTranslationOverrides`
11
+ * is the counter-example worth remembering: it reads the override file from disk,
12
+ * so it belongs to the CLI and is deliberately absent below. Consumers on other
13
+ * runtimes pass their own overrides to `createTranslationHelper`.
14
+ */
15
+ export { allTools } from './tools/tools.js';
16
+ export { composeToolHandler } from './handlers/builders/composeToolHandler.js';
17
+ export { createTranslationHelper } from './createTranslationHelper.js';
18
+ export { backlogErrorHandler } from './backlog/backlogErrorHandler.js';
19
+ export { buildToolSchema } from './types/tool.js';
20
+ export { isErrorLike } from './types/result.js';
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Reads description overrides from a `.backlog-mcp-serverrc` file (`.json`,
3
+ * `.yaml` or `.yml`) in the user's home directory.
4
+ *
5
+ * Node-only, and kept separate from `createTranslationHelper` for that reason:
6
+ * cosmiconfig walks the filesystem and the default search path is the home
7
+ * directory. The CLI calls this and hands the result to the helper.
8
+ *
9
+ * The file is user-authored, so its contents are unknown: anything that is not a
10
+ * string is dropped here rather than passed on. Every override ends up in a tool
11
+ * description, and a number or an array there would produce an invalid
12
+ * `tools/list` payload.
13
+ */
14
+ export declare function loadTranslationOverrides(options?: {
15
+ configName?: string;
16
+ searchDir?: string;
17
+ }): Record<string, string>;
@@ -0,0 +1,24 @@
1
+ import { cosmiconfigSync } from 'cosmiconfig';
2
+ import os from 'os';
3
+ /**
4
+ * Reads description overrides from a `.backlog-mcp-serverrc` file (`.json`,
5
+ * `.yaml` or `.yml`) in the user's home directory.
6
+ *
7
+ * Node-only, and kept separate from `createTranslationHelper` for that reason:
8
+ * cosmiconfig walks the filesystem and the default search path is the home
9
+ * directory. The CLI calls this and hands the result to the helper.
10
+ *
11
+ * The file is user-authored, so its contents are unknown: anything that is not a
12
+ * string is dropped here rather than passed on. Every override ends up in a tool
13
+ * description, and a number or an array there would produce an invalid
14
+ * `tools/list` payload.
15
+ */
16
+ export function loadTranslationOverrides(options) {
17
+ const explorer = cosmiconfigSync(options?.configName ?? 'backlog-mcp-server');
18
+ const searchPath = options?.searchDir ?? os.homedir();
19
+ const config = explorer.search(searchPath)?.config;
20
+ if (typeof config !== 'object' || config === null || Array.isArray(config)) {
21
+ return {};
22
+ }
23
+ return Object.fromEntries(Object.entries(config).filter(([, value]) => typeof value === 'string'));
24
+ }
@@ -0,0 +1,5 @@
1
+ import { MCPOptions } from './types/mcp.js';
2
+ import { DynamicToolsetGroup, ToolsetGroup } from './types/toolsets.js';
3
+ import { BacklogMCPServer } from './utils/wrapServerWithToolRegistry.js';
4
+ export declare function registerTools(server: BacklogMCPServer, toolsetGroup: ToolsetGroup, options: MCPOptions): void;
5
+ export declare function registerDynamicTools(server: BacklogMCPServer, dynamicToolsetGroup: DynamicToolsetGroup, prefix: string): void;
@@ -1,17 +1,18 @@
1
1
  import { backlogErrorHandler } from './backlog/backlogErrorHandler.js';
2
2
  import { composeToolHandler } from './handlers/builders/composeToolHandler.js';
3
3
  export function registerTools(server, toolsetGroup, options) {
4
- const { useFields, maxTokens, prefix } = options;
4
+ const { useFields, maxTokens, prefix, useOrganization } = options;
5
5
  registerToolsets({
6
6
  server,
7
7
  toolsetGroup,
8
8
  prefix,
9
- handlerStrategy: (tool) =>
9
+ prepareTool: (tool) =>
10
10
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
11
  composeToolHandler(tool, {
12
12
  useFields,
13
13
  errorHandler: backlogErrorHandler,
14
14
  maxTokens,
15
+ useOrganization,
15
16
  }),
16
17
  });
17
18
  }
@@ -20,18 +21,18 @@ export function registerDynamicTools(server, dynamicToolsetGroup, prefix) {
20
21
  server,
21
22
  toolsetGroup: dynamicToolsetGroup,
22
23
  prefix,
23
- handlerStrategy: (tool) => tool.handler,
24
+ prepareTool: (tool) => ({ schema: tool.schema, handler: tool.handler }),
24
25
  });
25
26
  }
26
- function registerToolsets({ server, toolsetGroup, prefix, handlerStrategy, }) {
27
+ function registerToolsets({ server, toolsetGroup, prefix, prepareTool, }) {
27
28
  for (const toolset of toolsetGroup.toolsets) {
28
29
  if (!toolset.enabled) {
29
30
  continue;
30
31
  }
31
32
  for (const tool of toolset.tools) {
32
33
  const toolNameWithPrefix = `${prefix}${tool.name}`;
33
- const handler = handlerStrategy(tool);
34
- server.registerOnce(toolNameWithPrefix, tool.description, tool.schema.shape, handler);
34
+ const { schema, handler } = prepareTool(tool);
35
+ server.registerOnce(toolNameWithPrefix, tool.description, schema, handler);
35
36
  }
36
37
  }
37
38
  }
@@ -0,0 +1,15 @@
1
+ import { Backlog } from 'backlog-js';
2
+ import { z } from 'zod';
3
+ import { TranslationHelper } from '../createTranslationHelper.js';
4
+ import { DocumentItemSchema } from '../types/zod/backlogOutputDefinition.js';
5
+ import { ToolDefinition } from '../types/tool.js';
6
+ declare const addDocumentSchema: (t: TranslationHelper["t"]) => {
7
+ projectId: z.ZodNumber;
8
+ title: z.ZodOptional<z.ZodString>;
9
+ content: z.ZodOptional<z.ZodString>;
10
+ emoji: z.ZodOptional<z.ZodString>;
11
+ parentId: z.ZodOptional<z.ZodString>;
12
+ addLast: z.ZodOptional<z.ZodBoolean>;
13
+ };
14
+ export declare const addDocumentTool: (backlog: Backlog, { t }: TranslationHelper) => ToolDefinition<ReturnType<typeof addDocumentSchema>, (typeof DocumentItemSchema)["shape"]>;
15
+ export {};
@@ -0,0 +1,30 @@
1
+ import { Backlog } from 'backlog-js';
2
+ import { z } from 'zod';
3
+ import { TranslationHelper } from '../createTranslationHelper.js';
4
+ import { IssueSchema } from '../types/zod/backlogOutputDefinition.js';
5
+ import { ToolDefinition } from '../types/tool.js';
6
+ declare const addIssueSchema: (t: TranslationHelper["t"]) => {
7
+ projectId: z.ZodNumber;
8
+ summary: z.ZodString;
9
+ issueTypeId: z.ZodNumber;
10
+ priorityId: z.ZodNumber;
11
+ description: z.ZodOptional<z.ZodString>;
12
+ startDate: z.ZodOptional<z.ZodString>;
13
+ dueDate: z.ZodOptional<z.ZodString>;
14
+ estimatedHours: z.ZodOptional<z.ZodNumber>;
15
+ actualHours: z.ZodOptional<z.ZodNumber>;
16
+ categoryId: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
17
+ versionId: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
18
+ milestoneId: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
19
+ assigneeId: z.ZodOptional<z.ZodNumber>;
20
+ notifiedUserId: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
21
+ attachmentId: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
22
+ parentIssueId: z.ZodOptional<z.ZodNumber>;
23
+ customFields: z.ZodOptional<z.ZodArray<z.ZodObject<{
24
+ id: z.ZodNumber;
25
+ value: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodArray<z.ZodString>, z.ZodArray<z.ZodNumber>]>>;
26
+ otherValue: z.ZodOptional<z.ZodString>;
27
+ }, z.core.$strip>>>;
28
+ };
29
+ export declare const addIssueTool: (backlog: Backlog, { t }: TranslationHelper) => ToolDefinition<ReturnType<typeof addIssueSchema>, (typeof IssueSchema)["shape"]>;
30
+ export {};
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod';
2
+ import { Backlog } from 'backlog-js';
3
+ import { ToolDefinition } from '../types/tool.js';
4
+ import { TranslationHelper } from '../createTranslationHelper.js';
5
+ import { IssueCommentSchema } from '../types/zod/backlogOutputDefinition.js';
6
+ declare const addIssueCommentSchema: (t: TranslationHelper["t"]) => {
7
+ issueId: z.ZodOptional<z.ZodNumber>;
8
+ issueKey: z.ZodOptional<z.ZodString>;
9
+ content: z.ZodString;
10
+ notifiedUserId: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
11
+ attachmentId: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
12
+ };
13
+ export declare const addIssueCommentTool: (backlog: Backlog, { t }: TranslationHelper) => ToolDefinition<ReturnType<typeof addIssueCommentSchema>, (typeof IssueCommentSchema)["shape"]>;
14
+ export {};
@@ -0,0 +1,18 @@
1
+ import { z } from 'zod';
2
+ import { Backlog } from 'backlog-js';
3
+ import { ToolDefinition } from '../types/tool.js';
4
+ import { TranslationHelper } from '../createTranslationHelper.js';
5
+ import { ProjectSchema } from '../types/zod/backlogOutputDefinition.js';
6
+ declare const addProjectSchema: (t: TranslationHelper["t"]) => {
7
+ name: z.ZodString;
8
+ key: z.ZodString;
9
+ chartEnabled: z.ZodOptional<z.ZodBoolean>;
10
+ subtaskingEnabled: z.ZodOptional<z.ZodBoolean>;
11
+ projectLeaderCanEditProjectLeader: z.ZodOptional<z.ZodBoolean>;
12
+ textFormattingRule: z.ZodOptional<z.ZodEnum<{
13
+ backlog: "backlog";
14
+ markdown: "markdown";
15
+ }>>;
16
+ };
17
+ export declare const addProjectTool: (backlog: Backlog, { t }: TranslationHelper) => ToolDefinition<ReturnType<typeof addProjectSchema>, (typeof ProjectSchema)["shape"]>;
18
+ export {};
@@ -0,0 +1,20 @@
1
+ import { z } from 'zod';
2
+ import { Backlog } from 'backlog-js';
3
+ import { ToolDefinition } from '../types/tool.js';
4
+ import { TranslationHelper } from '../createTranslationHelper.js';
5
+ import { PullRequestSchema } from '../types/zod/backlogOutputDefinition.js';
6
+ declare const addPullRequestSchema: (t: TranslationHelper["t"]) => {
7
+ projectId: z.ZodOptional<z.ZodNumber>;
8
+ projectKey: z.ZodOptional<z.ZodString>;
9
+ repoId: z.ZodOptional<z.ZodNumber>;
10
+ repoName: z.ZodOptional<z.ZodString>;
11
+ summary: z.ZodString;
12
+ description: z.ZodString;
13
+ base: z.ZodString;
14
+ branch: z.ZodString;
15
+ issueId: z.ZodOptional<z.ZodNumber>;
16
+ assigneeId: z.ZodOptional<z.ZodNumber>;
17
+ notifiedUserId: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
18
+ };
19
+ export declare const addPullRequestTool: (backlog: Backlog, { t }: TranslationHelper) => ToolDefinition<ReturnType<typeof addPullRequestSchema>, (typeof PullRequestSchema)["shape"]>;
20
+ export {};