backlog-mcp-server 0.18.1 → 0.19.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 +1 -0
- package/build/handlers/builders/composeDynamicToolHandler.d.ts +147 -0
- package/build/handlers/builders/composeDynamicToolHandler.js +55 -0
- package/build/lib.d.ts +2 -0
- package/build/lib.js +1 -0
- package/build/registerTools.js +18 -0
- package/build/tools/addDocument.js +1 -0
- package/build/tools/getDocument.js +1 -0
- package/build/tools/getDocuments.js +1 -0
- package/build/tools/getIssueAttachment.d.ts +16 -0
- package/build/tools/getIssueAttachment.js +345 -0
- package/build/tools/getNotifications.js +3 -0
- package/build/tools/tools.js +2 -0
- package/build/types/toolsets.d.ts +12 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -294,6 +294,7 @@ Tools for managing projects, categories, custom fields, and issue types.
|
|
|
294
294
|
Tools for managing issues, their comments, and related items like priorities, categories, custom fields, issue types, resolutions, and watching lists.
|
|
295
295
|
|
|
296
296
|
- `get_issue`: Returns information about a specific issue.
|
|
297
|
+
- `get_issue_attachment`: Downloads one attachment of an issue. Returns it as image or embedded resource content, or as base64 with `format: "base64"`.
|
|
297
298
|
- `get_issues`: Returns list of issues.
|
|
298
299
|
- `count_issues`: Returns count of issues.
|
|
299
300
|
- `add_issue`: Creates a new issue in the specified project.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ErrorLike } from '../../types/result.js';
|
|
3
|
+
import { DynamicToolDefinition } from '../../types/tool.js';
|
|
4
|
+
export type ComposeDynamicOptions = {
|
|
5
|
+
errorHandler?: (err: unknown) => ErrorLike;
|
|
6
|
+
useOrganization?: boolean;
|
|
7
|
+
};
|
|
8
|
+
type DynamicInput = {
|
|
9
|
+
organization?: string;
|
|
10
|
+
} & Record<string, unknown>;
|
|
11
|
+
/**
|
|
12
|
+
* Builds the schema and handler a dynamic tool is registered with.
|
|
13
|
+
*
|
|
14
|
+
* The counterpart of `composeToolHandler` for tools that produce a
|
|
15
|
+
* `CallToolResult` themselves. Those cannot go through the whole pipeline:
|
|
16
|
+
* field picking, the token limit and `wrapWithToolResult` all assume a JSON
|
|
17
|
+
* value they may reshape, and reshaping is exactly what a tool returning binary
|
|
18
|
+
* content must not allow — a base64 payload cut at the token limit is a corrupt
|
|
19
|
+
* file reported as a success.
|
|
20
|
+
*
|
|
21
|
+
* The two steps that are not about reshaping still apply, and are why this
|
|
22
|
+
* exists rather than registering the handler directly:
|
|
23
|
+
*
|
|
24
|
+
* - `wrapWithOrganizationContext`, without which a dynamic tool ignores
|
|
25
|
+
* `organization` and always talks to the default space;
|
|
26
|
+
* - `wrapWithErrorHandling`, without which a thrown Backlog error escapes as a
|
|
27
|
+
* protocol error rather than an `isError` result, and never reaches the
|
|
28
|
+
* handler that reacts to Backlog rejecting a credential.
|
|
29
|
+
*
|
|
30
|
+
* As in `composeToolHandler`, the returned schema is a fresh object: the tool
|
|
31
|
+
* definition is never mutated, because one toolset group is shared across
|
|
32
|
+
* per-request servers.
|
|
33
|
+
*/
|
|
34
|
+
export declare function composeDynamicToolHandler(tool: DynamicToolDefinition<any>, { errorHandler, useOrganization }?: ComposeDynamicOptions): {
|
|
35
|
+
schema: z.ZodObject<{
|
|
36
|
+
[x: string]: any;
|
|
37
|
+
organization: z.ZodOptional<z.ZodString>;
|
|
38
|
+
} | {
|
|
39
|
+
[x: string]: any;
|
|
40
|
+
organization?: undefined;
|
|
41
|
+
}, z.core.$strip>;
|
|
42
|
+
handler: (input: DynamicInput) => Promise<{
|
|
43
|
+
[x: string]: unknown;
|
|
44
|
+
_meta?: {
|
|
45
|
+
[x: string]: unknown;
|
|
46
|
+
"io.modelcontextprotocol/serverInfo"?: {
|
|
47
|
+
version: string;
|
|
48
|
+
websiteUrl?: string | undefined;
|
|
49
|
+
description?: string | undefined;
|
|
50
|
+
icons?: {
|
|
51
|
+
src: string;
|
|
52
|
+
mimeType?: string | undefined;
|
|
53
|
+
sizes?: string[] | undefined;
|
|
54
|
+
theme?: "dark" | "light" | undefined;
|
|
55
|
+
}[] | undefined;
|
|
56
|
+
name: string;
|
|
57
|
+
title?: string | undefined;
|
|
58
|
+
} | undefined;
|
|
59
|
+
} | undefined;
|
|
60
|
+
content: ({
|
|
61
|
+
type: "text";
|
|
62
|
+
text: string;
|
|
63
|
+
annotations?: {
|
|
64
|
+
audience?: ("assistant" | "user")[] | undefined;
|
|
65
|
+
priority?: number | undefined;
|
|
66
|
+
lastModified?: string | undefined;
|
|
67
|
+
} | undefined;
|
|
68
|
+
_meta?: {
|
|
69
|
+
[x: string]: unknown;
|
|
70
|
+
} | undefined;
|
|
71
|
+
} | {
|
|
72
|
+
type: "image";
|
|
73
|
+
data: string;
|
|
74
|
+
mimeType: string;
|
|
75
|
+
annotations?: {
|
|
76
|
+
audience?: ("assistant" | "user")[] | undefined;
|
|
77
|
+
priority?: number | undefined;
|
|
78
|
+
lastModified?: string | undefined;
|
|
79
|
+
} | undefined;
|
|
80
|
+
_meta?: {
|
|
81
|
+
[x: string]: unknown;
|
|
82
|
+
} | undefined;
|
|
83
|
+
} | {
|
|
84
|
+
type: "audio";
|
|
85
|
+
data: string;
|
|
86
|
+
mimeType: string;
|
|
87
|
+
annotations?: {
|
|
88
|
+
audience?: ("assistant" | "user")[] | undefined;
|
|
89
|
+
priority?: number | undefined;
|
|
90
|
+
lastModified?: string | undefined;
|
|
91
|
+
} | undefined;
|
|
92
|
+
_meta?: {
|
|
93
|
+
[x: string]: unknown;
|
|
94
|
+
} | undefined;
|
|
95
|
+
} | {
|
|
96
|
+
uri: string;
|
|
97
|
+
description?: string | undefined;
|
|
98
|
+
mimeType?: string | undefined;
|
|
99
|
+
size?: number | undefined;
|
|
100
|
+
annotations?: {
|
|
101
|
+
audience?: ("assistant" | "user")[] | undefined;
|
|
102
|
+
priority?: number | undefined;
|
|
103
|
+
lastModified?: string | undefined;
|
|
104
|
+
} | undefined;
|
|
105
|
+
_meta?: {
|
|
106
|
+
[x: string]: unknown;
|
|
107
|
+
} | undefined;
|
|
108
|
+
icons?: {
|
|
109
|
+
src: string;
|
|
110
|
+
mimeType?: string | undefined;
|
|
111
|
+
sizes?: string[] | undefined;
|
|
112
|
+
theme?: "dark" | "light" | undefined;
|
|
113
|
+
}[] | undefined;
|
|
114
|
+
name: string;
|
|
115
|
+
title?: string | undefined;
|
|
116
|
+
type: "resource_link";
|
|
117
|
+
} | {
|
|
118
|
+
type: "resource";
|
|
119
|
+
resource: {
|
|
120
|
+
uri: string;
|
|
121
|
+
mimeType?: string | undefined;
|
|
122
|
+
_meta?: {
|
|
123
|
+
[x: string]: unknown;
|
|
124
|
+
} | undefined;
|
|
125
|
+
text: string;
|
|
126
|
+
} | {
|
|
127
|
+
uri: string;
|
|
128
|
+
mimeType?: string | undefined;
|
|
129
|
+
_meta?: {
|
|
130
|
+
[x: string]: unknown;
|
|
131
|
+
} | undefined;
|
|
132
|
+
blob: string;
|
|
133
|
+
};
|
|
134
|
+
annotations?: {
|
|
135
|
+
audience?: ("assistant" | "user")[] | undefined;
|
|
136
|
+
priority?: number | undefined;
|
|
137
|
+
lastModified?: string | undefined;
|
|
138
|
+
} | undefined;
|
|
139
|
+
_meta?: {
|
|
140
|
+
[x: string]: unknown;
|
|
141
|
+
} | undefined;
|
|
142
|
+
})[];
|
|
143
|
+
structuredContent?: unknown;
|
|
144
|
+
isError?: boolean | undefined;
|
|
145
|
+
}>;
|
|
146
|
+
};
|
|
147
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { wrapWithErrorHandling } from '../transformers/wrapWithErrorHandling.js';
|
|
3
|
+
import { wrapWithOrganizationContext } from '../transformers/wrapWithOrganizationContext.js';
|
|
4
|
+
import { isErrorLike } from '../../types/result.js';
|
|
5
|
+
/**
|
|
6
|
+
* Builds the schema and handler a dynamic tool is registered with.
|
|
7
|
+
*
|
|
8
|
+
* The counterpart of `composeToolHandler` for tools that produce a
|
|
9
|
+
* `CallToolResult` themselves. Those cannot go through the whole pipeline:
|
|
10
|
+
* field picking, the token limit and `wrapWithToolResult` all assume a JSON
|
|
11
|
+
* value they may reshape, and reshaping is exactly what a tool returning binary
|
|
12
|
+
* content must not allow — a base64 payload cut at the token limit is a corrupt
|
|
13
|
+
* file reported as a success.
|
|
14
|
+
*
|
|
15
|
+
* The two steps that are not about reshaping still apply, and are why this
|
|
16
|
+
* exists rather than registering the handler directly:
|
|
17
|
+
*
|
|
18
|
+
* - `wrapWithOrganizationContext`, without which a dynamic tool ignores
|
|
19
|
+
* `organization` and always talks to the default space;
|
|
20
|
+
* - `wrapWithErrorHandling`, without which a thrown Backlog error escapes as a
|
|
21
|
+
* protocol error rather than an `isError` result, and never reaches the
|
|
22
|
+
* handler that reacts to Backlog rejecting a credential.
|
|
23
|
+
*
|
|
24
|
+
* As in `composeToolHandler`, the returned schema is a fresh object: the tool
|
|
25
|
+
* definition is never mutated, because one toolset group is shared across
|
|
26
|
+
* per-request servers.
|
|
27
|
+
*/
|
|
28
|
+
export function composeDynamicToolHandler(
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30
|
+
tool, { errorHandler, useOrganization = false } = {}) {
|
|
31
|
+
// `extend` even with nothing to add, so the returned schema is always a copy.
|
|
32
|
+
// Handing back `tool.schema` itself would make the invariant above hold only
|
|
33
|
+
// when `useOrganization` is set.
|
|
34
|
+
const schema = tool.schema.extend(useOrganization
|
|
35
|
+
? {
|
|
36
|
+
organization: z
|
|
37
|
+
.string()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe('Optional organization name. Use list_organizations to inspect available organizations.'),
|
|
40
|
+
}
|
|
41
|
+
: {});
|
|
42
|
+
const handler = wrapWithErrorHandling(wrapWithOrganizationContext(tool.handler), errorHandler);
|
|
43
|
+
return {
|
|
44
|
+
schema,
|
|
45
|
+
handler: async (input) => {
|
|
46
|
+
const result = await handler(input);
|
|
47
|
+
return isErrorLike(result)
|
|
48
|
+
? {
|
|
49
|
+
isError: true,
|
|
50
|
+
content: [{ type: 'text', text: result.message }],
|
|
51
|
+
}
|
|
52
|
+
: result.data;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
package/build/lib.d.ts
CHANGED
|
@@ -14,11 +14,13 @@
|
|
|
14
14
|
*/
|
|
15
15
|
export { allTools } from './tools/tools.js';
|
|
16
16
|
export { composeToolHandler } from './handlers/builders/composeToolHandler.js';
|
|
17
|
+
export { composeDynamicToolHandler } from './handlers/builders/composeDynamicToolHandler.js';
|
|
17
18
|
export { createDescriptionHelper } from './createDescriptionHelper.js';
|
|
18
19
|
export { backlogErrorHandler } from './backlog/backlogErrorHandler.js';
|
|
19
20
|
export { buildToolSchema } from './types/tool.js';
|
|
20
21
|
export { isErrorLike } from './types/result.js';
|
|
21
22
|
export type { ComposeOptions } from './handlers/builders/composeToolHandler.js';
|
|
23
|
+
export type { ComposeDynamicOptions } from './handlers/builders/composeDynamicToolHandler.js';
|
|
22
24
|
export type { DescriptionHelper } from './createDescriptionHelper.js';
|
|
23
25
|
export type { ToolDefinition, DynamicToolDefinition } from './types/tool.js';
|
|
24
26
|
export type { Toolset, ToolsetGroup, DynamicToolset, DynamicToolsetGroup, } from './types/toolsets.js';
|
package/build/lib.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
export { allTools } from './tools/tools.js';
|
|
16
16
|
export { composeToolHandler } from './handlers/builders/composeToolHandler.js';
|
|
17
|
+
export { composeDynamicToolHandler } from './handlers/builders/composeDynamicToolHandler.js';
|
|
17
18
|
export { createDescriptionHelper } from './createDescriptionHelper.js';
|
|
18
19
|
export { backlogErrorHandler } from './backlog/backlogErrorHandler.js';
|
|
19
20
|
export { buildToolSchema } from './types/tool.js';
|
package/build/registerTools.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { backlogErrorHandler } from './backlog/backlogErrorHandler.js';
|
|
2
|
+
import { composeDynamicToolHandler } from './handlers/builders/composeDynamicToolHandler.js';
|
|
2
3
|
import { composeToolHandler } from './handlers/builders/composeToolHandler.js';
|
|
3
4
|
export function registerTools(server, toolsetGroup, options) {
|
|
4
5
|
const { useFields, maxTokens, prefix, useOrganization } = options;
|
|
@@ -15,6 +16,23 @@ export function registerTools(server, toolsetGroup, options) {
|
|
|
15
16
|
useOrganization,
|
|
16
17
|
}),
|
|
17
18
|
});
|
|
19
|
+
// Tools that build their own result, registered from the same toolsets so
|
|
20
|
+
// that `--enable-toolsets` and the prefix cover them too.
|
|
21
|
+
registerToolsets({
|
|
22
|
+
server,
|
|
23
|
+
toolsetGroup: {
|
|
24
|
+
toolsets: toolsetGroup.toolsets.map((toolset) => ({
|
|
25
|
+
name: toolset.name,
|
|
26
|
+
description: toolset.description,
|
|
27
|
+
enabled: toolset.enabled,
|
|
28
|
+
tools: toolset.dynamicTools ?? [],
|
|
29
|
+
})),
|
|
30
|
+
},
|
|
31
|
+
prefix,
|
|
32
|
+
prepareTool: (tool) => composeDynamicToolHandler(
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
34
|
+
tool, { errorHandler: backlogErrorHandler, useOrganization }),
|
|
35
|
+
});
|
|
18
36
|
}
|
|
19
37
|
export function registerDynamicTools(server, dynamicToolsetGroup, prefix) {
|
|
20
38
|
registerToolsets({
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Backlog } from 'backlog-js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { DescriptionHelper } from '../createDescriptionHelper.js';
|
|
4
|
+
import { DynamicToolDefinition } from '../types/tool.js';
|
|
5
|
+
declare const getIssueAttachmentSchema: (t: DescriptionHelper['t']) => {
|
|
6
|
+
issueId: z.ZodOptional<z.ZodNumber>;
|
|
7
|
+
issueKey: z.ZodOptional<z.ZodString>;
|
|
8
|
+
attachmentId: z.ZodNumber;
|
|
9
|
+
format: z.ZodOptional<z.ZodEnum<{
|
|
10
|
+
auto: "auto";
|
|
11
|
+
base64: "base64";
|
|
12
|
+
}>>;
|
|
13
|
+
maxBytes: z.ZodOptional<z.ZodNumber>;
|
|
14
|
+
};
|
|
15
|
+
export declare const getIssueAttachmentTool: (backlog: Backlog, { t }: DescriptionHelper) => DynamicToolDefinition<ReturnType<typeof getIssueAttachmentSchema>>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { buildToolSchema } from '../types/tool.js';
|
|
3
|
+
import { resolveIdOrKey } from '../utils/resolveIdOrKey.js';
|
|
4
|
+
const MEBIBYTE = 1024 * 1024;
|
|
5
|
+
/**
|
|
6
|
+
* Both limits are about what survives the transport, not about what Backlog
|
|
7
|
+
* allows. Base64 inflates by 4/3, and the MCP SDK caps a single stdio message
|
|
8
|
+
* at 10 MiB by default, so 7 MiB of raw bytes is the point beyond which a
|
|
9
|
+
* successful download would still fail to reach the client.
|
|
10
|
+
*/
|
|
11
|
+
const DEFAULT_MAX_BYTES = 5 * MEBIBYTE;
|
|
12
|
+
const MAX_ATTACHMENT_BYTES = 7 * MEBIBYTE;
|
|
13
|
+
/**
|
|
14
|
+
* The raster types this tool returns as MCP `image` content.
|
|
15
|
+
*
|
|
16
|
+
* Narrower than the raster types Backlog serves, and the limit
|
|
17
|
+
* does not come from MCP — `ImageContent.mimeType` is an unconstrained string
|
|
18
|
+
* in the schema. It comes from the far end: a Claude-backed host forwards the
|
|
19
|
+
* block to the Messages API, which documents support for `image/jpeg`,
|
|
20
|
+
* `image/png`, `image/gif` and `image/webp` and nothing else
|
|
21
|
+
* (https://platform.claude.com/docs/en/build-with-claude/vision). An
|
|
22
|
+
* `image/bmp` block is rejected there, and a rejected block costs the caller
|
|
23
|
+
* the metadata as well as the file rather than merely failing to draw.
|
|
24
|
+
*
|
|
25
|
+
* These four are therefore what is safe to inline. A host backed by something
|
|
26
|
+
* other than Claude may well accept more; nothing here asserts otherwise, it
|
|
27
|
+
* just does not rely on it.
|
|
28
|
+
*
|
|
29
|
+
* `image/svg+xml` is absent for an unrelated reason — an SVG can carry script,
|
|
30
|
+
* and it is text, so it goes down the text path below where it can be read
|
|
31
|
+
* rather than executed.
|
|
32
|
+
*
|
|
33
|
+
* Anything not here is returned as an embedded resource, which every client
|
|
34
|
+
* can hold.
|
|
35
|
+
*/
|
|
36
|
+
const INLINE_IMAGE_TYPES = new Set([
|
|
37
|
+
'image/gif',
|
|
38
|
+
'image/jpeg',
|
|
39
|
+
'image/png',
|
|
40
|
+
'image/webp',
|
|
41
|
+
]);
|
|
42
|
+
/**
|
|
43
|
+
* Types whose bytes are meant to be read rather than rendered.
|
|
44
|
+
*
|
|
45
|
+
* These are returned as `resource.text`. Handing a caller base64 of a CSV is
|
|
46
|
+
* the same as handing it nothing: the point of fetching a log or a spreadsheet
|
|
47
|
+
* is the content, and base64 has to be decoded by something that can already
|
|
48
|
+
* read the file.
|
|
49
|
+
*/
|
|
50
|
+
const TEXT_TYPES = new Set([
|
|
51
|
+
'application/json',
|
|
52
|
+
'application/xml',
|
|
53
|
+
'image/svg+xml',
|
|
54
|
+
'text/csv',
|
|
55
|
+
'text/html',
|
|
56
|
+
'text/plain',
|
|
57
|
+
]);
|
|
58
|
+
const getIssueAttachmentSchema = buildToolSchema((t) => ({
|
|
59
|
+
issueId: z
|
|
60
|
+
.number()
|
|
61
|
+
.int()
|
|
62
|
+
.optional()
|
|
63
|
+
.describe(t('TOOL_GET_ISSUE_ATTACHMENT_ISSUE_ID', 'The numeric ID of the issue (e.g., 12345)')),
|
|
64
|
+
issueKey: z
|
|
65
|
+
.string()
|
|
66
|
+
.optional()
|
|
67
|
+
.describe(t('TOOL_GET_ISSUE_ATTACHMENT_ISSUE_KEY', "The key of the issue (e.g., 'PROJ-123')")),
|
|
68
|
+
attachmentId: z
|
|
69
|
+
.number()
|
|
70
|
+
.int()
|
|
71
|
+
.positive()
|
|
72
|
+
.describe(t('TOOL_GET_ISSUE_ATTACHMENT_ATTACHMENT_ID', "The numeric ID of the attachment. Get it from get_issue (the issue's `attachments` array).")),
|
|
73
|
+
format: z
|
|
74
|
+
.enum(['auto', 'base64'])
|
|
75
|
+
.optional()
|
|
76
|
+
.describe(t('TOOL_GET_ISSUE_ATTACHMENT_FORMAT', "How to return the file. 'auto' (default) returns a verified raster image as MCP image content and anything else as an embedded resource, so a client can render it. 'base64' returns a single JSON object with the encoded bytes in `content`, for callers that process the file programmatically — note that this puts the whole encoding in the caller's context.")),
|
|
77
|
+
maxBytes: z
|
|
78
|
+
.number()
|
|
79
|
+
.int()
|
|
80
|
+
.positive()
|
|
81
|
+
.max(MAX_ATTACHMENT_BYTES)
|
|
82
|
+
.optional()
|
|
83
|
+
.describe(t('TOOL_GET_ISSUE_ATTACHMENT_MAX_BYTES', `Maximum raw attachment size in bytes. Defaults to ${DEFAULT_MAX_BYTES} and cannot exceed ${MAX_ATTACHMENT_BYTES}.`)),
|
|
84
|
+
}));
|
|
85
|
+
function isReadableByteStream(body) {
|
|
86
|
+
return (typeof body === 'object' &&
|
|
87
|
+
body !== null &&
|
|
88
|
+
'getReader' in body &&
|
|
89
|
+
typeof body.getReader === 'function');
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Reads the whole body, refusing to buffer more than `maxBytes`.
|
|
93
|
+
*
|
|
94
|
+
* The limit is checked per chunk rather than against a `Content-Length`:
|
|
95
|
+
* `backlog-js` does not surface response headers, and a header would be the
|
|
96
|
+
* server's claim rather than what actually arrived.
|
|
97
|
+
*/
|
|
98
|
+
async function readBody(body, maxBytes) {
|
|
99
|
+
if (!isReadableByteStream(body)) {
|
|
100
|
+
throw new Error('Backlog returned an unsupported attachment body.');
|
|
101
|
+
}
|
|
102
|
+
const reader = body.getReader();
|
|
103
|
+
const chunks = [];
|
|
104
|
+
let size = 0;
|
|
105
|
+
let finished = false;
|
|
106
|
+
try {
|
|
107
|
+
while (true) {
|
|
108
|
+
const { done, value } = await reader.read();
|
|
109
|
+
if (done) {
|
|
110
|
+
finished = true;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
if (!(value instanceof Uint8Array)) {
|
|
114
|
+
throw new Error('Backlog returned a non-binary attachment chunk.');
|
|
115
|
+
}
|
|
116
|
+
size += value.byteLength;
|
|
117
|
+
if (size > maxBytes) {
|
|
118
|
+
throw new Error(`Attachment exceeds the ${maxBytes}-byte response limit. Raise maxBytes (up to ${MAX_ATTACHMENT_BYTES}) if the file really is this large.`);
|
|
119
|
+
}
|
|
120
|
+
chunks.push(value);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
// Leaving the loop before the stream ends leaves the response in flight —
|
|
125
|
+
// `releaseLock` detaches the reader, it does not abort what is arriving.
|
|
126
|
+
// Cancelling here rather than at each `throw` covers every way out,
|
|
127
|
+
// including a `read()` that rejects, and cannot be forgotten by the next
|
|
128
|
+
// early return added above.
|
|
129
|
+
if (!finished) {
|
|
130
|
+
try {
|
|
131
|
+
await reader.cancel();
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// Best effort: keep whatever error got us here.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
reader.releaseLock();
|
|
138
|
+
}
|
|
139
|
+
const bytes = new Uint8Array(size);
|
|
140
|
+
let offset = 0;
|
|
141
|
+
for (const chunk of chunks) {
|
|
142
|
+
bytes.set(chunk, offset);
|
|
143
|
+
offset += chunk.byteLength;
|
|
144
|
+
}
|
|
145
|
+
return { bytes, size };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* `btoa` over a string built in 32 KiB slices.
|
|
149
|
+
*
|
|
150
|
+
* `String.fromCharCode(...bytes)` on a multi-megabyte array overflows the call
|
|
151
|
+
* stack, and `Buffer` is not available to this module: `src/lib.ts` exposes the
|
|
152
|
+
* tool layer to consumers that do not run on Node.
|
|
153
|
+
*/
|
|
154
|
+
function toBase64(bytes) {
|
|
155
|
+
const chunks = [];
|
|
156
|
+
const chunkSize = 0x8000;
|
|
157
|
+
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
|
158
|
+
chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)));
|
|
159
|
+
}
|
|
160
|
+
return globalThis.btoa(chunks.join(''));
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* A name safe to put in metadata and a resource URI.
|
|
164
|
+
*
|
|
165
|
+
* `backlog-js` decodes `Content-Disposition` since 0.20.1, so what arrives is
|
|
166
|
+
* already the real name. What is left is that it is the server's string: strip
|
|
167
|
+
* any directory part and control characters rather than pass those on, and
|
|
168
|
+
* fall back to the id when nothing usable remains.
|
|
169
|
+
*/
|
|
170
|
+
function normalizeFilename(rawName, attachmentId) {
|
|
171
|
+
const basename = (rawName ?? '').split(/[/\\]/).pop() ?? '';
|
|
172
|
+
const cleaned = Array.from(basename)
|
|
173
|
+
.filter((character) => {
|
|
174
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
175
|
+
return codePoint > 0x1f && codePoint !== 0x7f;
|
|
176
|
+
})
|
|
177
|
+
.join('')
|
|
178
|
+
.trim();
|
|
179
|
+
return !cleaned || cleaned === '.' || cleaned === '..'
|
|
180
|
+
? `attachment-${attachmentId}`
|
|
181
|
+
: cleaned;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The bare MIME type of a raw `Content-Type` header.
|
|
185
|
+
*
|
|
186
|
+
* `File.FileData.contentType` is the header verbatim — `backlog-js` reads it
|
|
187
|
+
* with `response.headers.get('Content-Type')` and hands it over unparsed — so
|
|
188
|
+
* it may carry parameters and any casing. Every decision below is an exact
|
|
189
|
+
* lookup in `INLINE_IMAGE_TYPES` or `TEXT_TYPES`, and `text/csv;charset=UTF-8`
|
|
190
|
+
* misses both: the CSV would come back as base64, which is the case this tool
|
|
191
|
+
* exists to serve.
|
|
192
|
+
*/
|
|
193
|
+
function bareMimeType(rawContentType) {
|
|
194
|
+
return rawContentType.split(';')[0].trim().toLowerCase();
|
|
195
|
+
}
|
|
196
|
+
function startsWith(bytes, signature) {
|
|
197
|
+
return signature.every((byte, index) => bytes[index] === byte);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Whether the bytes really are the raster format the extension promises.
|
|
201
|
+
*
|
|
202
|
+
* A client renders `image` content without checking it, so an attachment named
|
|
203
|
+
* `.png` that is not a PNG would otherwise become a broken image with no
|
|
204
|
+
* explanation. Mismatches fall back to an embedded resource.
|
|
205
|
+
*/
|
|
206
|
+
function isExpectedImage(bytes, contentType) {
|
|
207
|
+
switch (contentType) {
|
|
208
|
+
case 'image/gif':
|
|
209
|
+
return (startsWith(bytes, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) ||
|
|
210
|
+
startsWith(bytes, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]));
|
|
211
|
+
case 'image/jpeg':
|
|
212
|
+
return startsWith(bytes, [0xff, 0xd8, 0xff]);
|
|
213
|
+
case 'image/png':
|
|
214
|
+
return startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
215
|
+
case 'image/webp':
|
|
216
|
+
return (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) &&
|
|
217
|
+
bytes[8] === 0x57 &&
|
|
218
|
+
bytes[9] === 0x45 &&
|
|
219
|
+
bytes[10] === 0x42 &&
|
|
220
|
+
bytes[11] === 0x50);
|
|
221
|
+
default:
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The raster type the bytes actually are, if it is one this tool inlines.
|
|
227
|
+
*
|
|
228
|
+
* A file saved under the wrong extension — a PNG named `.jpg` is the common
|
|
229
|
+
* one — is served happily by Backlog. Trusting the name and giving up would
|
|
230
|
+
* turn it into an opaque blob no client draws, when the bytes needed to
|
|
231
|
+
* identify it are already in hand.
|
|
232
|
+
*/
|
|
233
|
+
function sniffImageType(bytes) {
|
|
234
|
+
for (const type of INLINE_IMAGE_TYPES) {
|
|
235
|
+
if (isExpectedImage(bytes, type)) {
|
|
236
|
+
return type;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* `undefined` rather than replacement characters when the bytes are not UTF-8,
|
|
243
|
+
* so a binary file under a `.txt` name falls back to a blob instead of
|
|
244
|
+
* arriving as mojibake that reads like a successful download.
|
|
245
|
+
*/
|
|
246
|
+
function decodeUtf8(bytes) {
|
|
247
|
+
try {
|
|
248
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function getSpaceNamespace(sourceUrl) {
|
|
255
|
+
try {
|
|
256
|
+
return new URL(sourceUrl).host || 'unknown-space';
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return 'unknown-space';
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* A synthetic `backlog://` URI, never the URL the download came from.
|
|
264
|
+
*
|
|
265
|
+
* In API-key mode `backlog-js` puts the key in the query string, and
|
|
266
|
+
* `File.FileData.url` carries it verbatim — echoing it into a resource URI
|
|
267
|
+
* would hand the caller a working credential.
|
|
268
|
+
*/
|
|
269
|
+
function buildResourceUri(sourceUrl, issueIdOrKey, attachmentId, filename) {
|
|
270
|
+
const space = encodeURIComponent(getSpaceNamespace(sourceUrl));
|
|
271
|
+
return `backlog://attachments/${space}/issues/${encodeURIComponent(String(issueIdOrKey))}/attachments/${attachmentId}/${encodeURIComponent(filename)}`;
|
|
272
|
+
}
|
|
273
|
+
function errorResult(message) {
|
|
274
|
+
return {
|
|
275
|
+
isError: true,
|
|
276
|
+
content: [{ type: 'text', text: message }],
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
export const getIssueAttachmentTool = (backlog, { t }) => {
|
|
280
|
+
return {
|
|
281
|
+
name: 'get_issue_attachment',
|
|
282
|
+
description: t('TOOL_GET_ISSUE_ATTACHMENT_DESCRIPTION', "Downloads one attachment of an issue. Returns it as MCP image or embedded resource content by default, or as base64 in a JSON object with `format: 'base64'`. Call get_issue first to obtain the attachmentId from the issue's `attachments` array."),
|
|
283
|
+
schema: z.object(getIssueAttachmentSchema(t)),
|
|
284
|
+
handler: async ({ issueId, issueKey, attachmentId, format, maxBytes }) => {
|
|
285
|
+
const resolved = resolveIdOrKey('issue', { id: issueId, key: issueKey }, t);
|
|
286
|
+
if (!resolved.ok) {
|
|
287
|
+
return errorResult(resolved.error.message);
|
|
288
|
+
}
|
|
289
|
+
const fileData = await backlog.getIssueAttachment(resolved.value, attachmentId);
|
|
290
|
+
const filename = normalizeFilename('filename' in fileData ? fileData.filename : undefined, attachmentId);
|
|
291
|
+
const { bytes, size } = await readBody(fileData.body, maxBytes ?? DEFAULT_MAX_BYTES);
|
|
292
|
+
// What the bytes are beats what the server says, because Backlog derives
|
|
293
|
+
// `Content-Type` from the extension too — an attachment saved as `.jpg`
|
|
294
|
+
// whose bytes are a PNG is served as `image/jpeg`. Only when nothing
|
|
295
|
+
// matches and the server promised a raster does the type become opaque:
|
|
296
|
+
// saying `image/png` for something that is not one makes a client draw a
|
|
297
|
+
// broken image with no explanation.
|
|
298
|
+
const declaredType = bareMimeType(fileData.contentType) || 'application/octet-stream';
|
|
299
|
+
const contentType = sniffImageType(bytes) ??
|
|
300
|
+
(INLINE_IMAGE_TYPES.has(declaredType)
|
|
301
|
+
? 'application/octet-stream'
|
|
302
|
+
: declaredType);
|
|
303
|
+
if (format === 'base64') {
|
|
304
|
+
const data = toBase64(bytes);
|
|
305
|
+
return {
|
|
306
|
+
content: [
|
|
307
|
+
{
|
|
308
|
+
type: 'text',
|
|
309
|
+
text: JSON.stringify({ filename, contentType, size, content: data }, null, 2),
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
const metadata = {
|
|
315
|
+
filename,
|
|
316
|
+
contentType,
|
|
317
|
+
size,
|
|
318
|
+
attachmentId,
|
|
319
|
+
issueIdOrKey: resolved.value,
|
|
320
|
+
};
|
|
321
|
+
if (INLINE_IMAGE_TYPES.has(contentType)) {
|
|
322
|
+
return {
|
|
323
|
+
content: [
|
|
324
|
+
{ type: 'text', text: JSON.stringify(metadata, null, 2) },
|
|
325
|
+
{ type: 'image', data: toBase64(bytes), mimeType: contentType },
|
|
326
|
+
],
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
const uri = buildResourceUri(fileData.url, resolved.value, attachmentId, filename);
|
|
330
|
+
const text = TEXT_TYPES.has(contentType) ? decodeUtf8(bytes) : undefined;
|
|
331
|
+
const binaryContent = {
|
|
332
|
+
type: 'resource',
|
|
333
|
+
resource: text === undefined
|
|
334
|
+
? { uri, blob: toBase64(bytes), mimeType: contentType }
|
|
335
|
+
: { uri, text, mimeType: contentType },
|
|
336
|
+
};
|
|
337
|
+
return {
|
|
338
|
+
content: [
|
|
339
|
+
{ type: 'text', text: JSON.stringify(metadata, null, 2) },
|
|
340
|
+
binaryContent,
|
|
341
|
+
],
|
|
342
|
+
};
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
};
|
package/build/tools/tools.js
CHANGED
|
@@ -13,6 +13,7 @@ import { getCustomFieldsTool } from './getCustomFields.js';
|
|
|
13
13
|
import { getGitRepositoriesTool } from './getGitRepositories.js';
|
|
14
14
|
import { getGitRepositoryTool } from './getGitRepository.js';
|
|
15
15
|
import { getIssueTool } from './getIssue.js';
|
|
16
|
+
import { getIssueAttachmentTool } from './getIssueAttachment.js';
|
|
16
17
|
import { getIssueCommentsTool } from './getIssueComments.js';
|
|
17
18
|
import { getIssuesTool } from './getIssues.js';
|
|
18
19
|
import { getRelatedIssuesTool } from './getRelatedIssues.js';
|
|
@@ -93,6 +94,7 @@ export const allTools = (backlog, helper) => {
|
|
|
93
94
|
name: 'issue',
|
|
94
95
|
description: 'Tools for managing issues and their comments.',
|
|
95
96
|
enabled: false,
|
|
97
|
+
dynamicTools: [getIssueAttachmentTool(backlog, helper)],
|
|
96
98
|
tools: [
|
|
97
99
|
getIssueTool(backlog, helper),
|
|
98
100
|
getIssuesTool(backlog, helper),
|
|
@@ -5,7 +5,18 @@ type BaseToolset<TTool> = {
|
|
|
5
5
|
enabled: boolean;
|
|
6
6
|
tools: TTool[];
|
|
7
7
|
};
|
|
8
|
-
export type Toolset = BaseToolset<ToolDefinition<any, any
|
|
8
|
+
export type Toolset = BaseToolset<ToolDefinition<any, any>> & {
|
|
9
|
+
/**
|
|
10
|
+
* Tools of this toolset that build their own `CallToolResult`.
|
|
11
|
+
*
|
|
12
|
+
* A separate field rather than a member of `tools`, because the two are
|
|
13
|
+
* registered through different pipelines: a `ToolDefinition` is reshaped by
|
|
14
|
+
* field picking and the token limit, which a tool returning binary content
|
|
15
|
+
* must not be. Keeping them in one toolset is what makes `--enable-toolsets`
|
|
16
|
+
* and the prefix apply to both.
|
|
17
|
+
*/
|
|
18
|
+
dynamicTools?: DynamicToolDefinition<any>[];
|
|
19
|
+
};
|
|
9
20
|
export type ToolsetGroup = {
|
|
10
21
|
toolsets: Toolset[];
|
|
11
22
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backlog-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"backlog-mcp-server": "./build/index.js"
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"@hono/node-server": "^2.1.1",
|
|
37
37
|
"@modelcontextprotocol/hono": "^2.0.0",
|
|
38
38
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
39
|
-
"backlog-js": "^0.
|
|
39
|
+
"backlog-js": "^0.20.1",
|
|
40
40
|
"env-var": "^7.5.0",
|
|
41
41
|
"hono": "^4.13.5",
|
|
42
42
|
"js-yaml": "^5.4.1",
|