backlog-mcp-server 0.10.0 → 0.11.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.
package/README.md CHANGED
@@ -139,6 +139,30 @@ npm run dev
139
139
  }
140
140
  ```
141
141
 
142
+ ### HTTP transport (Streamable HTTP)
143
+
144
+ By default the server uses **stdio**. To run the [MCP Streamable HTTP](https://modelcontextprotocol.io/) transport instead (JSON-RPC over HTTP, same tools as stdio), start with `--transport http` or set `MCP_TRANSPORT=http`.
145
+
146
+ ```bash
147
+ npm run build
148
+ MCP_TRANSPORT=http MCP_HTTP_PORT=3333 node build/index.js
149
+ ```
150
+
151
+ - **Endpoint:** `POST`, `GET`, and `DELETE` on `http://<host>:<port><path>` (default path `/mcp`).
152
+ - **Session:** After `initialize`, clients must send the `mcp-session-id` header on later requests (as returned by the server).
153
+ - **Security:** Default bind is `127.0.0.1`. Do not expose the HTTP port to untrusted networks without authentication and TLS; it allows full use of your Backlog API key via MCP tools.
154
+
155
+ Environment variables (CLI flags override when both are set):
156
+
157
+ | Variable | Description |
158
+ | -------- | ----------- |
159
+ | `MCP_TRANSPORT` | `stdio` (default) or `http` |
160
+ | `MCP_HTTP_HOST` | Bind address (default `127.0.0.1`) |
161
+ | `MCP_HTTP_PORT` | Port (default `3333`) |
162
+ | `MCP_HTTP_PATH` | URL path (default `/mcp`) |
163
+ | `MCP_HTTP_JSON_RESPONSE` | `true` to prefer JSON responses over SSE when supported |
164
+ | `MCP_HTTP_ALLOWED_HOSTS` | Comma-separated allowed `Host` values when binding to `0.0.0.0` (DNS rebinding protection) |
165
+
142
166
  ## Tool Configuration
143
167
 
144
168
  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.
@@ -573,6 +597,10 @@ npm test
573
597
 
574
598
  The server supports several command line options:
575
599
 
600
+ - `--transport stdio|http`: MCP transport (default: stdio). Use `http` for Streamable HTTP.
601
+ - `--http-host`, `--http-port`, `--http-path`: HTTP bind address, port, and path (defaults: `127.0.0.1`, `3333`, `/mcp`).
602
+ - `--http-json-response`: Prefer JSON responses over SSE when the transport supports it.
603
+ - `--http-allowed-hosts`: Comma-separated allowed `Host` headers when binding to all interfaces.
576
604
  - `--export-translations`: Export all translation keys and values
577
605
  - `--optimize-response`: Enable GraphQL-style field selection
578
606
  - `--max-tokens=NUMBER`: Set maximum token limit for responses
@@ -587,6 +615,12 @@ Example:
587
615
  node build/index.js --optimize-response --max-tokens=100000 --prefix="backlog_" --enable-toolsets space,issue
588
616
  ```
589
617
 
618
+ HTTP example:
619
+
620
+ ```bash
621
+ node build/index.js --transport http --http-port 3333 --http-path /mcp
622
+ ```
623
+
590
624
  ## Multi-Organization Support
591
625
 
592
626
  This server can be configured to access multiple Backlog organizations from a single MCP server instance.
@@ -0,0 +1,29 @@
1
+ // Copyright (c) 2025 Nulab inc.
2
+ // Licensed under the MIT License.
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { registerDynamicTools, registerTools } from './registerTools.js';
5
+ import { organizationTools } from './tools/dynamicTools/organizations.js';
6
+ import { dynamicTools } from './tools/dynamicTools/toolsets.js';
7
+ import { createToolRegistrar } from './utils/toolRegistrar.js';
8
+ import { buildToolsetGroup } from './utils/toolsetUtils.js';
9
+ import { wrapServerWithToolRegistry, } from './utils/wrapServerWithToolRegistry.js';
10
+ /**
11
+ * Builds a fresh MCP server instance with all Backlog tools registered.
12
+ * Used once for stdio; one instance per HTTP session for Streamable HTTP.
13
+ */
14
+ export function createBacklogMcpServer({ version, useFields, backlog, clientRegistry, transHelper, enabledToolsets, mcpOption, dynamicToolsets, }) {
15
+ const server = wrapServerWithToolRegistry(new McpServer({
16
+ name: 'backlog',
17
+ title: useFields ? 'backlog (field selection enabled)' : 'backlog',
18
+ version,
19
+ }));
20
+ const toolsetGroup = buildToolsetGroup(backlog, transHelper, enabledToolsets);
21
+ registerTools(server, toolsetGroup, mcpOption);
22
+ registerDynamicTools(server, organizationTools(clientRegistry, transHelper), mcpOption.prefix);
23
+ if (dynamicToolsets) {
24
+ const registrar = createToolRegistrar(server, toolsetGroup, mcpOption);
25
+ const dynamicToolsetGroup = dynamicTools(registrar, transHelper, toolsetGroup);
26
+ registerDynamicTools(server, dynamicToolsetGroup, mcpOption.prefix);
27
+ }
28
+ return server;
29
+ }
@@ -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
- let handler = wrapWithErrorHandling(wrapWithOrganizationContext(tool.handler), errorHandler);
18
- if (useFields) {
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) {
@@ -0,0 +1,120 @@
1
+ // Copyright (c) 2025 Nulab inc.
2
+ // Licensed under the MIT License.
3
+ import { randomUUID } from 'node:crypto';
4
+ import { serve } from '@hono/node-server';
5
+ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
6
+ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
7
+ import { Hono } from 'hono';
8
+ import { logger } from './utils/logger.js';
9
+ const jsonRpcError = (code, message) => {
10
+ return { jsonrpc: '2.0', error: { code, message }, id: null };
11
+ };
12
+ const bodyContainsInitialize = (body) => {
13
+ return (Array.isArray(body) ? body : [body]).some(isInitializeRequest);
14
+ };
15
+ const buildAllowedHostnames = (host, allowedHosts) => {
16
+ if (allowedHosts?.length)
17
+ return allowedHosts;
18
+ const localhostHosts = ['127.0.0.1', 'localhost', '::1'];
19
+ return localhostHosts.includes(host)
20
+ ? ['localhost', '127.0.0.1', '[::1]']
21
+ : undefined;
22
+ };
23
+ const parseHostname = (hostHeader) => {
24
+ try {
25
+ return new URL(`http://${hostHeader}`).hostname;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ };
31
+ const checkHostHeader = (hostHeader, allowedHostnames) => {
32
+ if (!hostHeader)
33
+ return jsonRpcError(-32000, 'Missing Host header');
34
+ const hostname = parseHostname(hostHeader);
35
+ if (hostname === null) {
36
+ return jsonRpcError(-32000, `Invalid Host header: ${hostHeader}`);
37
+ }
38
+ return allowedHostnames.includes(hostname)
39
+ ? null
40
+ : jsonRpcError(-32000, `Invalid Host: ${hostname}`);
41
+ };
42
+ const startNewSession = async (req, body, enableJsonResponse, transports, createServer) => {
43
+ const transport = new WebStandardStreamableHTTPServerTransport({
44
+ sessionIdGenerator: () => randomUUID(),
45
+ enableJsonResponse,
46
+ onsessioninitialized: (sid) => {
47
+ transports[sid] = transport;
48
+ },
49
+ });
50
+ transport.onclose = () => {
51
+ const sid = transport.sessionId;
52
+ if (sid)
53
+ delete transports[sid];
54
+ };
55
+ await createServer().connect(transport);
56
+ return transport.handleRequest(req, { parsedBody: body });
57
+ };
58
+ export const runHttpMcpServer = async (options) => {
59
+ const { host, port, path: mcpPath, version, enableJsonResponse, allowedHosts, createServer, } = options;
60
+ if ((host === '0.0.0.0' || host === '::') && !allowedHosts?.length) {
61
+ logger.warn('Binding to all interfaces without --http-allowed-hosts. ' +
62
+ 'Set allowed Host values to prevent DNS rebinding attacks.');
63
+ }
64
+ const app = new Hono();
65
+ const transports = {};
66
+ const allowedHostnames = buildAllowedHostnames(host, allowedHosts);
67
+ app.get('/health', (c) => c.json({ status: 'healthy', timestamp: new Date().toISOString(), version }));
68
+ app.all(mcpPath, async (c) => {
69
+ const req = c.req.raw;
70
+ if (allowedHostnames) {
71
+ const hostError = checkHostHeader(req.headers.get('host'), allowedHostnames);
72
+ if (hostError)
73
+ return c.json(hostError, 403);
74
+ }
75
+ const sessionId = req.headers.get('mcp-session-id');
76
+ try {
77
+ if (sessionId && transports[sessionId]) {
78
+ return transports[sessionId].handleRequest(req);
79
+ }
80
+ if (sessionId) {
81
+ return c.json(jsonRpcError(-32000, 'Bad Request: Unknown or expired session ID. Send a new initialize request without mcp-session-id.'), 400);
82
+ }
83
+ if (req.method !== 'POST') {
84
+ return c.json(jsonRpcError(-32000, 'Bad Request: No mcp-session-id header.'), 400);
85
+ }
86
+ const parsed = await req.json().then((body) => ({ body }), () => null);
87
+ if (!parsed) {
88
+ return c.json(jsonRpcError(-32700, 'Parse error: Invalid JSON'), 400);
89
+ }
90
+ const { body } = parsed;
91
+ if (!bodyContainsInitialize(body)) {
92
+ const err = jsonRpcError(-32000, 'Bad Request: No mcp-session-id header and body is not an initialize request.');
93
+ return c.json(Array.isArray(body) ? [err] : err, 400);
94
+ }
95
+ return startNewSession(req, body, enableJsonResponse, transports, createServer);
96
+ }
97
+ catch (error) {
98
+ logger.error({ err: error }, 'Error handling MCP request');
99
+ return c.json(jsonRpcError(-32603, 'Internal server error'), 500);
100
+ }
101
+ });
102
+ const httpServer = await new Promise((resolve, reject) => {
103
+ const srv = serve({ fetch: app.fetch, port, hostname: host }, () => resolve(srv));
104
+ srv.on('error', reject);
105
+ });
106
+ const shutdown = async () => {
107
+ for (const sid of Object.keys(transports)) {
108
+ try {
109
+ await transports[sid].close();
110
+ }
111
+ catch {
112
+ /* ignore */
113
+ }
114
+ delete transports[sid];
115
+ }
116
+ httpServer.closeAllConnections();
117
+ await new Promise((resolve) => httpServer.close(() => resolve()));
118
+ };
119
+ return { httpServer, shutdown };
120
+ };
package/build/index.js CHANGED
@@ -1,25 +1,74 @@
1
1
  #!/usr/bin/env node
2
2
  // Copyright (c) 2025 Nulab inc.
3
3
  // Licensed under the MIT License.
4
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
5
  import dotenv from 'dotenv';
7
6
  import { default as env } from 'env-var';
8
7
  import yargs from 'yargs';
9
8
  import { hideBin } from 'yargs/helpers';
10
9
  import { createTranslationHelper } from './createTranslationHelper.js';
11
- import { registerDynamicTools, registerTools } from './registerTools.js';
12
- import { organizationTools } from './tools/dynamicTools/organizations.js';
13
- import { dynamicTools } from './tools/dynamicTools/toolsets.js';
10
+ import { createBacklogMcpServer } from './createBacklogMcpServer.js';
11
+ import { runHttpMcpServer } from './httpMcpServer.js';
14
12
  import { createBacklogClientRegistry } from './utils/backlogClientRegistry.js';
15
13
  import { logger } from './utils/logger.js';
16
- import { createToolRegistrar } from './utils/toolRegistrar.js';
17
- import { buildToolsetGroup } from './utils/toolsetUtils.js';
18
- import { wrapServerWithToolRegistry } from './utils/wrapServerWithToolRegistry.js';
19
14
  import packageJson from '../package.json' with { type: 'json' };
20
15
  const { version } = packageJson;
16
+ // Swallow SIGPIPE and stdout/stderr EPIPE so the process doesn't crash when a
17
+ // client disconnects mid-stream. Node.js emits EPIPE as both a Unix signal and
18
+ // as an error event on stdout/stderr streams — both must be handled.
19
+ process.on('SIGPIPE', () => { });
20
+ process.stdout.on('error', (err) => {
21
+ if (err.code !== 'EPIPE')
22
+ throw err;
23
+ });
24
+ process.stderr.on('error', (err) => {
25
+ if (err.code !== 'EPIPE')
26
+ throw err;
27
+ });
28
+ process.on('uncaughtException', (error) => {
29
+ logger.error({ err: error }, 'Uncaught exception');
30
+ process.exit(1);
31
+ });
32
+ process.on('unhandledRejection', (reason) => {
33
+ logger.error({ err: reason }, 'Unhandled rejection');
34
+ process.exit(1);
35
+ });
21
36
  dotenv.config();
22
37
  const argv = yargs(hideBin(process.argv))
38
+ .option('transport', {
39
+ type: 'string',
40
+ choices: ['stdio', 'http'],
41
+ describe: 'MCP transport: stdio (default) or Streamable HTTP',
42
+ default: env.get('MCP_TRANSPORT').default('stdio').asString().toLowerCase() ===
43
+ 'http'
44
+ ? 'http'
45
+ : 'stdio',
46
+ })
47
+ .option('http-host', {
48
+ type: 'string',
49
+ describe: 'Host to bind for HTTP transport',
50
+ default: env.get('MCP_HTTP_HOST').default('127.0.0.1').asString(),
51
+ })
52
+ .option('http-port', {
53
+ type: 'number',
54
+ describe: 'Port for HTTP transport',
55
+ default: env.get('MCP_HTTP_PORT').default(3333).asPortNumber(),
56
+ })
57
+ .option('http-path', {
58
+ type: 'string',
59
+ describe: 'URL path for MCP endpoint (must start with /)',
60
+ default: env.get('MCP_HTTP_PATH').default('/mcp').asString(),
61
+ })
62
+ .option('http-json-response', {
63
+ type: 'boolean',
64
+ describe: 'Prefer JSON responses over SSE streams when supported (Streamable HTTP)',
65
+ default: env.get('MCP_HTTP_JSON_RESPONSE').default('false').asBool(),
66
+ })
67
+ .option('http-allowed-hosts', {
68
+ type: 'string',
69
+ describe: 'Comma-separated allowed Host header values when binding to all interfaces (recommended with 0.0.0.0)',
70
+ default: env.get('MCP_HTTP_ALLOWED_HOSTS').default('').asString(),
71
+ })
23
72
  .option('max-tokens', {
24
73
  type: 'number',
25
74
  describe: 'Maximum number of tokens allowed in the response',
@@ -61,37 +110,75 @@ Available toolsets:
61
110
  const clientRegistry = createBacklogClientRegistry();
62
111
  const backlog = clientRegistry.createScopedClient();
63
112
  const useFields = argv.optimizeResponse;
64
- const server = wrapServerWithToolRegistry(new McpServer({
65
- name: 'backlog',
66
- title: useFields ? 'backlog (field selection enabled)' : 'backlog',
67
- version,
68
- }));
69
113
  const transHelper = createTranslationHelper();
70
114
  const maxTokens = argv.maxTokens;
71
115
  const prefix = argv.prefix;
72
- let enabledToolsets = argv.enableToolsets;
73
- // If dynamic toolsets are enabled, remove "all" to allow for selective enabling via commands
74
- if (argv.dynamicToolsets) {
75
- enabledToolsets = enabledToolsets.filter((a) => a != 'all');
76
- }
116
+ const enabledToolsets = argv.dynamicToolsets
117
+ ? argv.enableToolsets.filter((a) => a !== 'all')
118
+ : argv.enableToolsets;
77
119
  const mcpOption = { useFields: useFields, maxTokens, prefix };
78
- const toolsetGroup = buildToolsetGroup(backlog, transHelper, enabledToolsets);
79
- // Register all tools
80
- registerTools(server, toolsetGroup, mcpOption);
81
- registerDynamicTools(server, organizationTools(clientRegistry, transHelper), prefix);
82
- // Register dynamic tool management tools if enabled
83
- if (argv.dynamicToolsets) {
84
- const registrar = createToolRegistrar(server, toolsetGroup, mcpOption);
85
- const dynamicToolsetGroup = dynamicTools(registrar, transHelper, toolsetGroup);
86
- registerDynamicTools(server, dynamicToolsetGroup, prefix);
87
- }
120
+ // Factory: creates a fresh MCP server with all tools registered.
121
+ // Used once for stdio; one fresh instance per HTTP session for Streamable HTTP.
122
+ const createServer = () => createBacklogMcpServer({
123
+ version,
124
+ useFields,
125
+ backlog,
126
+ clientRegistry,
127
+ transHelper,
128
+ enabledToolsets,
129
+ mcpOption,
130
+ dynamicToolsets: argv.dynamicToolsets,
131
+ });
88
132
  if (argv.exportTranslations) {
89
133
  const data = transHelper.dump();
90
134
  // eslint-disable-next-line no-console
91
135
  console.log(JSON.stringify(data, null, 2));
92
136
  process.exit(0);
93
137
  }
138
+ function normalizeHttpPath(p) {
139
+ if (!p.startsWith('/')) {
140
+ return `/${p}`;
141
+ }
142
+ return p;
143
+ }
94
144
  async function main() {
145
+ if (argv.transport === 'http') {
146
+ const httpPath = normalizeHttpPath(argv.httpPath);
147
+ const allowedHostsRaw = argv.httpAllowedHosts;
148
+ const allowedHosts = allowedHostsRaw && allowedHostsRaw.trim().length > 0
149
+ ? allowedHostsRaw
150
+ .split(',')
151
+ .map((h) => h.trim())
152
+ .filter(Boolean)
153
+ : undefined;
154
+ const { shutdown } = await runHttpMcpServer({
155
+ host: argv.httpHost,
156
+ port: argv.httpPort,
157
+ path: httpPath,
158
+ version,
159
+ enableJsonResponse: argv.httpJsonResponse,
160
+ allowedHosts,
161
+ createServer,
162
+ });
163
+ process.once('SIGINT', () => {
164
+ void shutdown()
165
+ .catch((err) => logger.error({ err }, 'Error during shutdown'))
166
+ .finally(() => process.exit(0));
167
+ });
168
+ process.once('SIGTERM', () => {
169
+ void shutdown()
170
+ .catch((err) => logger.error({ err }, 'Error during shutdown'))
171
+ .finally(() => process.exit(0));
172
+ });
173
+ logger.info({
174
+ transport: 'http',
175
+ host: argv.httpHost,
176
+ port: argv.httpPort,
177
+ path: httpPath,
178
+ }, 'Backlog MCP Server listening (Streamable HTTP)');
179
+ return;
180
+ }
181
+ const server = createServer();
95
182
  const transport = new StdioServerTransport();
96
183
  await server.connect(transport);
97
184
  logger.info('Backlog MCP Server running on stdio');
@@ -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([z.number(), z.array(z.number())])
70
+ .union([
71
+ z.string(),
72
+ z.number(),
73
+ z.array(z.string()),
74
+ z.array(z.number()),
75
+ ])
71
76
  .optional()
72
- .describe('The ID(s) of the custom field item. For single-select fields, provide a number. For multi-select fields, provide an array of numbers representing the selected item IDs.'),
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([z.number(), z.array(z.number())])
89
+ .union([
90
+ z.string(),
91
+ z.number(),
92
+ z.array(z.string()),
93
+ z.array(z.number()),
94
+ ])
90
95
  .optional()
91
- .describe('The ID(s) of the custom field item. For single-select fields, provide a number. For multi-select fields, provide an array of numbers representing the selected item IDs.'),
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()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backlog-mcp-server",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "backlog-mcp-server": "./build/index.js"
@@ -27,12 +27,14 @@
27
27
  "build"
28
28
  ],
29
29
  "dependencies": {
30
+ "@hono/node-server": "^1.19.14",
30
31
  "@modelcontextprotocol/sdk": "^1.26.0",
31
32
  "backlog-js": "^0.16.0",
32
33
  "cosmiconfig": "^9.0.0",
33
34
  "dotenv": "^16.5.0",
34
35
  "env-var": "^7.5.0",
35
36
  "graphql": "^16.11.0",
37
+ "hono": "^4.12.18",
36
38
  "pino": "^9.9.0",
37
39
  "pino-pretty": "^13.1.1",
38
40
  "yargs": "^18.0.0",