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