backlog-mcp-server 0.19.0 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -1
- package/build/auth/backlogAuthContext.d.ts +19 -1
- package/build/auth/backlogAuthContext.js +30 -4
- package/build/auth/bearerAuthMiddleware.js +47 -32
- package/build/auth/tokenStore.d.ts +10 -0
- package/build/auth/tokenStore.js +13 -0
- package/build/backlog/backlogErrorHandler.js +15 -1
- package/build/backlog/parseBacklogAPIError.d.ts +8 -1
- package/build/backlog/parseBacklogAPIError.js +6 -2
- package/build/createBacklogMcpServer.js +7 -3
- package/build/handlers/builders/{composeDynamicToolHandler.d.ts → composeNativeContentToolHandler.d.ts} +5 -5
- package/build/handlers/builders/{composeDynamicToolHandler.js → composeNativeContentToolHandler.js} +1 -1
- package/build/httpMcpServer.js +3 -6
- package/build/index.js +0 -16
- package/build/lib.d.ts +4 -4
- package/build/lib.js +1 -1
- package/build/registerTools.d.ts +1 -2
- package/build/registerTools.js +13 -26
- package/build/tools/getIssueAttachment.d.ts +2 -2
- package/build/tools/organizations.d.ts +12 -0
- package/build/tools/{dynamicTools/organizations.js → organizations.js} +11 -11
- package/build/tools/tools.js +1 -3
- package/build/types/tool.d.ts +19 -1
- package/build/types/toolsets.d.ts +2 -6
- package/package.json +1 -1
- package/build/tools/deleteProject.d.ts +0 -11
- package/build/tools/deleteProject.js +0 -49
- package/build/tools/dynamicTools/organizations.d.ts +0 -6
package/README.md
CHANGED
|
@@ -287,7 +287,6 @@ Tools for managing projects, categories, custom fields, and issue types.
|
|
|
287
287
|
- `get_project`: Returns information about a specific project.
|
|
288
288
|
- `get_project_users`: Returns list of users in a specific project.
|
|
289
289
|
- `update_project`: Updates an existing project.
|
|
290
|
-
- `delete_project`: Deletes a project.
|
|
291
290
|
|
|
292
291
|
### Toolset: `issue`
|
|
293
292
|
|
|
@@ -1,2 +1,20 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Runs `fn` with the OAuth access token of the current request in scope.
|
|
3
|
+
*
|
|
4
|
+
* `onAuthError` is invoked the first time a Backlog call inside `fn` is
|
|
5
|
+
* rejected with an authentication error. It runs at the moment of detection
|
|
6
|
+
* rather than after `fn` settles, because a response that has already upgraded
|
|
7
|
+
* to SSE resolves before its handler finishes — the caller cannot rely on
|
|
8
|
+
* inspecting the outcome afterwards to invalidate anything.
|
|
9
|
+
*/
|
|
10
|
+
export declare function runWithAccessToken<T>(token: string, fn: () => Promise<T>, onAuthError?: () => void): Promise<T>;
|
|
2
11
|
export declare function getCurrentAccessToken(): string | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Reports that Backlog rejected the credentials of the current request.
|
|
14
|
+
*
|
|
15
|
+
* A no-op outside {@link runWithAccessToken}, which is how API-key mode stays
|
|
16
|
+
* unaffected: nothing establishes this context on the stdio transport.
|
|
17
|
+
*/
|
|
18
|
+
export declare function reportBacklogAuthError(): void;
|
|
19
|
+
/** Whether {@link reportBacklogAuthError} was called in the current context. */
|
|
20
|
+
export declare function hasBacklogAuthErrorBeenReported(): boolean;
|
|
@@ -1,10 +1,36 @@
|
|
|
1
1
|
// Copyright (c) 2025 Nulab inc.
|
|
2
2
|
// Licensed under the MIT License.
|
|
3
3
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
const authContextStorage = new AsyncLocalStorage();
|
|
5
|
+
/**
|
|
6
|
+
* Runs `fn` with the OAuth access token of the current request in scope.
|
|
7
|
+
*
|
|
8
|
+
* `onAuthError` is invoked the first time a Backlog call inside `fn` is
|
|
9
|
+
* rejected with an authentication error. It runs at the moment of detection
|
|
10
|
+
* rather than after `fn` settles, because a response that has already upgraded
|
|
11
|
+
* to SSE resolves before its handler finishes — the caller cannot rely on
|
|
12
|
+
* inspecting the outcome afterwards to invalidate anything.
|
|
13
|
+
*/
|
|
14
|
+
export function runWithAccessToken(token, fn, onAuthError) {
|
|
15
|
+
return authContextStorage.run({ accessToken: token, onAuthError, authErrorReported: false }, fn);
|
|
7
16
|
}
|
|
8
17
|
export function getCurrentAccessToken() {
|
|
9
|
-
return
|
|
18
|
+
return authContextStorage.getStore()?.accessToken;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Reports that Backlog rejected the credentials of the current request.
|
|
22
|
+
*
|
|
23
|
+
* A no-op outside {@link runWithAccessToken}, which is how API-key mode stays
|
|
24
|
+
* unaffected: nothing establishes this context on the stdio transport.
|
|
25
|
+
*/
|
|
26
|
+
export function reportBacklogAuthError() {
|
|
27
|
+
const context = authContextStorage.getStore();
|
|
28
|
+
if (!context || context.authErrorReported)
|
|
29
|
+
return;
|
|
30
|
+
context.authErrorReported = true;
|
|
31
|
+
context.onAuthError?.();
|
|
32
|
+
}
|
|
33
|
+
/** Whether {@link reportBacklogAuthError} was called in the current context. */
|
|
34
|
+
export function hasBacklogAuthErrorBeenReported() {
|
|
35
|
+
return authContextStorage.getStore()?.authErrorReported ?? false;
|
|
10
36
|
}
|
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
// Copyright (c) 2025 Nulab inc.
|
|
2
2
|
// Licensed under the MIT License.
|
|
3
3
|
import { verifyBacklogToken } from './backlogOAuthClient.js';
|
|
4
|
+
import { hasBacklogAuthErrorBeenReported, runWithAccessToken, } from './backlogAuthContext.js';
|
|
4
5
|
import { logger } from '../utils/logger.js';
|
|
5
6
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
6
7
|
export function createBearerAuthMiddleware(store, config, mcpPath) {
|
|
7
8
|
const prmPath = mcpPath === '/' ? '' : mcpPath;
|
|
8
9
|
const resourceMetadataUrl = `${config.serverBaseUrl}/.well-known/oauth-protected-resource${prmPath}`;
|
|
10
|
+
const wwwAuthenticate = (description) => description
|
|
11
|
+
? `Bearer error="invalid_token", error_description="${description}", resource_metadata="${resourceMetadataUrl}"`
|
|
12
|
+
: `Bearer resource_metadata="${resourceMetadataUrl}"`;
|
|
9
13
|
return async (c, next) => {
|
|
14
|
+
const unauthorized = (description, header = description) => {
|
|
15
|
+
c.header('WWW-Authenticate', wwwAuthenticate(header));
|
|
16
|
+
return c.json({ error: 'invalid_token', error_description: description }, 401);
|
|
17
|
+
};
|
|
10
18
|
const authHeader = c.req.header('authorization');
|
|
11
19
|
if (!authHeader) {
|
|
12
|
-
c.header('WWW-Authenticate',
|
|
20
|
+
c.header('WWW-Authenticate', wwwAuthenticate());
|
|
13
21
|
return c.json({
|
|
14
22
|
error: 'invalid_token',
|
|
15
23
|
error_description: 'Missing Authorization header',
|
|
@@ -17,42 +25,49 @@ export function createBearerAuthMiddleware(store, config, mcpPath) {
|
|
|
17
25
|
}
|
|
18
26
|
const [type, mcpToken] = authHeader.split(' ');
|
|
19
27
|
if (type?.toLowerCase() !== 'bearer' || !mcpToken) {
|
|
20
|
-
|
|
21
|
-
return c.json({ error: 'invalid_token', error_description: 'Expected Bearer token' }, 401);
|
|
28
|
+
return unauthorized('Expected Bearer token', 'Invalid Authorization header format');
|
|
22
29
|
}
|
|
23
30
|
const tokenEntry = store.getMcpToken(mcpToken);
|
|
24
31
|
if (!tokenEntry) {
|
|
25
|
-
|
|
26
|
-
return c.json({
|
|
27
|
-
error: 'invalid_token',
|
|
28
|
-
error_description: 'Unknown or expired token',
|
|
29
|
-
}, 401);
|
|
32
|
+
return unauthorized('Unknown or expired token');
|
|
30
33
|
}
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
let authInfo = store.getCachedVerification(mcpToken);
|
|
35
|
+
if (!authInfo) {
|
|
36
|
+
try {
|
|
37
|
+
const user = await verifyBacklogToken(config.backlogDomain, tokenEntry.backlogAccessToken);
|
|
38
|
+
authInfo = {
|
|
39
|
+
token: tokenEntry.backlogAccessToken,
|
|
40
|
+
clientId: String(user.id),
|
|
41
|
+
scopes: [],
|
|
42
|
+
expiresAt: Math.floor(Date.now() / 1000) + CACHE_TTL_MS / 1000,
|
|
43
|
+
};
|
|
44
|
+
store.cacheVerification(mcpToken, authInfo, CACHE_TTL_MS);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
logger.warn({ err }, 'Bearer token verification failed');
|
|
48
|
+
return unauthorized('Token verification failed');
|
|
49
|
+
}
|
|
36
50
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
51
|
+
c.set('authInfo', authInfo);
|
|
52
|
+
// The stored token is the only credential a downstream Backlog call has, so
|
|
53
|
+
// Backlog rejecting it is an authentication event and not a tool failure.
|
|
54
|
+
// Dropping the entry here is what lets the client recover: the next request
|
|
55
|
+
// fails the `getMcpToken` check above and is told to re-authenticate.
|
|
56
|
+
//
|
|
57
|
+
// Invalidation happens the moment the failure is reported rather than after
|
|
58
|
+
// `next()` settles, because a response that upgraded to SSE resolves before
|
|
59
|
+
// its handler has run.
|
|
60
|
+
const onAuthError = () => {
|
|
61
|
+
logger.warn({ clientId: tokenEntry.clientId }, 'Backlog rejected the stored access token; revoking the MCP token');
|
|
62
|
+
store.revokeMcpToken(mcpToken);
|
|
63
|
+
};
|
|
64
|
+
await runWithAccessToken(tokenEntry.backlogAccessToken, async () => {
|
|
47
65
|
await next();
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
error_description: 'Token verification failed',
|
|
55
|
-
}, 401);
|
|
56
|
-
}
|
|
66
|
+
// Read inside the context: `next()` resolving is not the end of the
|
|
67
|
+
// async scope, but it is the point at which the response is decided.
|
|
68
|
+
if (hasBacklogAuthErrorBeenReported()) {
|
|
69
|
+
c.res = unauthorized('Backlog rejected the access token');
|
|
70
|
+
}
|
|
71
|
+
}, onAuthError);
|
|
57
72
|
};
|
|
58
73
|
}
|
|
@@ -55,6 +55,16 @@ export declare function createTokenStore(): {
|
|
|
55
55
|
cacheVerification(token: string, authInfo: AuthInfo, ttlMs: number): void;
|
|
56
56
|
storeMcpToken(mcpToken: string, entry: McpTokenEntry): void;
|
|
57
57
|
getMcpToken(mcpToken: string): McpTokenEntry | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* Drops an MCP access token and its cached verification, so the next
|
|
60
|
+
* request on it is rejected by the bearer middleware.
|
|
61
|
+
*
|
|
62
|
+
* The refresh token is deliberately left in place: a Backlog 401 says the
|
|
63
|
+
* access token is spent, not that the grant is gone, so a client should be
|
|
64
|
+
* able to recover through the refresh grant before falling back to a full
|
|
65
|
+
* re-authorization.
|
|
66
|
+
*/
|
|
67
|
+
revokeMcpToken(mcpToken: string): void;
|
|
58
68
|
storeMcpRefreshToken(mcpRefreshToken: string, entry: McpRefreshEntry): void;
|
|
59
69
|
consumeMcpRefreshToken(mcpRefreshToken: string): McpRefreshEntry | undefined;
|
|
60
70
|
cleanup(): void;
|
package/build/auth/tokenStore.js
CHANGED
|
@@ -85,6 +85,19 @@ export function createTokenStore() {
|
|
|
85
85
|
}
|
|
86
86
|
return entry;
|
|
87
87
|
},
|
|
88
|
+
/**
|
|
89
|
+
* Drops an MCP access token and its cached verification, so the next
|
|
90
|
+
* request on it is rejected by the bearer middleware.
|
|
91
|
+
*
|
|
92
|
+
* The refresh token is deliberately left in place: a Backlog 401 says the
|
|
93
|
+
* access token is spent, not that the grant is gone, so a client should be
|
|
94
|
+
* able to recover through the refresh grant before falling back to a full
|
|
95
|
+
* re-authorization.
|
|
96
|
+
*/
|
|
97
|
+
revokeMcpToken(mcpToken) {
|
|
98
|
+
mcpAccessTokens.delete(mcpToken);
|
|
99
|
+
verificationCache.delete(mcpToken);
|
|
100
|
+
},
|
|
88
101
|
storeMcpRefreshToken(mcpRefreshToken, entry) {
|
|
89
102
|
mcpRefreshTokens.set(mcpRefreshToken, entry);
|
|
90
103
|
},
|
|
@@ -1,6 +1,20 @@
|
|
|
1
|
+
import { getCurrentAccessToken, reportBacklogAuthError, } from '../auth/backlogAuthContext.js';
|
|
1
2
|
import { parseBacklogAPIError } from './parseBacklogAPIError.js';
|
|
2
3
|
export const backlogErrorHandler = (err) => {
|
|
3
|
-
|
|
4
|
+
// Only an OAuth request carries an access token in its context; the stdio
|
|
5
|
+
// transport authenticates with an API key and never establishes one. The
|
|
6
|
+
// comparison is against `undefined` rather than truthiness so this asks
|
|
7
|
+
// exactly what `reportBacklogAuthError` asks — whether a context exists —
|
|
8
|
+
// and the two cannot answer differently for the same request.
|
|
9
|
+
const authMode = getCurrentAccessToken() !== undefined ? 'oauth' : 'apiKey';
|
|
10
|
+
const parsed = parseBacklogAPIError(err, { authMode });
|
|
11
|
+
// Backlog rejecting an OAuth token is authoritative: the credential this
|
|
12
|
+
// request was issued is spent. Reporting it lets the transport invalidate the
|
|
13
|
+
// token and tell the client to re-authenticate, instead of returning the
|
|
14
|
+
// failure as tool output the client cannot act on.
|
|
15
|
+
if (parsed.type === 'BacklogAuthError') {
|
|
16
|
+
reportBacklogAuthError();
|
|
17
|
+
}
|
|
4
18
|
return {
|
|
5
19
|
kind: 'error',
|
|
6
20
|
message: parsed.message,
|
|
@@ -5,4 +5,11 @@ export type ParsedBacklogAPIError = {
|
|
|
5
5
|
code?: number;
|
|
6
6
|
url?: string;
|
|
7
7
|
};
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* How the rejected request was authenticated. It decides only the wording of an
|
|
10
|
+
* authentication failure: in OAuth mode there is no API key to go and check.
|
|
11
|
+
*/
|
|
12
|
+
export type BacklogAuthMode = 'apiKey' | 'oauth';
|
|
13
|
+
export declare function parseBacklogAPIError(err: unknown, options?: {
|
|
14
|
+
authMode?: BacklogAuthMode;
|
|
15
|
+
}): ParsedBacklogAPIError;
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
const authErrorMessage = (status, authMode) => authMode === 'oauth'
|
|
2
|
+
? `Authentication failed (HTTP ${status}). The Backlog access token was rejected. Re-authenticate with Backlog, or check that your account has permission for this resource.`
|
|
3
|
+
: `Authentication failed (HTTP ${status}). Please check your API key or permissions.`;
|
|
4
|
+
export function parseBacklogAPIError(err, options = {}) {
|
|
5
|
+
const { authMode = 'apiKey' } = options;
|
|
2
6
|
const e = err;
|
|
3
7
|
if (e._name && e._status && e._url) {
|
|
4
8
|
const status = e._status;
|
|
@@ -8,7 +12,7 @@ export function parseBacklogAPIError(err) {
|
|
|
8
12
|
if (e._name === 'BacklogAuthError') {
|
|
9
13
|
return {
|
|
10
14
|
type: 'BacklogAuthError',
|
|
11
|
-
message:
|
|
15
|
+
message: authErrorMessage(status, authMode),
|
|
12
16
|
status,
|
|
13
17
|
url,
|
|
14
18
|
};
|
|
@@ -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 {
|
|
5
|
-
import { organizationTools } from './tools/
|
|
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
|
-
|
|
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
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { ErrorLike } from '../../types/result.js';
|
|
3
|
-
import {
|
|
4
|
-
export type
|
|
3
|
+
import { NativeContentToolDefinition } from '../../types/tool.js';
|
|
4
|
+
export type ComposeNativeContentOptions = {
|
|
5
5
|
errorHandler?: (err: unknown) => ErrorLike;
|
|
6
6
|
useOrganization?: boolean;
|
|
7
7
|
};
|
|
8
|
-
type
|
|
8
|
+
type NativeContentInput = {
|
|
9
9
|
organization?: string;
|
|
10
10
|
} & Record<string, unknown>;
|
|
11
11
|
/**
|
|
@@ -31,7 +31,7 @@ type DynamicInput = {
|
|
|
31
31
|
* definition is never mutated, because one toolset group is shared across
|
|
32
32
|
* per-request servers.
|
|
33
33
|
*/
|
|
34
|
-
export declare function
|
|
34
|
+
export declare function composeNativeContentToolHandler(tool: NativeContentToolDefinition<any>, { errorHandler, useOrganization }?: ComposeNativeContentOptions): {
|
|
35
35
|
schema: z.ZodObject<{
|
|
36
36
|
[x: string]: any;
|
|
37
37
|
organization: z.ZodOptional<z.ZodString>;
|
|
@@ -39,7 +39,7 @@ export declare function composeDynamicToolHandler(tool: DynamicToolDefinition<an
|
|
|
39
39
|
[x: string]: any;
|
|
40
40
|
organization?: undefined;
|
|
41
41
|
}, z.core.$strip>;
|
|
42
|
-
handler: (input:
|
|
42
|
+
handler: (input: NativeContentInput) => Promise<{
|
|
43
43
|
[x: string]: unknown;
|
|
44
44
|
_meta?: {
|
|
45
45
|
[x: string]: unknown;
|
package/build/handlers/builders/{composeDynamicToolHandler.js → composeNativeContentToolHandler.js}
RENAMED
|
@@ -25,7 +25,7 @@ import { isErrorLike } from '../../types/result.js';
|
|
|
25
25
|
* definition is never mutated, because one toolset group is shared across
|
|
26
26
|
* per-request servers.
|
|
27
27
|
*/
|
|
28
|
-
export function
|
|
28
|
+
export function composeNativeContentToolHandler(
|
|
29
29
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30
30
|
tool, { errorHandler, useOrganization = false } = {}) {
|
|
31
31
|
// `extend` even with nothing to add, so the returned schema is always a copy.
|
package/build/httpMcpServer.js
CHANGED
|
@@ -4,7 +4,6 @@ import { serve } from '@hono/node-server';
|
|
|
4
4
|
import { hostHeaderValidation, localhostHostValidation, localhostOriginValidation, originValidation, } from '@modelcontextprotocol/hono';
|
|
5
5
|
import { createMcpHandler } from '@modelcontextprotocol/server';
|
|
6
6
|
import { Hono } from 'hono';
|
|
7
|
-
import { runWithAccessToken } from './auth/backlogAuthContext.js';
|
|
8
7
|
import { logger } from './utils/logger.js';
|
|
9
8
|
const LOCALHOST_BINDS = ['127.0.0.1', 'localhost', '::1'];
|
|
10
9
|
export const runHttpMcpServer = async (options) => {
|
|
@@ -58,14 +57,12 @@ export const runHttpMcpServer = async (options) => {
|
|
|
58
57
|
responseMode: enableJsonResponse ? 'json' : 'auto',
|
|
59
58
|
onerror: (err) => logger.error({ err }, 'MCP handler error'),
|
|
60
59
|
});
|
|
60
|
+
// The bearer middleware owns the OAuth request context: it scopes the access
|
|
61
|
+
// token around this dispatch and converts a Backlog rejection into a 401.
|
|
61
62
|
app.all(mcpPath, async (c) => {
|
|
62
63
|
const authInfo = oauthEnabled ? c.get('authInfo') : undefined;
|
|
63
|
-
const accessToken = authInfo?.token;
|
|
64
64
|
try {
|
|
65
|
-
|
|
66
|
-
return accessToken
|
|
67
|
-
? await runWithAccessToken(accessToken, dispatch)
|
|
68
|
-
: await dispatch();
|
|
65
|
+
return await mcpHandler.fetch(c.req.raw, { authInfo });
|
|
69
66
|
}
|
|
70
67
|
catch (error) {
|
|
71
68
|
logger.error({ err: error }, 'Error handling MCP request');
|
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,14 +14,14 @@
|
|
|
14
14
|
*/
|
|
15
15
|
export { allTools } from './tools/tools.js';
|
|
16
16
|
export { composeToolHandler } from './handlers/builders/composeToolHandler.js';
|
|
17
|
-
export {
|
|
17
|
+
export { composeNativeContentToolHandler } from './handlers/builders/composeNativeContentToolHandler.js';
|
|
18
18
|
export { createDescriptionHelper } from './createDescriptionHelper.js';
|
|
19
19
|
export { backlogErrorHandler } from './backlog/backlogErrorHandler.js';
|
|
20
20
|
export { buildToolSchema } from './types/tool.js';
|
|
21
21
|
export { isErrorLike } from './types/result.js';
|
|
22
22
|
export type { ComposeOptions } from './handlers/builders/composeToolHandler.js';
|
|
23
|
-
export type {
|
|
23
|
+
export type { ComposeNativeContentOptions } from './handlers/builders/composeNativeContentToolHandler.js';
|
|
24
24
|
export type { DescriptionHelper } from './createDescriptionHelper.js';
|
|
25
|
-
export type { ToolDefinition,
|
|
26
|
-
export type { Toolset, ToolsetGroup
|
|
25
|
+
export type { ToolDefinition, NativeContentToolDefinition, } from './types/tool.js';
|
|
26
|
+
export type { Toolset, ToolsetGroup } from './types/toolsets.js';
|
|
27
27
|
export type { ErrorLike, SafeResult } from './types/result.js';
|
package/build/lib.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
export { allTools } from './tools/tools.js';
|
|
16
16
|
export { composeToolHandler } from './handlers/builders/composeToolHandler.js';
|
|
17
|
-
export {
|
|
17
|
+
export { composeNativeContentToolHandler } from './handlers/builders/composeNativeContentToolHandler.js';
|
|
18
18
|
export { createDescriptionHelper } from './createDescriptionHelper.js';
|
|
19
19
|
export { backlogErrorHandler } from './backlog/backlogErrorHandler.js';
|
|
20
20
|
export { buildToolSchema } from './types/tool.js';
|
package/build/registerTools.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { MCPOptions } from './types/mcp.js';
|
|
2
|
-
import {
|
|
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;
|
package/build/registerTools.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import { backlogErrorHandler } from './backlog/backlogErrorHandler.js';
|
|
2
|
-
import {
|
|
2
|
+
import { composeNativeContentToolHandler } from './handlers/builders/composeNativeContentToolHandler.js';
|
|
3
3
|
import { composeToolHandler } from './handlers/builders/composeToolHandler.js';
|
|
4
4
|
export function registerTools(server, toolsetGroup, options) {
|
|
5
5
|
const { useFields, maxTokens, prefix, useOrganization } = options;
|
|
6
6
|
registerToolsets({
|
|
7
7
|
server,
|
|
8
|
-
toolsetGroup,
|
|
8
|
+
toolsets: toolsetGroup.toolsets,
|
|
9
9
|
prefix,
|
|
10
|
-
prepareTool: (tool) =>
|
|
11
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
12
|
-
composeToolHandler(tool, {
|
|
10
|
+
prepareTool: (tool) => composeToolHandler(tool, {
|
|
13
11
|
useFields,
|
|
14
12
|
errorHandler: backlogErrorHandler,
|
|
15
13
|
maxTokens,
|
|
@@ -20,30 +18,19 @@ export function registerTools(server, toolsetGroup, options) {
|
|
|
20
18
|
// that `--enable-toolsets` and the prefix cover them too.
|
|
21
19
|
registerToolsets({
|
|
22
20
|
server,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
enabled: toolset.enabled,
|
|
28
|
-
tools: toolset.dynamicTools ?? [],
|
|
29
|
-
})),
|
|
30
|
-
},
|
|
21
|
+
toolsets: toolsetGroup.toolsets.map((toolset) => ({
|
|
22
|
+
enabled: toolset.enabled,
|
|
23
|
+
tools: toolset.nativeContentTools ?? [],
|
|
24
|
+
})),
|
|
31
25
|
prefix,
|
|
32
|
-
prepareTool: (tool) =>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
export function registerDynamicTools(server, dynamicToolsetGroup, prefix) {
|
|
38
|
-
registerToolsets({
|
|
39
|
-
server,
|
|
40
|
-
toolsetGroup: dynamicToolsetGroup,
|
|
41
|
-
prefix,
|
|
42
|
-
prepareTool: (tool) => ({ schema: tool.schema, handler: tool.handler }),
|
|
26
|
+
prepareTool: (tool) => composeNativeContentToolHandler(tool, {
|
|
27
|
+
errorHandler: backlogErrorHandler,
|
|
28
|
+
useOrganization,
|
|
29
|
+
}),
|
|
43
30
|
});
|
|
44
31
|
}
|
|
45
|
-
function registerToolsets({ server,
|
|
46
|
-
for (const toolset of
|
|
32
|
+
function registerToolsets({ server, toolsets, prefix, prepareTool, }) {
|
|
33
|
+
for (const toolset of toolsets) {
|
|
47
34
|
if (!toolset.enabled) {
|
|
48
35
|
continue;
|
|
49
36
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Backlog } from 'backlog-js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { DescriptionHelper } from '../createDescriptionHelper.js';
|
|
4
|
-
import {
|
|
4
|
+
import { NativeContentToolDefinition } from '../types/tool.js';
|
|
5
5
|
declare const getIssueAttachmentSchema: (t: DescriptionHelper['t']) => {
|
|
6
6
|
issueId: z.ZodOptional<z.ZodNumber>;
|
|
7
7
|
issueKey: z.ZodOptional<z.ZodString>;
|
|
@@ -12,5 +12,5 @@ declare const getIssueAttachmentSchema: (t: DescriptionHelper['t']) => {
|
|
|
12
12
|
}>>;
|
|
13
13
|
maxBytes: z.ZodOptional<z.ZodNumber>;
|
|
14
14
|
};
|
|
15
|
-
export declare const getIssueAttachmentTool: (backlog: Backlog, { t }: DescriptionHelper) =>
|
|
15
|
+
export declare const getIssueAttachmentTool: (backlog: Backlog, { t }: DescriptionHelper) => NativeContentToolDefinition<ReturnType<typeof getIssueAttachmentSchema>>;
|
|
16
16
|
export {};
|
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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) {
|
package/build/tools/tools.js
CHANGED
|
@@ -7,7 +7,6 @@ import { addWikiTool } from './addWiki.js';
|
|
|
7
7
|
import { updateWikiTool } from './updateWiki.js';
|
|
8
8
|
import { countIssuesTool } from './countIssues.js';
|
|
9
9
|
import { deleteIssueTool } from './deleteIssue.js';
|
|
10
|
-
import { deleteProjectTool } from './deleteProject.js';
|
|
11
10
|
import { getCategoriesTool } from './getCategories.js';
|
|
12
11
|
import { getCustomFieldsTool } from './getCustomFields.js';
|
|
13
12
|
import { getGitRepositoriesTool } from './getGitRepositories.js';
|
|
@@ -87,14 +86,13 @@ export const allTools = (backlog, helper) => {
|
|
|
87
86
|
getProjectTool(backlog, helper),
|
|
88
87
|
getProjectUsersTool(backlog, helper),
|
|
89
88
|
updateProjectTool(backlog, helper),
|
|
90
|
-
deleteProjectTool(backlog, helper),
|
|
91
89
|
],
|
|
92
90
|
},
|
|
93
91
|
{
|
|
94
92
|
name: 'issue',
|
|
95
93
|
description: 'Tools for managing issues and their comments.',
|
|
96
94
|
enabled: false,
|
|
97
|
-
|
|
95
|
+
nativeContentTools: [getIssueAttachmentTool(backlog, helper)],
|
|
98
96
|
tools: [
|
|
99
97
|
getIssueTool(backlog, helper),
|
|
100
98
|
getIssuesTool(backlog, helper),
|
package/build/types/tool.d.ts
CHANGED
|
@@ -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
|
-
|
|
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,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { NativeContentToolDefinition, ToolDefinition } from './tool.js';
|
|
2
2
|
type BaseToolset<TTool> = {
|
|
3
3
|
name: string;
|
|
4
4
|
description: string;
|
|
@@ -15,13 +15,9 @@ export type Toolset = BaseToolset<ToolDefinition<any, any>> & {
|
|
|
15
15
|
* must not be. Keeping them in one toolset is what makes `--enable-toolsets`
|
|
16
16
|
* and the prefix apply to both.
|
|
17
17
|
*/
|
|
18
|
-
|
|
18
|
+
nativeContentTools?: NativeContentToolDefinition<any>[];
|
|
19
19
|
};
|
|
20
20
|
export type ToolsetGroup = {
|
|
21
21
|
toolsets: Toolset[];
|
|
22
22
|
};
|
|
23
|
-
export type DynamicToolset = BaseToolset<DynamicToolDefinition<any>>;
|
|
24
|
-
export type DynamicToolsetGroup = {
|
|
25
|
-
toolsets: DynamicToolset[];
|
|
26
|
-
};
|
|
27
23
|
export {};
|
package/package.json
CHANGED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
import type { Entity } from 'backlog-js';
|
|
3
|
-
import { Backlog } from 'backlog-js';
|
|
4
|
-
import { ToolDefinition } from '../types/tool.js';
|
|
5
|
-
import { DescriptionHelper } from '../createDescriptionHelper.js';
|
|
6
|
-
declare const deleteProjectSchema: (t: DescriptionHelper['t']) => {
|
|
7
|
-
projectId: z.ZodOptional<z.ZodNumber>;
|
|
8
|
-
projectKey: z.ZodOptional<z.ZodString>;
|
|
9
|
-
};
|
|
10
|
-
export declare const deleteProjectTool: (backlog: Backlog, { t }: DescriptionHelper) => ToolDefinition<ReturnType<typeof deleteProjectSchema>, Entity.Project.Project>;
|
|
11
|
-
export {};
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
import { buildToolSchema } from '../types/tool.js';
|
|
3
|
-
import { outputFields } from '../types/outputFields.js';
|
|
4
|
-
import { resolveIdOrKey } from '../utils/resolveIdOrKey.js';
|
|
5
|
-
const deleteProjectSchema = buildToolSchema((t) => ({
|
|
6
|
-
projectId: z
|
|
7
|
-
.number()
|
|
8
|
-
.optional()
|
|
9
|
-
.describe(t('TOOL_DELETE_PROJECT_PROJECT_ID', 'The numeric ID of the project (e.g., 12345)')),
|
|
10
|
-
projectKey: z
|
|
11
|
-
.string()
|
|
12
|
-
.optional()
|
|
13
|
-
.describe(t('TOOL_DELETE_PROJECT_PROJECT_KEY', "The key of the project (e.g., 'PROJECT')")),
|
|
14
|
-
}));
|
|
15
|
-
export const deleteProjectTool = (backlog, { t }) => {
|
|
16
|
-
return {
|
|
17
|
-
name: 'delete_project',
|
|
18
|
-
description: t('TOOL_DELETE_PROJECT_DESCRIPTION', 'Deletes a project'),
|
|
19
|
-
schema: z.object(deleteProjectSchema(t)),
|
|
20
|
-
returnsList: false,
|
|
21
|
-
outputFields: outputFields()([
|
|
22
|
-
'id',
|
|
23
|
-
'projectKey',
|
|
24
|
-
'name',
|
|
25
|
-
'chartEnabled',
|
|
26
|
-
'useResolvedForChart',
|
|
27
|
-
'subtaskingEnabled',
|
|
28
|
-
'projectLeaderCanEditProjectLeader',
|
|
29
|
-
'useWiki',
|
|
30
|
-
'useFileSharing',
|
|
31
|
-
'useWikiTreeView',
|
|
32
|
-
'useOriginalImageSizeAtWiki',
|
|
33
|
-
'useSubversion',
|
|
34
|
-
'useGit',
|
|
35
|
-
'textFormattingRule',
|
|
36
|
-
'archived',
|
|
37
|
-
'displayOrder',
|
|
38
|
-
'useDevAttributes',
|
|
39
|
-
'grandchildIssueEnabled',
|
|
40
|
-
]),
|
|
41
|
-
handler: async ({ projectId, projectKey }) => {
|
|
42
|
-
const result = resolveIdOrKey('project', { id: projectId, key: projectKey }, t);
|
|
43
|
-
if (!result.ok) {
|
|
44
|
-
throw result.error;
|
|
45
|
-
}
|
|
46
|
-
return backlog.deleteProject(result.value);
|
|
47
|
-
},
|
|
48
|
-
};
|
|
49
|
-
};
|
|
@@ -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>>;
|