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 +87 -0
- package/README.md +117 -0
- package/build/createBacklogMcpServer.js +29 -0
- package/build/handlers/builders/composeToolHandler.js +16 -8
- package/build/handlers/transformers/wrapWithOrganizationContext.js +7 -0
- package/build/httpMcpServer.js +120 -0
- package/build/index.js +115 -25
- package/build/tools/dynamicTools/organizations.js +38 -0
- package/build/tools/dynamicTools/toolsets.js +1 -1
- package/build/utils/backlogClientRegistry.js +132 -0
- package/build/utils/backlogOrganizationContext.js +8 -0
- package/package.json +3 -1
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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 {
|
|
13
|
-
import {
|
|
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
|
|
61
|
-
const
|
|
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
|
|
119
|
+
enabledToolsets = enabledToolsets.filter((a) => a !== 'all');
|
|
76
120
|
}
|
|
77
121
|
const mcpOption = { useFields: useFields, maxTokens, prefix };
|
|
78
|
-
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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.
|
|
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",
|