backlog-mcp-server 0.5.0 → 0.7.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.md +4 -0
- package/build/index.js +4 -6
- package/build/tools/addDocument.js +40 -0
- package/build/tools/addWatching.js +25 -0
- package/build/tools/deleteWatching.js +17 -0
- package/build/tools/markWatchingAsRead.js +26 -0
- package/build/tools/tools.js +12 -0
- package/build/tools/updateWatching.js +18 -0
- package/build/tools/updateWiki.js +37 -0
- package/build/version.js +1 -1
- package/package.json +4 -6
- package/build/config.js +0 -99
- package/build/errors/ProjectAccessForbiddenError.js +0 -11
- package/build/guards/ProjectGuardService.js +0 -87
- package/build/handlers/transformers/wrapWithProjectGuard.js +0 -100
- package/build/tools/downloadDocumentAttachment.js +0 -1
package/README.md
CHANGED
|
@@ -212,6 +212,10 @@ Tools for managing issues, their comments, and related items like priorities, ca
|
|
|
212
212
|
- `get_resolutions`: Returns list of issue resolutions.
|
|
213
213
|
- `get_watching_list_items`: Returns list of watching items for a user.
|
|
214
214
|
- `get_watching_list_count`: Returns count of watching items for a user.
|
|
215
|
+
- `add_watching`: Adds a new watch to an issue.
|
|
216
|
+
- `update_watching`: Updates an existing watch note.
|
|
217
|
+
- `delete_watching`: Deletes a watch from an issue.
|
|
218
|
+
- `mark_watching_as_read`: Marks a watch as read.
|
|
215
219
|
- `get_version_milestone_list`: Returns list of version milestones for a project.
|
|
216
220
|
- `add_version_milestone`: Creates a new version milestone for a project.
|
|
217
221
|
- `update_version_milestone`: Updates an existing version milestone.
|
package/build/index.js
CHANGED
|
@@ -15,7 +15,8 @@ import { logger } from './utils/logger.js';
|
|
|
15
15
|
import { createToolRegistrar } from './utils/toolRegistrar.js';
|
|
16
16
|
import { buildToolsetGroup } from './utils/toolsetUtils.js';
|
|
17
17
|
import { wrapServerWithToolRegistry } from './utils/wrapServerWithToolRegistry.js';
|
|
18
|
-
import
|
|
18
|
+
import packageJson from '../package.json' with { type: 'json' };
|
|
19
|
+
const { version } = packageJson;
|
|
19
20
|
dotenv.config();
|
|
20
21
|
const domain = env.get('BACKLOG_DOMAIN').required().asString();
|
|
21
22
|
const apiKey = env.get('BACKLOG_API_KEY').required().asString();
|
|
@@ -62,11 +63,8 @@ Available toolsets:
|
|
|
62
63
|
const useFields = argv.optimizeResponse;
|
|
63
64
|
const server = wrapServerWithToolRegistry(new McpServer({
|
|
64
65
|
name: 'backlog',
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
Start with the example above and customize freely.`
|
|
68
|
-
: undefined,
|
|
69
|
-
version: VERSION,
|
|
66
|
+
title: useFields ? 'backlog (field selection enabled)' : 'backlog',
|
|
67
|
+
version,
|
|
70
68
|
}));
|
|
71
69
|
const transHelper = createTranslationHelper();
|
|
72
70
|
const maxTokens = argv.maxTokens;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { DocumentItemSchema } from '../types/zod/backlogOutputDefinition.js';
|
|
3
|
+
import { buildToolSchema } from '../types/tool.js';
|
|
4
|
+
const addDocumentSchema = buildToolSchema((t) => ({
|
|
5
|
+
projectId: z
|
|
6
|
+
.number()
|
|
7
|
+
.describe(t('TOOL_ADD_DOCUMENT_PROJECT_ID', 'Project ID')),
|
|
8
|
+
title: z
|
|
9
|
+
.string()
|
|
10
|
+
.optional()
|
|
11
|
+
.describe(t('TOOL_ADD_DOCUMENT_TITLE', 'Title of the document')),
|
|
12
|
+
content: z
|
|
13
|
+
.string()
|
|
14
|
+
.optional()
|
|
15
|
+
.describe(t('TOOL_ADD_DOCUMENT_CONTENT', 'Content of the document')),
|
|
16
|
+
emoji: z
|
|
17
|
+
.string()
|
|
18
|
+
.optional()
|
|
19
|
+
.describe(t('TOOL_ADD_DOCUMENT_EMOJI', 'Emoji for the document')),
|
|
20
|
+
parentId: z
|
|
21
|
+
.string()
|
|
22
|
+
.optional()
|
|
23
|
+
.describe(t('TOOL_ADD_DOCUMENT_PARENT_ID', 'Parent document ID')),
|
|
24
|
+
addLast: z
|
|
25
|
+
.boolean()
|
|
26
|
+
.optional()
|
|
27
|
+
.describe(t('TOOL_ADD_DOCUMENT_ADD_LAST', 'Add to the end of the list')),
|
|
28
|
+
}));
|
|
29
|
+
export const addDocumentTool = (backlog, { t }) => {
|
|
30
|
+
return {
|
|
31
|
+
name: 'addDocument',
|
|
32
|
+
description: t('TOOL_ADD_DOCUMENT_DESCRIPTION', 'Adds a new document to the specified project.'),
|
|
33
|
+
schema: z.object(addDocumentSchema(t)),
|
|
34
|
+
outputSchema: DocumentItemSchema,
|
|
35
|
+
importantFields: ['id', 'projectId', 'title', 'plain', 'createdUser'],
|
|
36
|
+
handler: async (params) => {
|
|
37
|
+
return backlog.addDocument(params);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { buildToolSchema } from '../types/tool.js';
|
|
3
|
+
import { WatchingListItemSchema } from '../types/zod/backlogOutputDefinition.js';
|
|
4
|
+
const addWatchingSchema = buildToolSchema((t) => ({
|
|
5
|
+
issueIdOrKey: z
|
|
6
|
+
.union([z.number(), z.string()])
|
|
7
|
+
.describe(t('TOOL_ADD_WATCHING_ISSUE_ID_OR_KEY', 'Issue ID or issue key (e.g., 1234 or "PROJECT-123")')),
|
|
8
|
+
note: z
|
|
9
|
+
.string()
|
|
10
|
+
.describe(t('TOOL_ADD_WATCHING_NOTE', 'Optional note for the watch'))
|
|
11
|
+
.optional()
|
|
12
|
+
.default(''),
|
|
13
|
+
}));
|
|
14
|
+
export const addWatchingTool = (backlog, { t }) => {
|
|
15
|
+
return {
|
|
16
|
+
name: 'add_watching',
|
|
17
|
+
description: t('TOOL_ADD_WATCHING_DESCRIPTION', 'Adds a new watch to an issue'),
|
|
18
|
+
schema: z.object(addWatchingSchema(t)),
|
|
19
|
+
outputSchema: WatchingListItemSchema,
|
|
20
|
+
handler: async ({ issueIdOrKey, note }) => backlog.postWatchingListItem({
|
|
21
|
+
issueIdOrKey,
|
|
22
|
+
note,
|
|
23
|
+
}),
|
|
24
|
+
};
|
|
25
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { buildToolSchema } from '../types/tool.js';
|
|
3
|
+
import { WatchingListItemSchema } from '../types/zod/backlogOutputDefinition.js';
|
|
4
|
+
const deleteWatchingSchema = buildToolSchema((t) => ({
|
|
5
|
+
watchId: z
|
|
6
|
+
.number()
|
|
7
|
+
.describe(t('TOOL_DELETE_WATCHING_WATCH_ID', 'Watch ID to delete')),
|
|
8
|
+
}));
|
|
9
|
+
export const deleteWatchingTool = (backlog, { t }) => {
|
|
10
|
+
return {
|
|
11
|
+
name: 'delete_watching',
|
|
12
|
+
description: t('TOOL_DELETE_WATCHING_DESCRIPTION', 'Deletes a watch from an issue'),
|
|
13
|
+
schema: z.object(deleteWatchingSchema(t)),
|
|
14
|
+
outputSchema: WatchingListItemSchema,
|
|
15
|
+
handler: async ({ watchId }) => backlog.deletehWatchingListItem(watchId),
|
|
16
|
+
};
|
|
17
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { buildToolSchema } from '../types/tool.js';
|
|
3
|
+
const markWatchingAsReadSchema = buildToolSchema((t) => ({
|
|
4
|
+
watchId: z
|
|
5
|
+
.number()
|
|
6
|
+
.describe(t('TOOL_MARK_WATCHING_AS_READ_WATCH_ID', 'Watch ID to mark as read')),
|
|
7
|
+
}));
|
|
8
|
+
export const MarkWatchingAsReadResultSchema = z.object({
|
|
9
|
+
success: z.boolean(),
|
|
10
|
+
message: z.string(),
|
|
11
|
+
});
|
|
12
|
+
export const markWatchingAsReadTool = (backlog, { t }) => {
|
|
13
|
+
return {
|
|
14
|
+
name: 'mark_watching_as_read',
|
|
15
|
+
description: t('TOOL_MARK_WATCHING_AS_READ_DESCRIPTION', 'Mark a watch as read'),
|
|
16
|
+
schema: z.object(markWatchingAsReadSchema(t)),
|
|
17
|
+
outputSchema: MarkWatchingAsReadResultSchema,
|
|
18
|
+
handler: async ({ watchId }) => {
|
|
19
|
+
await backlog.resetWatchingListItemAsRead(watchId);
|
|
20
|
+
return {
|
|
21
|
+
success: true,
|
|
22
|
+
message: `Watch ${watchId} marked as read`,
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
};
|
package/build/tools/tools.js
CHANGED
|
@@ -4,6 +4,7 @@ import { addProjectTool } from './addProject.js';
|
|
|
4
4
|
import { addPullRequestTool } from './addPullRequest.js';
|
|
5
5
|
import { addPullRequestCommentTool } from './addPullRequestComment.js';
|
|
6
6
|
import { addWikiTool } from './addWiki.js';
|
|
7
|
+
import { updateWikiTool } from './updateWiki.js';
|
|
7
8
|
import { countIssuesTool } from './countIssues.js';
|
|
8
9
|
import { deleteIssueTool } from './deleteIssue.js';
|
|
9
10
|
import { deleteProjectTool } from './deleteProject.js';
|
|
@@ -30,6 +31,10 @@ import { getSpaceTool } from './getSpace.js';
|
|
|
30
31
|
import { getUsersTool } from './getUsers.js';
|
|
31
32
|
import { getWatchingListCountTool } from './getWatchingListCount.js';
|
|
32
33
|
import { getWatchingListItemsTool } from './getWatchingListItems.js';
|
|
34
|
+
import { addWatchingTool } from './addWatching.js';
|
|
35
|
+
import { updateWatchingTool } from './updateWatching.js';
|
|
36
|
+
import { deleteWatchingTool } from './deleteWatching.js';
|
|
37
|
+
import { markWatchingAsReadTool } from './markWatchingAsRead.js';
|
|
33
38
|
import { getWikiTool } from './getWiki.js';
|
|
34
39
|
import { getWikiPagesTool } from './getWikiPages.js';
|
|
35
40
|
import { getWikisCountTool } from './getWikisCount.js';
|
|
@@ -46,6 +51,7 @@ import { getVersionMilestoneListTool } from './getVersionMilestoneList.js';
|
|
|
46
51
|
import { addVersionMilestoneTool } from './addVersionMilestone.js';
|
|
47
52
|
import { updateVersionMilestoneTool } from './updateVersionMilestone.js';
|
|
48
53
|
import { deleteVersionTool } from './deleteVersion.js';
|
|
54
|
+
import { addDocumentTool } from './addDocument.js';
|
|
49
55
|
export const allTools = (backlog, helper) => {
|
|
50
56
|
return {
|
|
51
57
|
toolsets: [
|
|
@@ -91,6 +97,10 @@ export const allTools = (backlog, helper) => {
|
|
|
91
97
|
getResolutionsTool(backlog, helper),
|
|
92
98
|
getWatchingListItemsTool(backlog, helper),
|
|
93
99
|
getWatchingListCountTool(backlog, helper),
|
|
100
|
+
addWatchingTool(backlog, helper),
|
|
101
|
+
updateWatchingTool(backlog, helper),
|
|
102
|
+
deleteWatchingTool(backlog, helper),
|
|
103
|
+
markWatchingAsReadTool(backlog, helper),
|
|
94
104
|
getVersionMilestoneListTool(backlog, helper),
|
|
95
105
|
addVersionMilestoneTool(backlog, helper),
|
|
96
106
|
updateVersionMilestoneTool(backlog, helper),
|
|
@@ -106,6 +116,7 @@ export const allTools = (backlog, helper) => {
|
|
|
106
116
|
getWikisCountTool(backlog, helper),
|
|
107
117
|
getWikiTool(backlog, helper),
|
|
108
118
|
addWikiTool(backlog, helper),
|
|
119
|
+
updateWikiTool(backlog, helper),
|
|
109
120
|
],
|
|
110
121
|
},
|
|
111
122
|
{
|
|
@@ -133,6 +144,7 @@ export const allTools = (backlog, helper) => {
|
|
|
133
144
|
getDocumentsTool(backlog, helper),
|
|
134
145
|
getDocumentTreeTool(backlog, helper),
|
|
135
146
|
getDocumentTool(backlog, helper),
|
|
147
|
+
addDocumentTool(backlog, helper),
|
|
136
148
|
],
|
|
137
149
|
},
|
|
138
150
|
{
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { buildToolSchema } from '../types/tool.js';
|
|
3
|
+
import { WatchingListItemSchema } from '../types/zod/backlogOutputDefinition.js';
|
|
4
|
+
const updateWatchingSchema = buildToolSchema((t) => ({
|
|
5
|
+
watchId: z.number().describe(t('TOOL_UPDATE_WATCHING_WATCH_ID', 'Watch ID')),
|
|
6
|
+
note: z
|
|
7
|
+
.string()
|
|
8
|
+
.describe(t('TOOL_UPDATE_WATCHING_NOTE', 'Updated note for the watch')),
|
|
9
|
+
}));
|
|
10
|
+
export const updateWatchingTool = (backlog, { t }) => {
|
|
11
|
+
return {
|
|
12
|
+
name: 'update_watching',
|
|
13
|
+
description: t('TOOL_UPDATE_WATCHING_DESCRIPTION', 'Updates an existing watch note'),
|
|
14
|
+
schema: z.object(updateWatchingSchema(t)),
|
|
15
|
+
outputSchema: WatchingListItemSchema,
|
|
16
|
+
handler: async ({ watchId, note }) => backlog.patchWatchingListItem(watchId, note),
|
|
17
|
+
};
|
|
18
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { buildToolSchema } from '../types/tool.js';
|
|
3
|
+
import { WikiSchema } from '../types/zod/backlogOutputDefinition.js';
|
|
4
|
+
const updateWikiSchema = buildToolSchema((t) => ({
|
|
5
|
+
wikiId: z
|
|
6
|
+
.union([z.string(), z.number()])
|
|
7
|
+
.describe(t('TOOL_UPDATE_WIKI_ID', 'Wiki ID')),
|
|
8
|
+
name: z
|
|
9
|
+
.string()
|
|
10
|
+
.optional()
|
|
11
|
+
.describe(t('TOOL_UPDATE_WIKI_NAME', 'Name of the wiki page')),
|
|
12
|
+
content: z
|
|
13
|
+
.string()
|
|
14
|
+
.optional()
|
|
15
|
+
.describe(t('TOOL_UPDATE_WIKI_CONTENT', 'Content of the wiki page')),
|
|
16
|
+
mailNotify: z
|
|
17
|
+
.boolean()
|
|
18
|
+
.optional()
|
|
19
|
+
.describe(t('TOOL_UPDATE_WIKI_MAIL_NOTIFY', 'Whether to send notification emails (default: false)')),
|
|
20
|
+
}));
|
|
21
|
+
export const updateWikiTool = (backlog, { t }) => {
|
|
22
|
+
return {
|
|
23
|
+
name: 'update_wiki',
|
|
24
|
+
description: t('TOOL_UPDATE_WIKI_DESCRIPTION', 'Updates an existing wiki page'),
|
|
25
|
+
schema: z.object(updateWikiSchema(t)),
|
|
26
|
+
outputSchema: WikiSchema,
|
|
27
|
+
importantFields: ['id', 'name', 'content', 'updatedUser'],
|
|
28
|
+
handler: async ({ wikiId, name, content, mailNotify }) => {
|
|
29
|
+
const wikiIdNumber = typeof wikiId === 'string' ? parseInt(wikiId, 10) : wikiId;
|
|
30
|
+
return backlog.patchWiki(wikiIdNumber, {
|
|
31
|
+
name,
|
|
32
|
+
content,
|
|
33
|
+
mailNotify,
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
};
|
package/build/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '0.
|
|
1
|
+
export const VERSION = '0.6.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backlog-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"backlog-mcp-server": "./build/index.js"
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"scripts": {
|
|
10
10
|
"dev": "tsx src/index.ts",
|
|
11
|
-
"prebuild": "node scripts/replace-version.js",
|
|
12
11
|
"build": "tsc && chmod 755 build/index.js",
|
|
13
12
|
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
|
|
14
13
|
"test:coverage": "NODE_OPTIONS=--experimental-vm-modules jest --coverage",
|
|
@@ -21,13 +20,12 @@
|
|
|
21
20
|
"build"
|
|
22
21
|
],
|
|
23
22
|
"dependencies": {
|
|
24
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
25
|
-
"backlog-js": "^0.
|
|
23
|
+
"@modelcontextprotocol/sdk": "^1.24.0",
|
|
24
|
+
"backlog-js": "^0.16.0",
|
|
26
25
|
"cosmiconfig": "^9.0.0",
|
|
27
26
|
"dotenv": "^16.5.0",
|
|
28
27
|
"env-var": "^7.5.0",
|
|
29
28
|
"graphql": "^16.11.0",
|
|
30
|
-
"node-fetch": "^3.3.2",
|
|
31
29
|
"pino": "^9.9.0",
|
|
32
30
|
"pino-pretty": "^13.1.1",
|
|
33
31
|
"yargs": "^18.0.0",
|
|
@@ -36,7 +34,7 @@
|
|
|
36
34
|
"devDependencies": {
|
|
37
35
|
"@eslint/js": "^9.24.0",
|
|
38
36
|
"tsx": "^4.20.6",
|
|
39
|
-
"@release-it/conventional-changelog": "^10.0.
|
|
37
|
+
"@release-it/conventional-changelog": "^10.0.2",
|
|
40
38
|
"@types/jest": "^29.5.14",
|
|
41
39
|
"@types/node": "^22.14.1",
|
|
42
40
|
"@types/yargs": "^17.0.33",
|
package/build/config.js
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 Nulab inc.
|
|
2
|
-
// Licensed under the MIT License.
|
|
3
|
-
import dotenv from 'dotenv';
|
|
4
|
-
import { default as env } from 'env-var';
|
|
5
|
-
import yargs from 'yargs';
|
|
6
|
-
import { hideBin } from 'yargs/helpers';
|
|
7
|
-
import { VERSION } from './version.js';
|
|
8
|
-
dotenv.config();
|
|
9
|
-
// Define Read Guard policies
|
|
10
|
-
const READ_GUARD_POLICIES = ['off', 'filter', 'deny'];
|
|
11
|
-
// Define Write Guard policies
|
|
12
|
-
const WRITE_GUARD_POLICIES = ['on', 'off'];
|
|
13
|
-
export const config = yargs(hideBin(process.argv))
|
|
14
|
-
.option('backlog-domain', {
|
|
15
|
-
type: 'string',
|
|
16
|
-
describe: 'Backlog domain',
|
|
17
|
-
default: env.get('BACKLOG_DOMAIN').required().asString(),
|
|
18
|
-
})
|
|
19
|
-
.option('backlog-api-key', {
|
|
20
|
-
type: 'string',
|
|
21
|
-
describe: 'Backlog API key',
|
|
22
|
-
default: env.get('BACKLOG_API_KEY').required().asString(),
|
|
23
|
-
})
|
|
24
|
-
.option('max-tokens', {
|
|
25
|
-
type: 'number',
|
|
26
|
-
describe: 'Maximum number of tokens allowed in the response',
|
|
27
|
-
default: env.get('MAX_TOKENS').default('50000').asIntPositive(),
|
|
28
|
-
})
|
|
29
|
-
.option('optimize-response', {
|
|
30
|
-
type: 'boolean',
|
|
31
|
-
describe: 'Enable GraphQL-style response optimization to include only requested fields',
|
|
32
|
-
default: env.get('OPTIMIZE_RESPONSE').default('false').asBool(),
|
|
33
|
-
})
|
|
34
|
-
.option('prefix', {
|
|
35
|
-
type: 'string',
|
|
36
|
-
describe: 'Optional string prefix to prepend to all generated outputs',
|
|
37
|
-
default: env.get('PREFIX').default('').asString(),
|
|
38
|
-
})
|
|
39
|
-
.option('export-translations', {
|
|
40
|
-
type: 'boolean',
|
|
41
|
-
describe: 'Export translations and exit',
|
|
42
|
-
default: false,
|
|
43
|
-
})
|
|
44
|
-
.option('enable-toolsets', {
|
|
45
|
-
type: 'array',
|
|
46
|
-
describe: `Specify which toolsets to enable. Defaults to 'all'.`,
|
|
47
|
-
default: env.get('ENABLE_TOOLSETS').default('all').asArray(','),
|
|
48
|
-
})
|
|
49
|
-
.option('dynamic-toolsets', {
|
|
50
|
-
type: 'boolean',
|
|
51
|
-
describe: 'Enable dynamic toolsets such as enable_toolset, list_available_toolsets, etc.',
|
|
52
|
-
default: env.get('ENABLE_DYNAMIC_TOOLSETS').default('false').asBool(),
|
|
53
|
-
})
|
|
54
|
-
// Project-Scoped Access Controls
|
|
55
|
-
.option('allowed-project-ids', {
|
|
56
|
-
type: 'array',
|
|
57
|
-
describe: 'Comma-separated list of allowed Backlog project IDs',
|
|
58
|
-
default: env.get('BACKLOG_ALLOWED_PROJECT_IDS').default('').asArray(',').filter(Boolean),
|
|
59
|
-
})
|
|
60
|
-
.option('allowed-project-keys', {
|
|
61
|
-
type: 'array',
|
|
62
|
-
describe: 'Comma-separated list of allowed Backlog project keys',
|
|
63
|
-
default: env.get('BACKLOG_ALLOWED_PROJECT_KEYS').default('').asArray(',').filter(Boolean),
|
|
64
|
-
})
|
|
65
|
-
.option('write-guard', {
|
|
66
|
-
choices: WRITE_GUARD_POLICIES,
|
|
67
|
-
describe: 'Policy for write operations (create/update/delete)',
|
|
68
|
-
default: env
|
|
69
|
-
.get('BACKLOG_WRITE_GUARD')
|
|
70
|
-
.default('off')
|
|
71
|
-
.asEnum(WRITE_GUARD_POLICIES),
|
|
72
|
-
})
|
|
73
|
-
.option('read-guard', {
|
|
74
|
-
choices: READ_GUARD_POLICIES,
|
|
75
|
-
describe: 'Policy for read operations (get/list/search)',
|
|
76
|
-
default: env
|
|
77
|
-
.get('BACKLOG_READ_GUARD')
|
|
78
|
-
.default('off')
|
|
79
|
-
.asEnum(READ_GUARD_POLICIES),
|
|
80
|
-
})
|
|
81
|
-
.option('default-project-id', {
|
|
82
|
-
type: 'number',
|
|
83
|
-
describe: 'Default project ID to use for create operations when project is omitted',
|
|
84
|
-
default: env.get('BACKLOG_DEFAULT_PROJECT_ID').asInt(),
|
|
85
|
-
})
|
|
86
|
-
.option('unguarded-ok', {
|
|
87
|
-
type: 'string',
|
|
88
|
-
describe: 'Explicitly allow running in production without guards',
|
|
89
|
-
default: env.get('BACKLOG_UNGUARDED_OK').asString(),
|
|
90
|
-
})
|
|
91
|
-
.option('key-resolve-ttl-sec', {
|
|
92
|
-
type: 'number',
|
|
93
|
-
describe: 'Cache TTL in seconds for project key-to-ID resolution',
|
|
94
|
-
default: env.get('BACKLOG_KEY_RESOLVE_TTL_SEC').default(300).asInt(),
|
|
95
|
-
})
|
|
96
|
-
.version(VERSION)
|
|
97
|
-
.help()
|
|
98
|
-
.alias('h', 'help')
|
|
99
|
-
.parseSync();
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 Nulab inc.
|
|
2
|
-
// Licensed under the MIT License.
|
|
3
|
-
export class ProjectAccessForbiddenError extends Error {
|
|
4
|
-
code = -32040;
|
|
5
|
-
data;
|
|
6
|
-
constructor(message, data) {
|
|
7
|
-
super(message);
|
|
8
|
-
this.name = 'ProjectAccessForbiddenError';
|
|
9
|
-
this.data = data;
|
|
10
|
-
}
|
|
11
|
-
}
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 Nulab inc.
|
|
2
|
-
// Licensed under the MIT License.
|
|
3
|
-
import { logger } from '../utils/logger.js';
|
|
4
|
-
export class ProjectGuardService {
|
|
5
|
-
allowedProjectIds = new Set();
|
|
6
|
-
projectKeyCache = new Map();
|
|
7
|
-
config;
|
|
8
|
-
backlog;
|
|
9
|
-
constructor(backlog, config) {
|
|
10
|
-
this.backlog = backlog;
|
|
11
|
-
this.config = config;
|
|
12
|
-
}
|
|
13
|
-
async initialize() {
|
|
14
|
-
// 1. Parse IDs from config
|
|
15
|
-
this.config.allowedProjectIds.forEach((id) => {
|
|
16
|
-
const numericId = Number(id);
|
|
17
|
-
if (!isNaN(numericId)) {
|
|
18
|
-
this.allowedProjectIds.add(numericId);
|
|
19
|
-
}
|
|
20
|
-
});
|
|
21
|
-
if (this.config.allowedProjectKeys.length > 0) {
|
|
22
|
-
const projects = await this.backlog.getProjects();
|
|
23
|
-
const projectMap = new Map();
|
|
24
|
-
projects.forEach((p) => projectMap.set(p.projectKey, p.id));
|
|
25
|
-
for (const key of this.config.allowedProjectKeys) {
|
|
26
|
-
const id = projectMap.get(key);
|
|
27
|
-
if (id) {
|
|
28
|
-
this.allowedProjectIds.add(id);
|
|
29
|
-
}
|
|
30
|
-
else {
|
|
31
|
-
throw new Error(`Failed to resolve project key: ${key}`);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
this.performStartupValidation();
|
|
36
|
-
}
|
|
37
|
-
performStartupValidation() {
|
|
38
|
-
const isProduction = process.env.NODE_ENV === 'production';
|
|
39
|
-
const guardsEnabled = this.config.readGuard !== 'off' || this.config.writeGuard !== 'off';
|
|
40
|
-
if (guardsEnabled && this.allowedProjectIds.size === 0) {
|
|
41
|
-
throw new Error('FATAL: Guards are enabled but no allowed projects are configured.');
|
|
42
|
-
}
|
|
43
|
-
if (this.allowedProjectIds.size > 0 && !guardsEnabled) {
|
|
44
|
-
const message = 'WARNING: Allowed projects are configured but both read and write guards are off.';
|
|
45
|
-
if (isProduction) {
|
|
46
|
-
throw new Error(`FATAL: ${message}`);
|
|
47
|
-
}
|
|
48
|
-
else {
|
|
49
|
-
logger.warn(message);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
if (this.allowedProjectIds.size === 0 &&
|
|
53
|
-
!guardsEnabled &&
|
|
54
|
-
isProduction) {
|
|
55
|
-
if (this.config.unguardedOk !== 'I_UNDERSTAND_THE_RISKS') {
|
|
56
|
-
throw new Error('FATAL: Running in production without guards requires BACKLOG_UNGUARDED_OK=I_UNDERSTAND_THE_RISKS');
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
else if (this.allowedProjectIds.size === 0 && !guardsEnabled) {
|
|
60
|
-
logger.warn('WARNING: Server is running in a fully unguarded mode.');
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
get readGuardPolicy() {
|
|
64
|
-
return this.config.readGuard;
|
|
65
|
-
}
|
|
66
|
-
get writeGuardPolicy() {
|
|
67
|
-
return this.config.writeGuard;
|
|
68
|
-
}
|
|
69
|
-
isAllowed(projectId) {
|
|
70
|
-
if (this.allowedProjectIds.size === 0) {
|
|
71
|
-
return true; // No restrictions
|
|
72
|
-
}
|
|
73
|
-
return this.allowedProjectIds.has(projectId);
|
|
74
|
-
}
|
|
75
|
-
filterProjectIds(projectIds) {
|
|
76
|
-
if (this.allowedProjectIds.size === 0) {
|
|
77
|
-
return projectIds;
|
|
78
|
-
}
|
|
79
|
-
return projectIds.filter((id) => this.allowedProjectIds.has(id));
|
|
80
|
-
}
|
|
81
|
-
getAllowedProjectIds() {
|
|
82
|
-
return this.allowedProjectIds;
|
|
83
|
-
}
|
|
84
|
-
getDefaultProjectId() {
|
|
85
|
-
return this.config.defaultProjectId;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 Nulab inc.
|
|
2
|
-
// Licensed under the MIT License.
|
|
3
|
-
import { ProjectAccessForbiddenError, } from '../../errors/ProjectAccessForbiddenError.js';
|
|
4
|
-
import { logger } from '../../utils/logger.js';
|
|
5
|
-
const getOperationType = (toolName) => {
|
|
6
|
-
const lowerToolName = toolName.toLowerCase();
|
|
7
|
-
if (lowerToolName.startsWith('add') ||
|
|
8
|
-
lowerToolName.startsWith('update') ||
|
|
9
|
-
lowerToolName.startsWith('delete') ||
|
|
10
|
-
lowerToolName.includes('mark') ||
|
|
11
|
-
lowerToolName.includes('reset')) {
|
|
12
|
-
return 'write';
|
|
13
|
-
}
|
|
14
|
-
if (lowerToolName.startsWith('get') ||
|
|
15
|
-
lowerToolName.startsWith('count') ||
|
|
16
|
-
lowerToolName.startsWith('list')) {
|
|
17
|
-
return 'read';
|
|
18
|
-
}
|
|
19
|
-
return 'neutral';
|
|
20
|
-
};
|
|
21
|
-
export const wrapWithProjectGuard = (handler, toolName, guardService, backlog) => {
|
|
22
|
-
return async (params) => {
|
|
23
|
-
const operationType = getOperationType(toolName);
|
|
24
|
-
const { projectId, projectKey } = params;
|
|
25
|
-
if (operationType === 'write') {
|
|
26
|
-
if (guardService.writeGuardPolicy === 'on') {
|
|
27
|
-
let targetProjectId = projectId;
|
|
28
|
-
if (projectKey) {
|
|
29
|
-
// This is a simplified resolution. A real implementation would cache.
|
|
30
|
-
const project = await backlog.getProject(projectKey);
|
|
31
|
-
targetProjectId = project.id;
|
|
32
|
-
}
|
|
33
|
-
if (!targetProjectId) {
|
|
34
|
-
targetProjectId = guardService.getDefaultProjectId();
|
|
35
|
-
}
|
|
36
|
-
if (!targetProjectId || !guardService.isAllowed(targetProjectId)) {
|
|
37
|
-
const errorData = {
|
|
38
|
-
policy: 'on',
|
|
39
|
-
allowedProjectIds: [...guardService.getAllowedProjectIds()],
|
|
40
|
-
requestedProjectId: targetProjectId,
|
|
41
|
-
requestedProjectKey: projectKey,
|
|
42
|
-
};
|
|
43
|
-
logger.warn({ toolName, operationType, result: 'blocked', ...errorData }, 'Project write access blocked');
|
|
44
|
-
throw new ProjectAccessForbiddenError('Write operation is not allowed for this project', errorData);
|
|
45
|
-
}
|
|
46
|
-
params.projectId = targetProjectId;
|
|
47
|
-
logger.info({
|
|
48
|
-
toolName,
|
|
49
|
-
operationType,
|
|
50
|
-
result: 'allowed',
|
|
51
|
-
projectId: targetProjectId,
|
|
52
|
-
}, 'Project write access allowed');
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
else if (operationType === 'read') {
|
|
56
|
-
const policy = guardService.readGuardPolicy;
|
|
57
|
-
if (policy === 'deny') {
|
|
58
|
-
let targetProjectId = projectId;
|
|
59
|
-
if (projectKey) {
|
|
60
|
-
const project = await backlog.getProject(projectKey);
|
|
61
|
-
targetProjectId = project.id;
|
|
62
|
-
}
|
|
63
|
-
if (targetProjectId && !guardService.isAllowed(targetProjectId)) {
|
|
64
|
-
const errorData = {
|
|
65
|
-
policy: 'deny',
|
|
66
|
-
allowedProjectIds: [...guardService.getAllowedProjectIds()],
|
|
67
|
-
requestedProjectId: targetProjectId,
|
|
68
|
-
};
|
|
69
|
-
logger.warn({ toolName, operationType, result: 'blocked', ...errorData }, 'Project read access blocked');
|
|
70
|
-
throw new ProjectAccessForbiddenError('Read operation is not allowed for this project', errorData);
|
|
71
|
-
}
|
|
72
|
-
if (!targetProjectId && guardService.getAllowedProjectIds().size > 1) {
|
|
73
|
-
const errorData = {
|
|
74
|
-
policy: 'deny',
|
|
75
|
-
allowedProjectIds: [...guardService.getAllowedProjectIds()],
|
|
76
|
-
};
|
|
77
|
-
logger.warn({ toolName, operationType, result: 'blocked', ...errorData }, 'Project read access blocked (ambiguous project)');
|
|
78
|
-
throw new ProjectAccessForbiddenError('Project must be specified for read operations when multiple projects are allowed.', errorData);
|
|
79
|
-
}
|
|
80
|
-
logger.info({
|
|
81
|
-
toolName,
|
|
82
|
-
operationType,
|
|
83
|
-
result: 'allowed',
|
|
84
|
-
policy,
|
|
85
|
-
projectId: targetProjectId,
|
|
86
|
-
}, 'Project read access allowed');
|
|
87
|
-
}
|
|
88
|
-
else if (policy === 'filter') {
|
|
89
|
-
const allowedIds = [...guardService.getAllowedProjectIds()];
|
|
90
|
-
if (params.projectId) {
|
|
91
|
-
params.projectId = guardService.filterProjectIds(Array.isArray(params.projectId) ? params.projectId : [params.projectId]);
|
|
92
|
-
}
|
|
93
|
-
else {
|
|
94
|
-
params.projectId = allowedIds;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
return handler(params);
|
|
99
|
-
};
|
|
100
|
-
};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|