backlog-mcp-server 0.9.0 → 0.10.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
@@ -587,6 +587,89 @@ Example:
587
587
  node build/index.js --optimize-response --max-tokens=100000 --prefix="backlog_" --enable-toolsets space,issue
588
588
  ```
589
589
 
590
+ ## Multi-Organization Support
591
+
592
+ This server can be configured to access multiple Backlog organizations from a single MCP server instance.
593
+
594
+ ### Configuration
595
+
596
+ Configure one env pair per organization and set a default organization:
597
+
598
+ ```bash
599
+ BACKLOG_DEFAULT_ORG=COMPANY_A
600
+ BACKLOG_ORG_COMPANY_A_DOMAIN=company-a.backlog.com
601
+ BACKLOG_ORG_COMPANY_A_API_KEY=your-company-a-api-key
602
+ BACKLOG_ORG_COMPANY_B_DOMAIN=company-b.backlog.com
603
+ BACKLOG_ORG_COMPANY_B_API_KEY=your-company-b-api-key
604
+ ```
605
+
606
+ This works whether the variables come from a local `.env`, your shell environment, or an MCP client config `env` block.
607
+
608
+ Example MCP config:
609
+
610
+ ```json
611
+ {
612
+ "env": {
613
+ "BACKLOG_DEFAULT_ORG": "COMPANY_A",
614
+ "BACKLOG_ORG_COMPANY_A_DOMAIN": "company-a.backlog.com",
615
+ "BACKLOG_ORG_COMPANY_A_API_KEY": "your-company-a-api-key",
616
+ "BACKLOG_ORG_COMPANY_B_DOMAIN": "company-b.backlog.com",
617
+ "BACKLOG_ORG_COMPANY_B_API_KEY": "your-company-b-api-key"
618
+ }
619
+ }
620
+ ```
621
+
622
+ If no multi-organization env vars are set, the server falls back to the existing single-organization configuration:
623
+
624
+ ```bash
625
+ BACKLOG_DOMAIN=your-domain.backlog.com
626
+ BACKLOG_API_KEY=your-api-key
627
+ ```
628
+
629
+ ### Tool Usage
630
+
631
+ All normal tools accept an optional `organization` input field. When provided, the tool call is routed to that Backlog organization.
632
+
633
+ Examples:
634
+
635
+ ```json
636
+ {
637
+ "organization": "COMPANY_B",
638
+ "projectKey": "PROJECT"
639
+ }
640
+ ```
641
+
642
+ If `organization` is omitted:
643
+
644
+ - the organization named by `BACKLOG_DEFAULT_ORG` is used
645
+ - if multi-organization env vars are present and `BACKLOG_DEFAULT_ORG` is missing, the server fails at startup
646
+
647
+ ### Organization Discovery
648
+
649
+ The server provides a `list_organizations` tool that returns the configured organization names, their domains, and which one is the default.
650
+
651
+ Example response:
652
+
653
+ ```json
654
+ [
655
+ {
656
+ "name": "COMPANY_A",
657
+ "domain": "company-a.backlog.com",
658
+ "isDefault": true
659
+ },
660
+ {
661
+ "name": "COMPANY_B",
662
+ "domain": "company-b.backlog.com",
663
+ "isDefault": false
664
+ }
665
+ ]
666
+ ```
667
+
668
+ ### Notes
669
+
670
+ - For multi-org mode, every organization must define both `BACKLOG_ORG_<NAME>_DOMAIN` and `BACKLOG_ORG_<NAME>_API_KEY`.
671
+ - The `<NAME>` part is the organization name exposed through the `organization` tool input and `list_organizations`.
672
+
590
673
  ## License
591
674
 
592
675
  This project is licensed under the [MIT License](./LICENSE).
@@ -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
+ }
package/build/index.js CHANGED
@@ -3,14 +3,15 @@
3
3
  // Licensed under the MIT License.
4
4
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
5
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
- import * as backlogjs from 'backlog-js';
7
6
  import dotenv from 'dotenv';
8
7
  import { default as env } from 'env-var';
9
8
  import yargs from 'yargs';
10
9
  import { hideBin } from 'yargs/helpers';
11
10
  import { createTranslationHelper } from './createTranslationHelper.js';
12
11
  import { registerDynamicTools, registerTools } from './registerTools.js';
12
+ import { organizationTools } from './tools/dynamicTools/organizations.js';
13
13
  import { dynamicTools } from './tools/dynamicTools/toolsets.js';
14
+ import { createBacklogClientRegistry } from './utils/backlogClientRegistry.js';
14
15
  import { logger } from './utils/logger.js';
15
16
  import { createToolRegistrar } from './utils/toolRegistrar.js';
16
17
  import { buildToolsetGroup } from './utils/toolsetUtils.js';
@@ -18,9 +19,6 @@ import { wrapServerWithToolRegistry } from './utils/wrapServerWithToolRegistry.j
18
19
  import packageJson from '../package.json' with { type: 'json' };
19
20
  const { version } = packageJson;
20
21
  dotenv.config();
21
- const domain = env.get('BACKLOG_DOMAIN').required().asString();
22
- const apiKey = env.get('BACKLOG_API_KEY').required().asString();
23
- const backlog = new backlogjs.Backlog({ host: domain, apiKey: apiKey });
24
22
  const argv = yargs(hideBin(process.argv))
25
23
  .option('max-tokens', {
26
24
  type: 'number',
@@ -60,6 +58,8 @@ Available toolsets:
60
58
  default: env.get('ENABLE_DYNAMIC_TOOLSETS').default('false').asBool(),
61
59
  })
62
60
  .parseSync();
61
+ const clientRegistry = createBacklogClientRegistry();
62
+ const backlog = clientRegistry.createScopedClient();
63
63
  const useFields = argv.optimizeResponse;
64
64
  const server = wrapServerWithToolRegistry(new McpServer({
65
65
  name: 'backlog',
@@ -78,6 +78,7 @@ const mcpOption = { useFields: useFields, maxTokens, prefix };
78
78
  const toolsetGroup = buildToolsetGroup(backlog, transHelper, enabledToolsets);
79
79
  // Register all tools
80
80
  registerTools(server, toolsetGroup, mcpOption);
81
+ registerDynamicTools(server, organizationTools(clientRegistry, transHelper), prefix);
81
82
  // Register dynamic tool management tools if enabled
82
83
  if (argv.dynamicToolsets) {
83
84
  const registrar = createToolRegistrar(server, toolsetGroup, mcpOption);
@@ -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);
@@ -55,7 +55,6 @@ import { addVersionMilestoneTool } from './addVersionMilestone.js';
55
55
  import { updateVersionMilestoneTool } from './updateVersionMilestone.js';
56
56
  import { deleteVersionTool } from './deleteVersion.js';
57
57
  import { addDocumentTool } from './addDocument.js';
58
- import { deleteDocumentTool } from './deleteDocument.js';
59
58
  export const allTools = (backlog, helper) => {
60
59
  return {
61
60
  toolsets: [
@@ -152,7 +151,6 @@ export const allTools = (backlog, helper) => {
152
151
  getDocumentTreeTool(backlog, helper),
153
152
  getDocumentTool(backlog, helper),
154
153
  addDocumentTool(backlog, helper),
155
- deleteDocumentTool(backlog, helper),
156
154
  ],
157
155
  },
158
156
  {
@@ -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.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "backlog-mcp-server": "./build/index.js"
@@ -27,7 +27,7 @@
27
27
  "build"
28
28
  ],
29
29
  "dependencies": {
30
- "@modelcontextprotocol/sdk": "^1.24.0",
30
+ "@modelcontextprotocol/sdk": "^1.26.0",
31
31
  "backlog-js": "^0.16.0",
32
32
  "cosmiconfig": "^9.0.0",
33
33
  "dotenv": "^16.5.0",
@@ -1,19 +0,0 @@
1
- import { z } from 'zod';
2
- import { DocumentItemSchema } from '../types/zod/backlogOutputDefinition.js';
3
- import { buildToolSchema } from '../types/tool.js';
4
- const deleteDocumentSchema = buildToolSchema((t) => ({
5
- documentId: z
6
- .string()
7
- .describe(t('TOOL_DELETE_DOCUMENT_DOCUMENT_ID', 'Document ID')),
8
- }));
9
- export const deleteDocumentTool = (backlog, { t }) => {
10
- return {
11
- name: 'delete_document',
12
- description: t('TOOL_DELETE_DOCUMENT_DESCRIPTION', 'Permanently deletes a document. This is irreversible (hard delete) and the document cannot be recovered. Unlike the Backlog UI trash feature, this operation does not move the document to trash.'),
13
- schema: z.object(deleteDocumentSchema(t)),
14
- outputSchema: DocumentItemSchema,
15
- handler: async ({ documentId }) => {
16
- return backlog.deleteDocument(documentId);
17
- },
18
- };
19
- };