backlog-mcp-server 0.18.1 → 0.20.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 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.
@@ -1,8 +1,8 @@
1
1
  // Copyright (c) 2025 Nulab inc.
2
2
  // Licensed under the MIT License.
3
3
  import { McpServer } from '@modelcontextprotocol/server';
4
- import { registerDynamicTools, registerTools } from './registerTools.js';
5
- import { organizationTools } from './tools/dynamicTools/organizations.js';
4
+ import { registerTools } from './registerTools.js';
5
+ import { organizationTools } from './tools/organizations.js';
6
6
  import { buildToolsetGroup } from './utils/toolsetUtils.js';
7
7
  import { wrapServerWithToolRegistry, } from './utils/wrapServerWithToolRegistry.js';
8
8
  // The tool list is fixed for the process lifetime: it only depends on CLI flags
@@ -27,7 +27,11 @@ export function createBacklogMcpServer({ version, useFields, backlog, clientRegi
27
27
  // is configured; the `organization` parameter its description points at is
28
28
  // published under the same condition.
29
29
  if (mcpOption.useOrganization) {
30
- registerDynamicTools(server, organizationTools(clientRegistry, descriptionHelper), mcpOption.prefix);
30
+ registerTools(server, organizationTools(clientRegistry, descriptionHelper),
31
+ // `useOrganization: false` regardless of the flag that got us here.
32
+ // `list_organizations` is what a caller reads to learn what may go in
33
+ // `organization`; scoping the answer to one organization is circular.
34
+ { ...mcpOption, useOrganization: false });
31
35
  }
32
36
  return server;
33
37
  }
@@ -0,0 +1,147 @@
1
+ import { z } from 'zod';
2
+ import { ErrorLike } from '../../types/result.js';
3
+ import { NativeContentToolDefinition } from '../../types/tool.js';
4
+ export type ComposeNativeContentOptions = {
5
+ errorHandler?: (err: unknown) => ErrorLike;
6
+ useOrganization?: boolean;
7
+ };
8
+ type NativeContentInput = {
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 composeNativeContentToolHandler(tool: NativeContentToolDefinition<any>, { errorHandler, useOrganization }?: ComposeNativeContentOptions): {
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: NativeContentInput) => 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 composeNativeContentToolHandler(
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/index.js CHANGED
@@ -129,22 +129,6 @@ Available toolsets:
129
129
  if (hideBin(process.argv).some((arg) => arg.split('=')[0] === '--export-translations')) {
130
130
  process.stderr.write('--export-translations is deprecated and will be removed in a future release. Use --export-descriptions.\n');
131
131
  }
132
- // Dynamic toolsets are gone. yargs ignores the unknown flag, so without this the
133
- // server would start with a quietly different tool list: the flag used to drop
134
- // `all` from the enabled toolsets, so a setup that passed only this one went from
135
- // no toolsets plus three meta-tools to every toolset enabled.
136
- //
137
- // Only worth saying to someone who had it switched on. A setting left at `false`
138
- // asked for what it now gets, so a notice claiming the tool list changed would be
139
- // wrong.
140
- const asksForDynamicToolsets = (value) => value !== undefined &&
141
- !['', '0', 'false', 'no'].includes(value.toLowerCase());
142
- const dynamicToolsetsFlag = hideBin(process.argv).find((arg) => arg.split('=')[0] === '--dynamic-toolsets');
143
- if ((dynamicToolsetsFlag !== undefined &&
144
- asksForDynamicToolsets(dynamicToolsetsFlag.split('=')[1] ?? 'true')) ||
145
- asksForDynamicToolsets(process.env.ENABLE_DYNAMIC_TOOLSETS)) {
146
- process.stderr.write('Dynamic toolsets have been removed, and --dynamic-toolsets / ENABLE_DYNAMIC_TOOLSETS no longer do anything. Every toolset is enabled unless you narrow it with --enable-toolsets or ENABLE_TOOLSETS.\n');
147
- }
148
132
  const clientRegistry = oauthConfig
149
133
  ? createOAuthBacklogClientRegistry(oauthConfig.backlogDomain)
150
134
  : createBacklogClientRegistry();
package/build/lib.d.ts CHANGED
@@ -14,12 +14,14 @@
14
14
  */
15
15
  export { allTools } from './tools/tools.js';
16
16
  export { composeToolHandler } from './handlers/builders/composeToolHandler.js';
17
+ export { composeNativeContentToolHandler } from './handlers/builders/composeNativeContentToolHandler.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 { ComposeNativeContentOptions } from './handlers/builders/composeNativeContentToolHandler.js';
22
24
  export type { DescriptionHelper } from './createDescriptionHelper.js';
23
- export type { ToolDefinition, DynamicToolDefinition } from './types/tool.js';
24
- export type { Toolset, ToolsetGroup, DynamicToolset, DynamicToolsetGroup, } from './types/toolsets.js';
25
+ export type { ToolDefinition, NativeContentToolDefinition, } from './types/tool.js';
26
+ export type { Toolset, ToolsetGroup } from './types/toolsets.js';
25
27
  export type { ErrorLike, SafeResult } from './types/result.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 { composeNativeContentToolHandler } from './handlers/builders/composeNativeContentToolHandler.js';
17
18
  export { createDescriptionHelper } from './createDescriptionHelper.js';
18
19
  export { backlogErrorHandler } from './backlog/backlogErrorHandler.js';
19
20
  export { buildToolSchema } from './types/tool.js';
@@ -1,5 +1,4 @@
1
1
  import { MCPOptions } from './types/mcp.js';
2
- import { DynamicToolsetGroup, ToolsetGroup } from './types/toolsets.js';
2
+ import { ToolsetGroup } from './types/toolsets.js';
3
3
  import { BacklogMCPServer } from './utils/wrapServerWithToolRegistry.js';
4
4
  export declare function registerTools(server: BacklogMCPServer, toolsetGroup: ToolsetGroup, options: MCPOptions): void;
5
- export declare function registerDynamicTools(server: BacklogMCPServer, dynamicToolsetGroup: DynamicToolsetGroup, prefix: string): void;
@@ -1,31 +1,36 @@
1
1
  import { backlogErrorHandler } from './backlog/backlogErrorHandler.js';
2
+ import { composeNativeContentToolHandler } from './handlers/builders/composeNativeContentToolHandler.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;
5
6
  registerToolsets({
6
7
  server,
7
- toolsetGroup,
8
+ toolsets: toolsetGroup.toolsets,
8
9
  prefix,
9
- prepareTool: (tool) =>
10
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
- composeToolHandler(tool, {
10
+ prepareTool: (tool) => composeToolHandler(tool, {
12
11
  useFields,
13
12
  errorHandler: backlogErrorHandler,
14
13
  maxTokens,
15
14
  useOrganization,
16
15
  }),
17
16
  });
18
- }
19
- export function registerDynamicTools(server, dynamicToolsetGroup, prefix) {
17
+ // Tools that build their own result, registered from the same toolsets so
18
+ // that `--enable-toolsets` and the prefix cover them too.
20
19
  registerToolsets({
21
20
  server,
22
- toolsetGroup: dynamicToolsetGroup,
21
+ toolsets: toolsetGroup.toolsets.map((toolset) => ({
22
+ enabled: toolset.enabled,
23
+ tools: toolset.nativeContentTools ?? [],
24
+ })),
23
25
  prefix,
24
- prepareTool: (tool) => ({ schema: tool.schema, handler: tool.handler }),
26
+ prepareTool: (tool) => composeNativeContentToolHandler(tool, {
27
+ errorHandler: backlogErrorHandler,
28
+ useOrganization,
29
+ }),
25
30
  });
26
31
  }
27
- function registerToolsets({ server, toolsetGroup, prefix, prepareTool, }) {
28
- for (const toolset of toolsetGroup.toolsets) {
32
+ function registerToolsets({ server, toolsets, prefix, prepareTool, }) {
33
+ for (const toolset of toolsets) {
29
34
  if (!toolset.enabled) {
30
35
  continue;
31
36
  }
@@ -46,6 +46,7 @@ export const addDocumentTool = (backlog, { t }) => {
46
46
  'created',
47
47
  'updatedUser',
48
48
  'updated',
49
+ 'childDocumentIds',
49
50
  ]),
50
51
  importantFields: ['id', 'projectId', 'title', 'plain', 'createdUser'],
51
52
  handler: async (params) => {
@@ -26,6 +26,7 @@ export const getDocumentTool = (backlog, { t }) => {
26
26
  'created',
27
27
  'updatedUser',
28
28
  'updated',
29
+ 'childDocumentIds',
29
30
  ]),
30
31
  importantFields: ['id', 'title', 'createdUser'],
31
32
  handler: async ({ documentId }) => {
@@ -31,6 +31,7 @@ export const getDocumentsTool = (backlog, { t }) => {
31
31
  'created',
32
32
  'updatedUser',
33
33
  'updated',
34
+ 'childDocumentIds',
34
35
  ]),
35
36
  importantFields: ['id', 'projectId', 'title', 'plain'],
36
37
  handler: async ({ projectIds, offset }) => {
@@ -0,0 +1,16 @@
1
+ import { Backlog } from 'backlog-js';
2
+ import { z } from 'zod';
3
+ import { DescriptionHelper } from '../createDescriptionHelper.js';
4
+ import { NativeContentToolDefinition } 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) => NativeContentToolDefinition<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
+ };
@@ -35,6 +35,9 @@ export const getNotificationsTool = (backlog, { t }) => {
35
35
  'comment',
36
36
  'pullRequest',
37
37
  'pullRequestComment',
38
+ 'document',
39
+ 'documentComment',
40
+ 'documentCommentReply',
38
41
  'sender',
39
42
  'created',
40
43
  ]),
@@ -0,0 +1,12 @@
1
+ import { DescriptionHelper } from '../createDescriptionHelper.js';
2
+ import { BacklogClientRegistry } from '../utils/backlogClientRegistry.js';
3
+ import { ToolDefinition } from '../types/tool.js';
4
+ import { ToolsetGroup } from '../types/toolsets.js';
5
+ type OrganizationOutput = {
6
+ name: string;
7
+ domain: string;
8
+ isDefault: boolean;
9
+ };
10
+ export declare function organizationTools(registry: BacklogClientRegistry, { t }: DescriptionHelper): ToolsetGroup;
11
+ export declare function listOrganizationsTool(registry: BacklogClientRegistry, t: DescriptionHelper['t']): ToolDefinition<Record<string, never>, OrganizationOutput>;
12
+ export {};
@@ -16,17 +16,17 @@ export function listOrganizationsTool(registry, t) {
16
16
  name: 'list_organizations',
17
17
  description: t('TOOL_LIST_ORGANIZATIONS_DESCRIPTION', 'List configured Backlog organizations and identify the default organization.'),
18
18
  schema: z.object({}),
19
- handler: async () => {
20
- const organizations = registry.listOrganizations().map(toToolOutput);
21
- return {
22
- content: [
23
- {
24
- type: 'text',
25
- text: JSON.stringify(organizations, null, 2),
26
- },
27
- ],
28
- };
29
- },
19
+ outputFields: ['name', 'domain', 'isDefault'],
20
+ /**
21
+ * False even though the handler returns an array.
22
+ *
23
+ * `returnsList` decides whether `--optimize-response` publishes a `fields`
24
+ * parameter, and that pays off where a response grows without bound. This
25
+ * one is bounded by how many spaces the operator configured, over three
26
+ * fields — a `fields` enum would cost every client schema to trim nothing.
27
+ */
28
+ returnsList: false,
29
+ handler: async () => registry.listOrganizations().map(toToolOutput),
30
30
  };
31
31
  }
32
32
  function toToolOutput(organization) {
@@ -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
+ nativeContentTools: [getIssueAttachmentTool(backlog, helper)],
96
98
  tools: [
97
99
  getIssueTool(backlog, helper),
98
100
  getIssuesTool(backlog, helper),
@@ -29,7 +29,25 @@ export type ToolDefinition<Shape extends z.ZodRawShape, Result> = {
29
29
  returnsList: boolean;
30
30
  };
31
31
  export declare const buildToolSchema: <T extends z.ZodRawShape>(fn: (t: DescriptionHelper['t']) => T) => (t: DescriptionHelper['t']) => T;
32
- export type DynamicToolDefinition<Shape extends z.ZodRawShape> = {
32
+ /**
33
+ * A tool that assembles its own `CallToolResult`.
34
+ *
35
+ * The exception, not a second way of writing a tool: a `ToolDefinition` returns
36
+ * a plain value and the handler pipeline turns it into a result, which is what
37
+ * almost every tool wants. This type exists for the few whose result the
38
+ * pipeline cannot express or would corrupt — `wrapWithToolResult` ends a tool at
39
+ * exactly one text block, so `image` and `resource` content is unreachable
40
+ * through it, and `wrapWithTokenLimit` would cut a base64 payload mid-string and
41
+ * return it as `kind: 'ok'`, a corrupt file reported as a success.
42
+ *
43
+ * The name is the content, not the tool: these produce MCP content types
44
+ * natively rather than being reshaped into one. What they give up is everything
45
+ * the pipeline does — field picking, the token limit, JSON serialisation — so
46
+ * reach for it only when the result shape actually requires it.
47
+ * `composeNativeContentToolHandler` puts back the two steps that are not about
48
+ * reshaping, the organization context and error handling.
49
+ */
50
+ export type NativeContentToolDefinition<Shape extends z.ZodRawShape> = {
33
51
  name: string;
34
52
  description: string;
35
53
  schema: z.ZodObject<Shape>;
@@ -1,16 +1,23 @@
1
- import { DynamicToolDefinition, ToolDefinition } from './tool.js';
1
+ import { NativeContentToolDefinition, ToolDefinition } from './tool.js';
2
2
  type BaseToolset<TTool> = {
3
3
  name: string;
4
4
  description: string;
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
+ nativeContentTools?: NativeContentToolDefinition<any>[];
19
+ };
9
20
  export type ToolsetGroup = {
10
21
  toolsets: Toolset[];
11
22
  };
12
- export type DynamicToolset = BaseToolset<DynamicToolDefinition<any>>;
13
- export type DynamicToolsetGroup = {
14
- toolsets: DynamicToolset[];
15
- };
16
23
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backlog-mcp-server",
3
- "version": "0.18.1",
3
+ "version": "0.20.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.19.1",
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",
@@ -1,6 +0,0 @@
1
- import { DescriptionHelper } from '../../createDescriptionHelper.js';
2
- import { BacklogClientRegistry } from '../../utils/backlogClientRegistry.js';
3
- import { DynamicToolDefinition } from '../../types/tool.js';
4
- import { DynamicToolsetGroup } from '../../types/toolsets.js';
5
- export declare function organizationTools(registry: BacklogClientRegistry, { t }: DescriptionHelper): DynamicToolsetGroup;
6
- export declare function listOrganizationsTool(registry: BacklogClientRegistry, t: DescriptionHelper['t']): DynamicToolDefinition<Record<string, never>>;