backlog-mcp-server 0.9.1 → 0.11.0

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.ja.md CHANGED
@@ -507,6 +507,93 @@ npm test
507
507
  node build/index.js --optimize-response --max-tokens=100000 --prefix="backlog_" --enable-toolsets space,issue
508
508
  ```
509
509
 
510
+ ## 複数組織対応
511
+
512
+ このサーバーは、1つのMCPサーバーインスタンスから複数のBacklog組織にアクセスできるよう設定できます。
513
+
514
+ ### 設定
515
+
516
+ 組織ごとに環境変数のペアを定義し、デフォルト組織を設定します。
517
+
518
+ ```bash
519
+ BACKLOG_DEFAULT_ORG=COMPANY_A
520
+ BACKLOG_ORG_COMPANY_A_DOMAIN=company-a.backlog.com
521
+ BACKLOG_ORG_COMPANY_A_API_KEY=your-company-a-api-key
522
+ BACKLOG_ORG_COMPANY_B_DOMAIN=company-b.backlog.com
523
+ BACKLOG_ORG_COMPANY_B_API_KEY=your-company-b-api-key
524
+ ```
525
+
526
+ これらの変数は、ローカルの`.env`、シェル環境変数、またはMCPクライアント設定の`env`ブロックのいずれからでも利用できます。
527
+
528
+ MCP設定例:
529
+
530
+ ```json
531
+ {
532
+ "mcpServers": {
533
+ "backlog": {
534
+ "env": {
535
+ "BACKLOG_DEFAULT_ORG": "COMPANY_A",
536
+ "BACKLOG_ORG_COMPANY_A_DOMAIN": "company-a.backlog.com",
537
+ "BACKLOG_ORG_COMPANY_A_API_KEY": "your-company-a-api-key",
538
+ "BACKLOG_ORG_COMPANY_B_DOMAIN": "company-b.backlog.com",
539
+ "BACKLOG_ORG_COMPANY_B_API_KEY": "your-company-b-api-key"
540
+ }
541
+ }
542
+ }
543
+ }
544
+ ```
545
+
546
+ 複数組織用の環境変数が設定されていない場合、サーバーは従来どおり単一組織用の設定にフォールバックします。
547
+
548
+ ```bash
549
+ BACKLOG_DOMAIN=your-domain.backlog.com
550
+ BACKLOG_API_KEY=your-api-key
551
+ ```
552
+
553
+ ### ツールの使い方
554
+
555
+ 通常のツールはすべて、任意の`organization`入力フィールドを受け付けます。指定した場合、そのBacklog組織に対してツールが実行されます。
556
+
557
+ 例:
558
+
559
+ ```json
560
+ {
561
+ "organization": "COMPANY_B",
562
+ "projectKey": "PROJECT"
563
+ }
564
+ ```
565
+
566
+ `organization`を省略した場合:
567
+
568
+ - `BACKLOG_DEFAULT_ORG`で指定した組織が使われます
569
+ - 複数組織用の環境変数が存在するのに`BACKLOG_DEFAULT_ORG`が未設定の場合、サーバーは起動時に失敗します
570
+
571
+ ### 組織一覧の確認
572
+
573
+ サーバーは `list_organizations` ツールを提供しており、設定済みの組織名、ドメイン、デフォルト組織かどうかを返します。
574
+
575
+ レスポンス例:
576
+
577
+ ```json
578
+ [
579
+ {
580
+ "name": "COMPANY_A",
581
+ "domain": "company-a.backlog.com",
582
+ "isDefault": true
583
+ },
584
+ {
585
+ "name": "COMPANY_B",
586
+ "domain": "company-b.backlog.com",
587
+ "isDefault": false
588
+ }
589
+ ]
590
+ ```
591
+
592
+ ### 注意
593
+
594
+ - 複数組織モードでは、各組織に対して `BACKLOG_ORG_<NAME>_DOMAIN` と `BACKLOG_ORG_<NAME>_API_KEY` の両方を定義する必要があります
595
+ - `<NAME>` の部分が、`organization`入力や `list_organizations` に表示される組織名になります
596
+
510
597
  ## ライセンス
511
598
 
512
599
  このプロジェクトは [MITライセンス](./LICENSE) のもとでライセンスされています。
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,95 @@ 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
+
624
+ ## Multi-Organization Support
625
+
626
+ This server can be configured to access multiple Backlog organizations from a single MCP server instance.
627
+
628
+ ### Configuration
629
+
630
+ Configure one env pair per organization and set a default organization:
631
+
632
+ ```bash
633
+ BACKLOG_DEFAULT_ORG=COMPANY_A
634
+ BACKLOG_ORG_COMPANY_A_DOMAIN=company-a.backlog.com
635
+ BACKLOG_ORG_COMPANY_A_API_KEY=your-company-a-api-key
636
+ BACKLOG_ORG_COMPANY_B_DOMAIN=company-b.backlog.com
637
+ BACKLOG_ORG_COMPANY_B_API_KEY=your-company-b-api-key
638
+ ```
639
+
640
+ This works whether the variables come from a local `.env`, your shell environment, or an MCP client config `env` block.
641
+
642
+ Example MCP config:
643
+
644
+ ```json
645
+ {
646
+ "env": {
647
+ "BACKLOG_DEFAULT_ORG": "COMPANY_A",
648
+ "BACKLOG_ORG_COMPANY_A_DOMAIN": "company-a.backlog.com",
649
+ "BACKLOG_ORG_COMPANY_A_API_KEY": "your-company-a-api-key",
650
+ "BACKLOG_ORG_COMPANY_B_DOMAIN": "company-b.backlog.com",
651
+ "BACKLOG_ORG_COMPANY_B_API_KEY": "your-company-b-api-key"
652
+ }
653
+ }
654
+ ```
655
+
656
+ If no multi-organization env vars are set, the server falls back to the existing single-organization configuration:
657
+
658
+ ```bash
659
+ BACKLOG_DOMAIN=your-domain.backlog.com
660
+ BACKLOG_API_KEY=your-api-key
661
+ ```
662
+
663
+ ### Tool Usage
664
+
665
+ All normal tools accept an optional `organization` input field. When provided, the tool call is routed to that Backlog organization.
666
+
667
+ Examples:
668
+
669
+ ```json
670
+ {
671
+ "organization": "COMPANY_B",
672
+ "projectKey": "PROJECT"
673
+ }
674
+ ```
675
+
676
+ If `organization` is omitted:
677
+
678
+ - the organization named by `BACKLOG_DEFAULT_ORG` is used
679
+ - if multi-organization env vars are present and `BACKLOG_DEFAULT_ORG` is missing, the server fails at startup
680
+
681
+ ### Organization Discovery
682
+
683
+ The server provides a `list_organizations` tool that returns the configured organization names, their domains, and which one is the default.
684
+
685
+ Example response:
686
+
687
+ ```json
688
+ [
689
+ {
690
+ "name": "COMPANY_A",
691
+ "domain": "company-a.backlog.com",
692
+ "isDefault": true
693
+ },
694
+ {
695
+ "name": "COMPANY_B",
696
+ "domain": "company-b.backlog.com",
697
+ "isDefault": false
698
+ }
699
+ ]
700
+ ```
701
+
702
+ ### Notes
703
+
704
+ - For multi-org mode, every organization must define both `BACKLOG_ORG_<NAME>_DOMAIN` and `BACKLOG_ORG_<NAME>_API_KEY`.
705
+ - The `<NAME>` part is the organization name exposed through the `organization` tool input and `list_organizations`.
706
+
590
707
  ## License
591
708
 
592
709
  This project is licensed under the [MIT License](./LICENSE).
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import { wrapWithErrorHandling } from '../transformers/wrapWithErrorHandling.js';
3
3
  import { wrapWithFieldPicking } from '../transformers/wrapWithFieldPicking.js';
4
+ import { wrapWithOrganizationContext } from '../transformers/wrapWithOrganizationContext.js';
4
5
  import { wrapWithTokenLimit } from '../transformers/wrapWithTokenLimit.js';
5
6
  import { wrapWithToolResult } from '../transformers/wrapWithToolResult.js';
6
7
  import { z } from 'zod';
@@ -8,19 +9,26 @@ import { generateFieldsDescription } from '../../utils/generateFieldsDescription
8
9
  export function composeToolHandler(tool, options) {
9
10
  const { useFields, errorHandler, maxTokens } = options;
10
11
  // Step 1: Add `fields` to schema if needed
11
- if (useFields) {
12
- const fieldDesc = generateFieldsDescription(tool.outputSchema, tool.importantFields ?? [], tool.name);
13
- tool.schema = extendSchema(tool.schema, fieldDesc);
14
- }
12
+ const fieldDesc = useFields
13
+ ? generateFieldsDescription(tool.outputSchema, tool.importantFields ?? [], tool.name)
14
+ : undefined;
15
+ tool.schema = extendSchema(tool.schema, fieldDesc);
15
16
  // Step 2: Compose
16
- let handler = wrapWithErrorHandling(tool.handler, errorHandler);
17
+ let handler = wrapWithErrorHandling(wrapWithOrganizationContext(tool.handler), errorHandler);
17
18
  if (useFields) {
18
19
  handler = wrapWithFieldPicking(handler);
19
20
  }
20
21
  return wrapWithToolResult(wrapWithTokenLimit(handler, maxTokens));
21
22
  }
22
23
  function extendSchema(schema, desc) {
23
- return schema.extend({
24
- fields: z.string().describe(desc),
25
- });
24
+ const extension = {
25
+ organization: z
26
+ .string()
27
+ .optional()
28
+ .describe('Optional organization name. Use list_organizations to inspect available organizations.'),
29
+ };
30
+ if (desc) {
31
+ extension.fields = z.string().describe(desc);
32
+ }
33
+ return schema.extend(extension);
26
34
  }
@@ -0,0 +1,7 @@
1
+ import { runWithOrganization } from '../../utils/backlogOrganizationContext.js';
2
+ export function wrapWithOrganizationContext(fn) {
3
+ return async (input) => {
4
+ const { organization, ...rest } = input;
5
+ return runWithOrganization(organization, () => fn(rest));
6
+ };
7
+ }
@@ -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,24 +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
- import * as backlogjs from 'backlog-js';
7
5
  import dotenv from 'dotenv';
8
6
  import { default as env } from 'env-var';
9
7
  import yargs from 'yargs';
10
8
  import { hideBin } from 'yargs/helpers';
11
9
  import { createTranslationHelper } from './createTranslationHelper.js';
12
- import { registerDynamicTools, registerTools } from './registerTools.js';
13
- import { dynamicTools } from './tools/dynamicTools/toolsets.js';
10
+ import { createBacklogMcpServer } from './createBacklogMcpServer.js';
11
+ import { runHttpMcpServer } from './httpMcpServer.js';
12
+ import { createBacklogClientRegistry } from './utils/backlogClientRegistry.js';
14
13
  import { logger } from './utils/logger.js';
15
- import { createToolRegistrar } from './utils/toolRegistrar.js';
16
- import { buildToolsetGroup } from './utils/toolsetUtils.js';
17
- import { wrapServerWithToolRegistry } from './utils/wrapServerWithToolRegistry.js';
18
14
  import packageJson from '../package.json' with { type: 'json' };
19
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
+ });
20
36
  dotenv.config();
21
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
+ })
22
72
  .option('max-tokens', {
23
73
  type: 'number',
24
74
  describe: 'Maximum number of tokens allowed in the response',
@@ -57,40 +107,80 @@ Available toolsets:
57
107
  default: env.get('ENABLE_DYNAMIC_TOOLSETS').default('false').asBool(),
58
108
  })
59
109
  .parseSync();
60
- const domain = env.get('BACKLOG_DOMAIN').required().asString();
61
- const apiKey = env.get('BACKLOG_API_KEY').required().asString();
62
- const backlog = new backlogjs.Backlog({ host: domain, apiKey: apiKey });
110
+ const clientRegistry = createBacklogClientRegistry();
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
116
  let enabledToolsets = argv.enableToolsets;
73
117
  // If dynamic toolsets are enabled, remove "all" to allow for selective enabling via commands
74
118
  if (argv.dynamicToolsets) {
75
- enabledToolsets = enabledToolsets.filter((a) => a != 'all');
119
+ enabledToolsets = enabledToolsets.filter((a) => a !== 'all');
76
120
  }
77
121
  const mcpOption = { useFields: useFields, maxTokens, prefix };
78
- const toolsetGroup = buildToolsetGroup(backlog, transHelper, enabledToolsets);
79
- // Register all tools
80
- registerTools(server, toolsetGroup, mcpOption);
81
- // Register dynamic tool management tools if enabled
82
- if (argv.dynamicToolsets) {
83
- const registrar = createToolRegistrar(server, toolsetGroup, mcpOption);
84
- const dynamicToolsetGroup = dynamicTools(registrar, transHelper, toolsetGroup);
85
- registerDynamicTools(server, dynamicToolsetGroup, prefix);
86
- }
122
+ // Factory: creates a fresh MCP server with all tools registered.
123
+ // Used once for stdio; one fresh instance per HTTP session for Streamable HTTP.
124
+ const createServer = () => createBacklogMcpServer({
125
+ version,
126
+ useFields,
127
+ backlog,
128
+ clientRegistry,
129
+ transHelper,
130
+ enabledToolsets,
131
+ mcpOption,
132
+ dynamicToolsets: argv.dynamicToolsets,
133
+ });
87
134
  if (argv.exportTranslations) {
88
135
  const data = transHelper.dump();
89
136
  // eslint-disable-next-line no-console
90
137
  console.log(JSON.stringify(data, null, 2));
91
138
  process.exit(0);
92
139
  }
140
+ function normalizeHttpPath(p) {
141
+ if (!p.startsWith('/')) {
142
+ return `/${p}`;
143
+ }
144
+ return p;
145
+ }
93
146
  async function main() {
147
+ if (argv.transport === 'http') {
148
+ const httpPath = normalizeHttpPath(argv.httpPath);
149
+ const allowedHostsRaw = argv.httpAllowedHosts;
150
+ const allowedHosts = allowedHostsRaw && allowedHostsRaw.trim().length > 0
151
+ ? allowedHostsRaw
152
+ .split(',')
153
+ .map((h) => h.trim())
154
+ .filter(Boolean)
155
+ : undefined;
156
+ const { shutdown } = await runHttpMcpServer({
157
+ host: argv.httpHost,
158
+ port: argv.httpPort,
159
+ path: httpPath,
160
+ version,
161
+ enableJsonResponse: argv.httpJsonResponse,
162
+ allowedHosts,
163
+ createServer,
164
+ });
165
+ process.once('SIGINT', () => {
166
+ void shutdown()
167
+ .catch((err) => logger.error({ err }, 'Error during shutdown'))
168
+ .finally(() => process.exit(0));
169
+ });
170
+ process.once('SIGTERM', () => {
171
+ void shutdown()
172
+ .catch((err) => logger.error({ err }, 'Error during shutdown'))
173
+ .finally(() => process.exit(0));
174
+ });
175
+ logger.info({
176
+ transport: 'http',
177
+ host: argv.httpHost,
178
+ port: argv.httpPort,
179
+ path: httpPath,
180
+ }, 'Backlog MCP Server listening (Streamable HTTP)');
181
+ return;
182
+ }
183
+ const server = createServer();
94
184
  const transport = new StdioServerTransport();
95
185
  await server.connect(transport);
96
186
  logger.info('Backlog MCP Server running on stdio');
@@ -0,0 +1,38 @@
1
+ import { z } from 'zod';
2
+ export function organizationTools(registry, { t }) {
3
+ return {
4
+ toolsets: [
5
+ {
6
+ name: 'organization_metadata',
7
+ description: 'Tools for inspecting configured Backlog organizations.',
8
+ enabled: true,
9
+ tools: [listOrganizationsTool(registry, t)],
10
+ },
11
+ ],
12
+ };
13
+ }
14
+ export function listOrganizationsTool(registry, t) {
15
+ return {
16
+ name: 'list_organizations',
17
+ description: t('TOOL_LIST_ORGANIZATIONS_DESCRIPTION', 'List configured Backlog organizations and identify the default organization.'),
18
+ schema: z.object({}),
19
+ handler: async () => {
20
+ const organizations = registry.listOrganizations().map(toToolOutput);
21
+ return {
22
+ content: [
23
+ {
24
+ type: 'text',
25
+ text: JSON.stringify(organizations, null, 2),
26
+ },
27
+ ],
28
+ };
29
+ },
30
+ };
31
+ }
32
+ function toToolOutput(organization) {
33
+ return {
34
+ name: organization.name,
35
+ domain: organization.domain,
36
+ isDefault: organization.isDefault,
37
+ };
38
+ }
@@ -24,7 +24,7 @@ const enableToolsetSchema = buildToolSchema((t) => ({
24
24
  export const enableToolsetTool = (toolRegistrar, { t }) => {
25
25
  return {
26
26
  name: 'enable_toolset',
27
- description: t('TOOL_ENABLE_TOOLSET_DESCRIPTION', 'Enable one of the sets of tools the GitHub MCP server provides, use get_toolset_tools and list_available_toolsets first to see what this will enable'),
27
+ description: t('TOOL_ENABLE_TOOLSET_DESCRIPTION', 'Enable one of the Backlog MCP server toolsets. Use get_toolset_tools and list_available_toolsets first to inspect what this will enable.'),
28
28
  schema: z.object(enableToolsetSchema(t)),
29
29
  handler: async ({ toolset }) => {
30
30
  const msg = await toolRegistrar.enableToolsetAndRefresh(toolset);
@@ -0,0 +1,132 @@
1
+ import { Backlog } from 'backlog-js';
2
+ import { getCurrentOrganization } from './backlogOrganizationContext.js';
3
+ export function createBacklogClientRegistry(input = {}) {
4
+ const env = input.env ?? process.env;
5
+ const multiOrgRegistry = createMultiOrganizationRegistryFromEnv(env);
6
+ if (multiOrgRegistry) {
7
+ return multiOrgRegistry;
8
+ }
9
+ const domain = env.BACKLOG_DOMAIN;
10
+ const apiKey = env.BACKLOG_API_KEY;
11
+ if (!domain || !apiKey) {
12
+ throw new Error('Configure either BACKLOG_ORG_<NAME>_DOMAIN and BACKLOG_ORG_<NAME>_API_KEY with BACKLOG_DEFAULT_ORG, or both BACKLOG_DOMAIN and BACKLOG_API_KEY.');
13
+ }
14
+ const defaultName = 'default';
15
+ const client = new Backlog({ host: domain, apiKey });
16
+ const info = {
17
+ name: defaultName,
18
+ domain,
19
+ isDefault: true,
20
+ };
21
+ return {
22
+ resolveClient: (organization) => {
23
+ if (organization && organization !== defaultName) {
24
+ throw new Error(`Unknown organization '${organization}'. Use list_organizations to inspect available organizations.`);
25
+ }
26
+ return client;
27
+ },
28
+ createScopedClient: () => createBacklogClientProxy(() => {
29
+ const organization = getCurrentOrganization();
30
+ if (organization && organization !== defaultName) {
31
+ throw new Error(`Unknown organization '${organization}'. Use list_organizations to inspect available organizations.`);
32
+ }
33
+ return client;
34
+ }),
35
+ listOrganizations: () => [info],
36
+ getDefaultOrganization: () => defaultName,
37
+ };
38
+ }
39
+ function createMultiOrganizationRegistryFromEnv(env) {
40
+ const organizations = new Map();
41
+ let hasMultiOrgKeys = false;
42
+ for (const [key, value] of Object.entries(env)) {
43
+ const match = /^BACKLOG_ORG_(.+)_(DOMAIN|API_KEY)$/.exec(key);
44
+ if (!match) {
45
+ continue;
46
+ }
47
+ hasMultiOrgKeys = true;
48
+ const [, organization, field] = match;
49
+ const config = organizations.get(organization) ?? {};
50
+ if (field === 'DOMAIN') {
51
+ config.domain = value;
52
+ }
53
+ else {
54
+ config.apiKey = value;
55
+ }
56
+ organizations.set(organization, config);
57
+ }
58
+ if (!hasMultiOrgKeys) {
59
+ return undefined;
60
+ }
61
+ const invalidOrganizations = Array.from(organizations.entries())
62
+ .filter(([, config]) => !config.domain || !config.apiKey)
63
+ .map(([organization, config]) => {
64
+ const missing = [];
65
+ if (!config.domain)
66
+ missing.push(`BACKLOG_ORG_${organization}_DOMAIN`);
67
+ if (!config.apiKey)
68
+ missing.push(`BACKLOG_ORG_${organization}_API_KEY`);
69
+ return `${organization} (missing: ${missing.join(', ')})`;
70
+ })
71
+ .sort();
72
+ if (invalidOrganizations.length > 0) {
73
+ throw new Error(`Incomplete multi-organization configuration. ${invalidOrganizations.join('; ')}`);
74
+ }
75
+ if (organizations.size === 0) {
76
+ throw new Error('No valid multi-organization configuration was found. Define BACKLOG_ORG_<NAME>_DOMAIN and BACKLOG_ORG_<NAME>_API_KEY pairs.');
77
+ }
78
+ const defaultOrganization = env.BACKLOG_DEFAULT_ORG;
79
+ if (!defaultOrganization) {
80
+ throw new Error('BACKLOG_DEFAULT_ORG is required when using BACKLOG_ORG_<NAME>_DOMAIN and BACKLOG_ORG_<NAME>_API_KEY.');
81
+ }
82
+ const clients = new Map();
83
+ // At this point, all organizations have been validated to have both domain and apiKey
84
+ const validatedOrganizations = organizations;
85
+ const organizationInfo = Array.from(validatedOrganizations.entries()).map(([name, config]) => {
86
+ clients.set(name, new Backlog({
87
+ host: config.domain,
88
+ apiKey: config.apiKey,
89
+ }));
90
+ return {
91
+ name,
92
+ domain: config.domain,
93
+ isDefault: name === defaultOrganization,
94
+ };
95
+ });
96
+ if (!clients.has(defaultOrganization)) {
97
+ throw new Error(`BACKLOG_DEFAULT_ORG '${defaultOrganization}' does not match any configured organization. Use list_organizations to inspect available organizations.`);
98
+ }
99
+ return {
100
+ resolveClient: (organization) => {
101
+ const orgName = organization ?? defaultOrganization;
102
+ return resolveKnownClient(clients, orgName);
103
+ },
104
+ createScopedClient: () => createBacklogClientProxy(() => {
105
+ const organization = getCurrentOrganization();
106
+ return organization === undefined
107
+ ? resolveKnownClient(clients, defaultOrganization)
108
+ : resolveKnownClient(clients, organization);
109
+ }),
110
+ listOrganizations: () => organizationInfo,
111
+ getDefaultOrganization: () => defaultOrganization,
112
+ };
113
+ }
114
+ function resolveKnownClient(clients, organization) {
115
+ const client = clients.get(organization);
116
+ if (!client) {
117
+ throw new Error(`Unknown organization '${organization}'. Use list_organizations to inspect available organizations.`);
118
+ }
119
+ return client;
120
+ }
121
+ function createBacklogClientProxy(resolveClient) {
122
+ return new Proxy({}, {
123
+ get(_target, prop) {
124
+ const client = resolveClient();
125
+ const value = Reflect.get(client, prop);
126
+ if (typeof value === 'function') {
127
+ return value.bind(client);
128
+ }
129
+ return value;
130
+ },
131
+ });
132
+ }
@@ -0,0 +1,8 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ const organizationStorage = new AsyncLocalStorage();
3
+ export function runWithOrganization(organization, fn) {
4
+ return organizationStorage.run(organization, fn);
5
+ }
6
+ export function getCurrentOrganization() {
7
+ return organizationStorage.getStore();
8
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backlog-mcp-server",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
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.12",
36
38
  "pino": "^9.9.0",
37
39
  "pino-pretty": "^13.1.1",
38
40
  "yargs": "^18.0.0",