@kammcs/buildit-mcp 0.0.0-stage → 0.2.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/CHANGELOG.md +84 -0
- package/LICENSE +21 -0
- package/README.md +325 -2
- package/SECURITY.md +48 -0
- package/dist/api/client.js +382 -0
- package/dist/api/generated/operations.js +1615 -0
- package/dist/api/generated/schemas.js +1413 -0
- package/dist/api/generated/strict.js +1681 -0
- package/dist/api/operations.js +43 -0
- package/dist/config.js +280 -0
- package/dist/errors.js +347 -0
- package/dist/identity.js +124 -0
- package/dist/index.js +73 -0
- package/dist/log.js +90 -0
- package/dist/prompts.js +114 -0
- package/dist/resources.js +55 -0
- package/dist/server.js +257 -0
- package/dist/tools/chat.js +273 -0
- package/dist/tools/comments.js +128 -0
- package/dist/tools/items-read.js +603 -0
- package/dist/tools/items-write.js +580 -0
- package/dist/tools/pages.js +405 -0
- package/dist/tools/planning.js +697 -0
- package/dist/tools/plans.js +906 -0
- package/dist/tools/projects.js +415 -0
- package/dist/tools/setup.js +292 -0
- package/dist/tools/shared.js +462 -0
- package/dist/tools/whoami.js +230 -0
- package/dist/toolsets/catalog.js +72 -0
- package/dist/toolsets/registry.js +97 -0
- package/dist/toolsets/toolsets.js +89 -0
- package/dist/transports/http.js +212 -0
- package/dist/transports/stdio.js +203 -0
- package/dist/untrusted.js +466 -0
- package/dist/version.js +28 -0
- package/package.json +75 -4
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every tool, resource and prompt this server can offer. Add new ones here
|
|
3
|
+
* (see CLAUDE.md). Order doesn't matter: the registry sorts the list it
|
|
4
|
+
* serves.
|
|
5
|
+
*/
|
|
6
|
+
import { PROMPTS } from '../prompts.js';
|
|
7
|
+
import { RESOURCES } from '../resources.js';
|
|
8
|
+
import { listChannelsTool, readChannelTool, readThreadTool } from '../tools/chat.js';
|
|
9
|
+
import { addCommentTool, listCommentsTool } from '../tools/comments.js';
|
|
10
|
+
import { getItemTool, searchItemsTool } from '../tools/items-read.js';
|
|
11
|
+
import { assignItemTool, createItemTool, linkItemsTool, rankItemTool, transitionItemTool, unlinkItemsTool, updateItemTool, } from '../tools/items-write.js';
|
|
12
|
+
import { createPageTool, getPageTool, listPagesTool, searchPagesTool, updatePageTool, } from '../tools/pages.js';
|
|
13
|
+
import { listReleasesTool, listSprintsTool, planReleaseTool, planSprintTool, writeReleaseNotesTool, } from '../tools/planning.js';
|
|
14
|
+
import { applyPlanTool, getWorkflowTool, listWorkTypesTool, proposeArchiveStatusTool, proposeBulkUpdateTool, proposeDeleteItemTool, proposeFieldChangeTool, proposeLabelChangeTool, proposeMoveItemTool, proposeWorkflowChangeTool, proposeWorkTypeChangeTool, } from '../tools/plans.js';
|
|
15
|
+
import { describeProjectTool, findUsersTool, listProjectsTool } from '../tools/projects.js';
|
|
16
|
+
import { createChannelTool, createProjectTool } from '../tools/setup.js';
|
|
17
|
+
import { whoamiTool } from '../tools/whoami.js';
|
|
18
|
+
export const CATALOG = [
|
|
19
|
+
// items
|
|
20
|
+
whoamiTool,
|
|
21
|
+
listProjectsTool,
|
|
22
|
+
describeProjectTool,
|
|
23
|
+
findUsersTool,
|
|
24
|
+
searchItemsTool,
|
|
25
|
+
getItemTool,
|
|
26
|
+
createItemTool,
|
|
27
|
+
updateItemTool,
|
|
28
|
+
assignItemTool,
|
|
29
|
+
transitionItemTool,
|
|
30
|
+
linkItemsTool,
|
|
31
|
+
unlinkItemsTool,
|
|
32
|
+
rankItemTool,
|
|
33
|
+
// comments
|
|
34
|
+
listCommentsTool,
|
|
35
|
+
addCommentTool,
|
|
36
|
+
// planning
|
|
37
|
+
listSprintsTool,
|
|
38
|
+
planSprintTool,
|
|
39
|
+
listReleasesTool,
|
|
40
|
+
planReleaseTool,
|
|
41
|
+
writeReleaseNotesTool,
|
|
42
|
+
// pages
|
|
43
|
+
listPagesTool,
|
|
44
|
+
searchPagesTool,
|
|
45
|
+
getPageTool,
|
|
46
|
+
createPageTool,
|
|
47
|
+
updatePageTool,
|
|
48
|
+
// chat
|
|
49
|
+
listChannelsTool,
|
|
50
|
+
readChannelTool,
|
|
51
|
+
readThreadTool,
|
|
52
|
+
// admin
|
|
53
|
+
createChannelTool,
|
|
54
|
+
createProjectTool,
|
|
55
|
+
getWorkflowTool,
|
|
56
|
+
listWorkTypesTool,
|
|
57
|
+
proposeWorkflowChangeTool,
|
|
58
|
+
proposeWorkTypeChangeTool,
|
|
59
|
+
proposeFieldChangeTool,
|
|
60
|
+
proposeLabelChangeTool,
|
|
61
|
+
// destructive
|
|
62
|
+
proposeDeleteItemTool,
|
|
63
|
+
proposeMoveItemTool,
|
|
64
|
+
proposeBulkUpdateTool,
|
|
65
|
+
proposeArchiveStatusTool,
|
|
66
|
+
// listed with any propose_* tool
|
|
67
|
+
applyPlanTool,
|
|
68
|
+
];
|
|
69
|
+
/** Resource templates (buildit://items/{key}, buildit://pages/{id}). */
|
|
70
|
+
export const RESOURCE_CATALOG = RESOURCES;
|
|
71
|
+
/** Prompts (plan_epic, triage, standup). */
|
|
72
|
+
export const PROMPT_CATALOG = PROMPTS;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { callOperation } from '../api/operations.js';
|
|
2
|
+
import { expandScopes, TOOLSETS } from './toolsets.js';
|
|
3
|
+
/** The context for one call of `tool`. */
|
|
4
|
+
export function createToolContext(init) {
|
|
5
|
+
const apiOptions = {
|
|
6
|
+
tool: init.tool,
|
|
7
|
+
...(init.client ? { client: init.client } : {}),
|
|
8
|
+
...(init.signal ? { signal: init.signal } : {}),
|
|
9
|
+
};
|
|
10
|
+
return {
|
|
11
|
+
api: init.api,
|
|
12
|
+
logger: init.logger,
|
|
13
|
+
...(init.signal ? { signal: init.signal } : {}),
|
|
14
|
+
apiOptions,
|
|
15
|
+
call: (id, args = {}) => callOperation(init.api, id, args, apiOptions),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** Identity helper that keeps the argument and output types tied to the schemas. */
|
|
19
|
+
export function defineTool(definition) {
|
|
20
|
+
return definition;
|
|
21
|
+
}
|
|
22
|
+
const TOOLSET_ORDER = new Map(TOOLSETS.map((t, i) => [t.name, i]));
|
|
23
|
+
function compareTools(a, b) {
|
|
24
|
+
const byToolset = (TOOLSET_ORDER.get(a.toolset) ?? 99) - (TOOLSET_ORDER.get(b.toolset) ?? 99);
|
|
25
|
+
if (byToolset !== 0)
|
|
26
|
+
return byToolset;
|
|
27
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The tools a caller may see, in a fixed order (toolset order, then name).
|
|
31
|
+
*
|
|
32
|
+
* @param grantedScopes the token's scopes from /v1/me, or `null` to skip the
|
|
33
|
+
* scope rule (used when serving a call: the API checks scopes itself).
|
|
34
|
+
* @param limited whether /v1/me shows project or channel limits on the token:
|
|
35
|
+
* tools marked `unlimitedOnly` are then left out (ignored when
|
|
36
|
+
* `grantedScopes` is null, since the identity isn't known).
|
|
37
|
+
*/
|
|
38
|
+
export function selectTools(catalog, policy, grantedScopes, limited = false) {
|
|
39
|
+
const toolsets = new Set(policy.toolsets);
|
|
40
|
+
const excluded = new Set(policy.excludeTools);
|
|
41
|
+
const scopes = grantedScopes === null ? null : expandScopes(grantedScopes);
|
|
42
|
+
const allowed = (tool) => (!policy.readOnly || tool.annotations.readOnlyHint) && !excluded.has(tool.name);
|
|
43
|
+
const listed = catalog
|
|
44
|
+
.filter((tool) => tool.withPlans !== true)
|
|
45
|
+
.filter((tool) => toolsets.has(tool.toolset))
|
|
46
|
+
.filter(allowed)
|
|
47
|
+
.filter((tool) => scopes === null || tool.scopes.every((s) => scopes.has(s)))
|
|
48
|
+
.filter((tool) => scopes === null || !limited || tool.unlimitedOnly !== true);
|
|
49
|
+
const proposes = listed.some((tool) => tool.name.startsWith(PROPOSE_PREFIX));
|
|
50
|
+
const companions = proposes
|
|
51
|
+
? catalog.filter((tool) => tool.withPlans === true).filter(allowed)
|
|
52
|
+
: [];
|
|
53
|
+
return [...listed, ...companions].sort(compareTools);
|
|
54
|
+
}
|
|
55
|
+
/** Tools that propose a plan start with this; apply_plan comes with them. */
|
|
56
|
+
export const PROPOSE_PREFIX = 'propose_';
|
|
57
|
+
/** The resources or prompts a caller may see: by toolset and, when known, scopes. */
|
|
58
|
+
export function selectGated(defs, toolsets, grantedScopes) {
|
|
59
|
+
const enabled = new Set(toolsets);
|
|
60
|
+
const scopes = grantedScopes === null ? null : expandScopes(grantedScopes);
|
|
61
|
+
return defs
|
|
62
|
+
.filter((d) => enabled.has(d.toolset))
|
|
63
|
+
.filter((d) => scopes === null || d.scopes.every((s) => scopes.has(s)));
|
|
64
|
+
}
|
|
65
|
+
export function definePrompt(definition) {
|
|
66
|
+
return definition;
|
|
67
|
+
}
|
|
68
|
+
/** Names in `names` that no tool in the catalog has (for a startup warning). */
|
|
69
|
+
export function unknownToolNames(catalog, names) {
|
|
70
|
+
const known = new Set(catalog.map((t) => t.name));
|
|
71
|
+
return names.filter((n) => !known.has(n));
|
|
72
|
+
}
|
|
73
|
+
/** Throws if the catalog breaks the rules every tool must follow (checked by the tests). */
|
|
74
|
+
export function assertValidCatalog(catalog) {
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
for (const tool of catalog) {
|
|
77
|
+
if (!/^[a-z][a-z0-9_]{0,63}$/.test(tool.name))
|
|
78
|
+
throw new Error(`Bad tool name: ${tool.name}`);
|
|
79
|
+
if (seen.has(tool.name))
|
|
80
|
+
throw new Error(`Duplicate tool name: ${tool.name}`);
|
|
81
|
+
seen.add(tool.name);
|
|
82
|
+
const toolset = TOOLSETS.find((t) => t.name === tool.toolset);
|
|
83
|
+
if (!toolset)
|
|
84
|
+
throw new Error(`${tool.name}: unknown toolset ${tool.toolset}`);
|
|
85
|
+
for (const scope of tool.scopes) {
|
|
86
|
+
if (!toolset.scopes.includes(scope)) {
|
|
87
|
+
throw new Error(`${tool.name}: scope ${scope} is not declared by toolset ${tool.toolset}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (tool.annotations.readOnlyHint && tool.annotations.destructiveHint) {
|
|
91
|
+
throw new Error(`${tool.name}: a read-only tool can't be destructive`);
|
|
92
|
+
}
|
|
93
|
+
if (tool.withPlans === true && tool.scopes.length > 0) {
|
|
94
|
+
throw new Error(`${tool.name}: a tool listed with plans takes the plan's scope, not its own`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The toolsets and the token scopes their tools use.
|
|
3
|
+
*
|
|
4
|
+
* A toolset is a group of tools that can be switched on or off together
|
|
5
|
+
* (BUILDIT_TOOLSETS, --toolsets, or the X-Buildit-Toolsets header in HTTP
|
|
6
|
+
* mode). Each tool also declares the exact scopes it needs; a tool is only
|
|
7
|
+
* listed when the token has them.
|
|
8
|
+
*/
|
|
9
|
+
import { SCOPE_IMPLIES, SCOPES } from '../api/generated/operations.js';
|
|
10
|
+
/** Token scopes defined by the agent API (from the contract). */
|
|
11
|
+
export { SCOPE_IMPLIES, SCOPES };
|
|
12
|
+
/**
|
|
13
|
+
* Adds the implied scopes, transitively, as the contract defines them
|
|
14
|
+
* (projects:delete implies projects:write, which implies projects:read).
|
|
15
|
+
* Unknown scope strings are kept as they are. The API is the authority; this
|
|
16
|
+
* only keeps the tool list from hiding tools a granted scope allows.
|
|
17
|
+
*/
|
|
18
|
+
export function expandScopes(granted) {
|
|
19
|
+
const out = new Set();
|
|
20
|
+
const pending = [...granted];
|
|
21
|
+
for (let scope = pending.pop(); scope !== undefined; scope = pending.pop()) {
|
|
22
|
+
if (out.has(scope))
|
|
23
|
+
continue;
|
|
24
|
+
out.add(scope);
|
|
25
|
+
if (Object.hasOwn(SCOPE_IMPLIES, scope))
|
|
26
|
+
pending.push(...SCOPE_IMPLIES[scope]);
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
export const TOOLSET_NAMES = [
|
|
31
|
+
'items',
|
|
32
|
+
'comments',
|
|
33
|
+
'planning',
|
|
34
|
+
'pages',
|
|
35
|
+
'chat',
|
|
36
|
+
'admin',
|
|
37
|
+
'destructive',
|
|
38
|
+
];
|
|
39
|
+
/** In the order tools are listed. */
|
|
40
|
+
export const TOOLSETS = [
|
|
41
|
+
{
|
|
42
|
+
name: 'items',
|
|
43
|
+
description: 'Projects and their items: search, read, create, update, transition, assign, link.',
|
|
44
|
+
defaultEnabled: true,
|
|
45
|
+
scopes: ['projects:read', 'projects:write'],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'comments',
|
|
49
|
+
description: 'Comments on items: list and add.',
|
|
50
|
+
defaultEnabled: true,
|
|
51
|
+
scopes: ['projects:read', 'projects:write'],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'planning',
|
|
55
|
+
description: 'Sprints and releases, and release notes pages.',
|
|
56
|
+
defaultEnabled: false,
|
|
57
|
+
// Release notes are written as a page.
|
|
58
|
+
scopes: ['projects:read', 'projects:write', 'pages:write'],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'pages',
|
|
62
|
+
description: 'Channel pages: list, read, create and edit.',
|
|
63
|
+
defaultEnabled: false,
|
|
64
|
+
scopes: ['pages:read', 'pages:write'],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'chat',
|
|
68
|
+
description: 'Channel messages and threads (read only; never direct messages).',
|
|
69
|
+
defaultEnabled: false,
|
|
70
|
+
scopes: ['chat:read'],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'admin',
|
|
74
|
+
description: 'New channels and projects; workflows, work types, fields and labels, each change previewed and confirmed.',
|
|
75
|
+
defaultEnabled: false,
|
|
76
|
+
scopes: ['projects:read', 'projects:admin'],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'destructive',
|
|
80
|
+
description: 'Delete, move, bulk update and archive; every change is previewed and confirmed.',
|
|
81
|
+
defaultEnabled: false,
|
|
82
|
+
// Archiving a status also changes the workflow.
|
|
83
|
+
scopes: ['projects:read', 'projects:delete', 'projects:admin'],
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
export const DEFAULT_TOOLSETS = TOOLSETS.filter((t) => t.defaultEnabled).map((t) => t.name);
|
|
87
|
+
export function isToolsetName(value) {
|
|
88
|
+
return TOOLSET_NAMES.includes(value);
|
|
89
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streamable HTTP mode: stateless, one MCP server instance per request.
|
|
3
|
+
*
|
|
4
|
+
* - Every POST must carry `Authorization: Bearer <token>`; that request's
|
|
5
|
+
* API calls use that token, and nothing is kept afterwards.
|
|
6
|
+
* - Host and Origin are checked before anything else (DNS-rebinding
|
|
7
|
+
* protection): on a loopback bind only localhost names pass; elsewhere the
|
|
8
|
+
* configured lists apply, and any request with an Origin header is refused
|
|
9
|
+
* unless its hostname is listed. Off loopback, allowed hosts are required
|
|
10
|
+
* unless the operator explicitly opts out (allowAnyHost).
|
|
11
|
+
* - GET and DELETE on the endpoint answer 405: there are no sessions to
|
|
12
|
+
* resume or end, and no standalone event stream.
|
|
13
|
+
* - `X-Buildit-Toolsets` narrows the toolsets for one request: only toolsets
|
|
14
|
+
* the server's own config enables can be picked, and read-only mode and
|
|
15
|
+
* the exclude list still apply. The header can never add a toolset.
|
|
16
|
+
* - Both protocol eras are served: 2026-07-28 (stateless, per-request _meta)
|
|
17
|
+
* and the 2025-11-25 initialize handshake (through the SDK's stateless
|
|
18
|
+
* fallback).
|
|
19
|
+
*/
|
|
20
|
+
import { randomUUID } from 'node:crypto';
|
|
21
|
+
import { serve } from '@hono/node-server';
|
|
22
|
+
import { hostHeaderValidation, originValidation } from '@modelcontextprotocol/hono';
|
|
23
|
+
import { createMcpHandler, isJsonContentType, localhostAllowedHostnames, localhostAllowedOrigins, readRequestBody, } from '@modelcontextprotocol/server';
|
|
24
|
+
import { Hono } from 'hono';
|
|
25
|
+
import { ApiClient } from '../api/client.js';
|
|
26
|
+
import { ConfigError, isLoopbackHost, parseToolsets } from '../config.js';
|
|
27
|
+
import { IdentityCache, invalidatesIdentity } from '../identity.js';
|
|
28
|
+
import { createMcpServer, DEGRADED_LIST_TTL_MS, listedByPolicy, resolveListedTools, identityFromApi, } from '../server.js';
|
|
29
|
+
import { CATALOG } from '../toolsets/catalog.js';
|
|
30
|
+
export const MCP_PATH = '/mcp';
|
|
31
|
+
export const TOOLSETS_HEADER = 'x-buildit-toolsets';
|
|
32
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
33
|
+
function jsonRpcError(code, message) {
|
|
34
|
+
return { jsonrpc: '2.0', error: { code, message }, id: null };
|
|
35
|
+
}
|
|
36
|
+
/** The bearer token from an Authorization header, or undefined. */
|
|
37
|
+
export function bearerToken(header) {
|
|
38
|
+
if (header === undefined)
|
|
39
|
+
return undefined;
|
|
40
|
+
const match = /^Bearer[ \t]+([^\s]+)[ \t]*$/i.exec(header.trim());
|
|
41
|
+
return match?.[1];
|
|
42
|
+
}
|
|
43
|
+
/** The list methods whose answer depends on the token's scopes. */
|
|
44
|
+
const LIST_METHODS = new Set([
|
|
45
|
+
'tools/list',
|
|
46
|
+
'resources/list',
|
|
47
|
+
'resources/templates/list',
|
|
48
|
+
'prompts/list',
|
|
49
|
+
]);
|
|
50
|
+
/** Whether a JSON-RPC body (single or batch) lists tools, resources or prompts. */
|
|
51
|
+
function listsTools(body) {
|
|
52
|
+
const messages = Array.isArray(body) ? body : [body];
|
|
53
|
+
return messages.some((m) => {
|
|
54
|
+
const method = m !== null && typeof m === 'object' ? m.method : undefined;
|
|
55
|
+
return typeof method === 'string' && LIST_METHODS.has(method);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
export function createHttpApp(config, logger, options = {}) {
|
|
59
|
+
const catalog = options.catalog ?? CATALOG;
|
|
60
|
+
const loopback = isLoopbackHost(config.http.host);
|
|
61
|
+
if (!loopback && config.http.allowedHosts === undefined && config.http.allowAnyHost !== true) {
|
|
62
|
+
throw new ConfigError(`Binding to ${config.http.host} needs allowed hosts (BUILDIT_ALLOWED_HOSTS), or BUILDIT_ALLOW_ANY_HOST=true to serve without the Host check.`);
|
|
63
|
+
}
|
|
64
|
+
const allowedHosts = config.http.allowedHosts ?? (loopback ? localhostAllowedHostnames() : undefined);
|
|
65
|
+
const allowedOrigins = config.http.allowedOrigins ?? (loopback ? localhostAllowedOrigins() : []);
|
|
66
|
+
const basePolicy = {
|
|
67
|
+
readOnly: config.readOnly,
|
|
68
|
+
excludeTools: config.excludeTools,
|
|
69
|
+
};
|
|
70
|
+
// The token's scopes and limits, briefly, so listing doesn't read /v1/me on every request.
|
|
71
|
+
const identities = new IdentityCache(options.identityCache);
|
|
72
|
+
const handler = createMcpHandler(async (ctx) => {
|
|
73
|
+
const auth = ctx.authInfo;
|
|
74
|
+
// Unreachable: the route below only calls the handler with authInfo.
|
|
75
|
+
if (!auth)
|
|
76
|
+
throw new Error('missing caller');
|
|
77
|
+
const extra = auth.extra;
|
|
78
|
+
const token = auth.token;
|
|
79
|
+
const reqLogger = logger.child({ http_request_id: extra.requestId });
|
|
80
|
+
const api = new ApiClient({
|
|
81
|
+
baseUrl: config.apiUrl,
|
|
82
|
+
token,
|
|
83
|
+
logger: reqLogger,
|
|
84
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
85
|
+
onError: (err) => {
|
|
86
|
+
if (invalidatesIdentity(err))
|
|
87
|
+
identities.evict(token);
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
const policy = { ...basePolicy, toolsets: extra.toolsets };
|
|
91
|
+
// Listing reads the token's scopes (cached briefly); a call is checked by the API itself.
|
|
92
|
+
if (!extra.lists) {
|
|
93
|
+
return createMcpServer({ ...listedByPolicy(catalog, policy), api, logger: reqLogger });
|
|
94
|
+
}
|
|
95
|
+
const readIdentity = identityFromApi(api);
|
|
96
|
+
const listed = await resolveListedTools(() => identities.get(token, readIdentity), catalog, policy, reqLogger);
|
|
97
|
+
return createMcpServer({
|
|
98
|
+
tools: listed.tools,
|
|
99
|
+
resources: listed.resources,
|
|
100
|
+
prompts: listed.prompts,
|
|
101
|
+
api,
|
|
102
|
+
logger: reqLogger,
|
|
103
|
+
// A list made without the token's scopes is cached briefly, so it is soon read again.
|
|
104
|
+
...(listed.identity === 'known' ? {} : { toolsListTtlMs: DEGRADED_LIST_TTL_MS }),
|
|
105
|
+
});
|
|
106
|
+
}, {
|
|
107
|
+
onerror: (error) => {
|
|
108
|
+
logger.warn('mcp handler error', { error });
|
|
109
|
+
},
|
|
110
|
+
maxRequestBodySize: MAX_BODY_BYTES,
|
|
111
|
+
});
|
|
112
|
+
const app = new Hono();
|
|
113
|
+
// Access log: method, path, status and timing only. Never headers or bodies.
|
|
114
|
+
app.use('*', async (c, next) => {
|
|
115
|
+
const started = Date.now();
|
|
116
|
+
await next();
|
|
117
|
+
logger.info('http request', {
|
|
118
|
+
method: c.req.method,
|
|
119
|
+
path: c.req.path,
|
|
120
|
+
status: c.res.status,
|
|
121
|
+
duration_ms: Date.now() - started,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
// Before the Host check: container and platform probes call it by
|
|
125
|
+
// localhost or the instance's address, and it says nothing but "ok".
|
|
126
|
+
app.get('/healthz', (c) => c.json({ status: 'ok' }));
|
|
127
|
+
if (allowedHosts !== undefined)
|
|
128
|
+
app.use('*', hostHeaderValidation(allowedHosts));
|
|
129
|
+
app.use('*', originValidation(allowedOrigins));
|
|
130
|
+
const methodNotAllowed = (c) => {
|
|
131
|
+
c.header('Allow', 'POST');
|
|
132
|
+
return c.json(jsonRpcError(-32000, 'Method not allowed. This server is stateless: POST only.'), 405);
|
|
133
|
+
};
|
|
134
|
+
app.on(['GET', 'DELETE', 'PUT', 'PATCH'], MCP_PATH, methodNotAllowed);
|
|
135
|
+
app.post(MCP_PATH, async (c) => {
|
|
136
|
+
const token = bearerToken(c.req.header('authorization'));
|
|
137
|
+
if (token === undefined) {
|
|
138
|
+
c.header('WWW-Authenticate', 'Bearer realm="buildit-mcp"');
|
|
139
|
+
return c.json(jsonRpcError(-32001, 'Unauthorized: send "Authorization: Bearer <token>" with a buildIt.Social personal access token.'), 401);
|
|
140
|
+
}
|
|
141
|
+
let toolsets = config.toolsets;
|
|
142
|
+
const header = c.req.header(TOOLSETS_HEADER);
|
|
143
|
+
if (header !== undefined) {
|
|
144
|
+
let asked;
|
|
145
|
+
try {
|
|
146
|
+
asked = parseToolsets(header, 'X-Buildit-Toolsets');
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
if (!(err instanceof ConfigError))
|
|
150
|
+
throw err;
|
|
151
|
+
return c.json(jsonRpcError(-32602, err.message), 400);
|
|
152
|
+
}
|
|
153
|
+
// The configured toolsets are a ceiling: the header can only narrow them.
|
|
154
|
+
toolsets = config.toolsets.filter((t) => asked.includes(t));
|
|
155
|
+
}
|
|
156
|
+
let parsedBody = undefined;
|
|
157
|
+
if (isJsonContentType(c.req.header('content-type'))) {
|
|
158
|
+
const body = await readRequestBody(c.req.raw.clone(), MAX_BODY_BYTES);
|
|
159
|
+
if (body.tooLarge) {
|
|
160
|
+
return c.json(jsonRpcError(-32000, 'Payload too large.'), 413);
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
parsedBody = JSON.parse(body.text);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return c.json(jsonRpcError(-32700, 'Parse error: the body is not valid JSON.'), 400);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const extra = {
|
|
170
|
+
toolsets,
|
|
171
|
+
lists: listsTools(parsedBody),
|
|
172
|
+
requestId: randomUUID(),
|
|
173
|
+
};
|
|
174
|
+
return handler.fetch(c.req.raw, {
|
|
175
|
+
...(parsedBody === undefined ? {} : { parsedBody }),
|
|
176
|
+
authInfo: { token, clientId: 'buildit-personal-access-token', scopes: [], extra },
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
app.notFound((c) => c.json(jsonRpcError(-32000, `Not found. The MCP endpoint is ${MCP_PATH}.`), 404));
|
|
180
|
+
app.onError((err, c) => {
|
|
181
|
+
logger.error('http handler failed', { error: err });
|
|
182
|
+
return c.json(jsonRpcError(-32603, 'Internal error.'), 500);
|
|
183
|
+
});
|
|
184
|
+
return { app, handler };
|
|
185
|
+
}
|
|
186
|
+
export async function startHttp(config, logger, options = {}) {
|
|
187
|
+
const { app, handler } = createHttpApp(config, logger, options);
|
|
188
|
+
const server = await new Promise((resolve, reject) => {
|
|
189
|
+
const s = serve({ fetch: app.fetch, hostname: config.http.host, port: config.http.port }, () => {
|
|
190
|
+
resolve(s);
|
|
191
|
+
});
|
|
192
|
+
s.once('error', reject);
|
|
193
|
+
});
|
|
194
|
+
const port = server.address().port;
|
|
195
|
+
const hostForUrl = config.http.host.includes(':') ? `[${config.http.host}]` : config.http.host;
|
|
196
|
+
const url = `http://${hostForUrl}:${port}${MCP_PATH}`;
|
|
197
|
+
logger.info('listening', { url });
|
|
198
|
+
return {
|
|
199
|
+
url,
|
|
200
|
+
port,
|
|
201
|
+
close: async () => {
|
|
202
|
+
await handler.close();
|
|
203
|
+
await new Promise((resolve) => {
|
|
204
|
+
server.close(() => {
|
|
205
|
+
resolve();
|
|
206
|
+
});
|
|
207
|
+
if ('closeAllConnections' in server)
|
|
208
|
+
server.closeAllConnections();
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { isJSONRPCRequest, } from '@modelcontextprotocol/server';
|
|
2
|
+
import { serveStdio, StdioServerTransport, } from '@modelcontextprotocol/server/stdio';
|
|
3
|
+
import { ApiClient } from '../api/client.js';
|
|
4
|
+
import { invalidatesIdentity } from '../identity.js';
|
|
5
|
+
import { createSwitchableMcpServer, DEGRADED_LIST_TTL_MS, listedByPolicy, resolveListedTools, identityFromApi, } from '../server.js';
|
|
6
|
+
import { CATALOG } from '../toolsets/catalog.js';
|
|
7
|
+
import { compareVersions, SERVER_VERSION } from '../version.js';
|
|
8
|
+
/** A known identity is read again at most this often. */
|
|
9
|
+
export const IDENTITY_REFRESH_MS = 60_000;
|
|
10
|
+
/** Methods whose answer depends on the token's scopes: they wait for a due refresh. */
|
|
11
|
+
const LIST_METHODS = new Set([
|
|
12
|
+
'tools/list',
|
|
13
|
+
'resources/list',
|
|
14
|
+
'resources/templates/list',
|
|
15
|
+
'prompts/list',
|
|
16
|
+
]);
|
|
17
|
+
/** Methods that use something listed: they trigger a due refresh. */
|
|
18
|
+
const CALL_METHODS = new Set(['tools/call', 'resources/read', 'prompts/get']);
|
|
19
|
+
/**
|
|
20
|
+
* The token's identity for one stdio connection, and the server whose
|
|
21
|
+
* visible tools follow it.
|
|
22
|
+
*/
|
|
23
|
+
export class StdioIdentity {
|
|
24
|
+
read;
|
|
25
|
+
logger;
|
|
26
|
+
now;
|
|
27
|
+
listed;
|
|
28
|
+
refreshAt = 0;
|
|
29
|
+
pending;
|
|
30
|
+
generation = 0;
|
|
31
|
+
server;
|
|
32
|
+
constructor(read, logger, now = Date.now) {
|
|
33
|
+
this.read = read;
|
|
34
|
+
this.logger = logger;
|
|
35
|
+
this.now = now;
|
|
36
|
+
}
|
|
37
|
+
/** The latest listing, if any. */
|
|
38
|
+
get current() {
|
|
39
|
+
return this.listed;
|
|
40
|
+
}
|
|
41
|
+
/** Whether the identity should be read again before it is relied on. */
|
|
42
|
+
get due() {
|
|
43
|
+
return this.listed === undefined || this.now() >= this.refreshAt;
|
|
44
|
+
}
|
|
45
|
+
/** Reads the identity again if it is due (one read at a time), and returns the listing. */
|
|
46
|
+
refresh() {
|
|
47
|
+
if (this.pending)
|
|
48
|
+
return this.pending;
|
|
49
|
+
if (this.listed && !this.due)
|
|
50
|
+
return Promise.resolve(this.listed);
|
|
51
|
+
const generation = this.generation;
|
|
52
|
+
const pending = this.read()
|
|
53
|
+
.then((result) => {
|
|
54
|
+
const before = this.listed?.identity;
|
|
55
|
+
this.listed = result;
|
|
56
|
+
const wait = result.identity === 'known' ? IDENTITY_REFRESH_MS : (result.error?.retryAfterMs ?? 0);
|
|
57
|
+
// An invalidation during the read asks for another one.
|
|
58
|
+
this.refreshAt = generation === this.generation ? this.now() + wait : 0;
|
|
59
|
+
if (result.identity === 'known' && before !== 'known') {
|
|
60
|
+
this.logger.info('token identified', {
|
|
61
|
+
scopes: result.scopes,
|
|
62
|
+
limited: result.limited,
|
|
63
|
+
tools: result.tools.length,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
this.server?.show(result);
|
|
67
|
+
return result;
|
|
68
|
+
})
|
|
69
|
+
.finally(() => {
|
|
70
|
+
if (this.pending === pending)
|
|
71
|
+
this.pending = undefined;
|
|
72
|
+
});
|
|
73
|
+
this.pending = pending;
|
|
74
|
+
return pending;
|
|
75
|
+
}
|
|
76
|
+
/** Read the identity again on the next list or call (after an auth error). */
|
|
77
|
+
invalidate() {
|
|
78
|
+
this.generation++;
|
|
79
|
+
this.refreshAt = 0;
|
|
80
|
+
}
|
|
81
|
+
/** The server whose visible tools follow the identity from now on. */
|
|
82
|
+
attach(server) {
|
|
83
|
+
this.server = server;
|
|
84
|
+
if (this.listed)
|
|
85
|
+
server.show(this.listed);
|
|
86
|
+
}
|
|
87
|
+
/** Called before a message reaches the server: refreshes the identity when it is due. */
|
|
88
|
+
async beforeMessage(message) {
|
|
89
|
+
if (!isJSONRPCRequest(message) || !this.due)
|
|
90
|
+
return;
|
|
91
|
+
if (LIST_METHODS.has(message.method)) {
|
|
92
|
+
await this.refresh();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (!CALL_METHODS.has(message.method))
|
|
96
|
+
return;
|
|
97
|
+
if (this.listed?.identity === 'refused') {
|
|
98
|
+
// The called tool may be hidden until the token is accepted: wait for the answer.
|
|
99
|
+
await this.refresh();
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
// The call goes ahead; the API checks it either way.
|
|
103
|
+
void this.refresh();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* A transport that lets `before` run (in order) ahead of each incoming
|
|
109
|
+
* message, so the identity can be refreshed before a list or call is served.
|
|
110
|
+
*/
|
|
111
|
+
class GatedTransport {
|
|
112
|
+
inner;
|
|
113
|
+
before;
|
|
114
|
+
onclose;
|
|
115
|
+
onerror;
|
|
116
|
+
onmessage;
|
|
117
|
+
queue = Promise.resolve();
|
|
118
|
+
constructor(inner, before) {
|
|
119
|
+
this.inner = inner;
|
|
120
|
+
this.before = before;
|
|
121
|
+
}
|
|
122
|
+
start() {
|
|
123
|
+
this.inner.onmessage = (message, extra) => {
|
|
124
|
+
this.queue = this.queue
|
|
125
|
+
.then(() => this.before(message))
|
|
126
|
+
.catch(() => undefined)
|
|
127
|
+
.then(() => {
|
|
128
|
+
this.onmessage?.(message, extra);
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
this.inner.onerror = (error) => this.onerror?.(error);
|
|
132
|
+
this.inner.onclose = () => this.onclose?.();
|
|
133
|
+
return this.inner.start();
|
|
134
|
+
}
|
|
135
|
+
send(message, options) {
|
|
136
|
+
return this.inner.send(message, options);
|
|
137
|
+
}
|
|
138
|
+
close() {
|
|
139
|
+
return this.inner.close();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export function startStdio(config, logger, options = {}) {
|
|
143
|
+
if (config.token === undefined)
|
|
144
|
+
throw new Error('stdio mode needs a token');
|
|
145
|
+
const catalog = options.catalog ?? CATALOG;
|
|
146
|
+
const policy = {
|
|
147
|
+
toolsets: config.toolsets,
|
|
148
|
+
readOnly: config.readOnly,
|
|
149
|
+
excludeTools: config.excludeTools,
|
|
150
|
+
};
|
|
151
|
+
const invalidations = { listener: () => undefined };
|
|
152
|
+
const api = new ApiClient({
|
|
153
|
+
baseUrl: config.apiUrl,
|
|
154
|
+
token: config.token,
|
|
155
|
+
logger,
|
|
156
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
157
|
+
onError: (err) => {
|
|
158
|
+
if (invalidatesIdentity(err))
|
|
159
|
+
invalidations.listener();
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
const identity = new StdioIdentity(() => resolveListedTools(identityFromApi(api), catalog, policy, logger), logger, options.now);
|
|
163
|
+
invalidations.listener = () => {
|
|
164
|
+
identity.invalidate();
|
|
165
|
+
};
|
|
166
|
+
// Read the identity at startup.
|
|
167
|
+
void identity.refresh();
|
|
168
|
+
// Warn early when the API needs a newer server.
|
|
169
|
+
void api
|
|
170
|
+
.getMeta()
|
|
171
|
+
.then((meta) => {
|
|
172
|
+
if (meta.min_mcp_version && compareVersions(SERVER_VERSION, meta.min_mcp_version) < 0) {
|
|
173
|
+
logger.warn('this buildit-mcp is older than the API supports; update it', {
|
|
174
|
+
version: SERVER_VERSION,
|
|
175
|
+
min_version: meta.min_mcp_version,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
.catch(() => undefined);
|
|
180
|
+
// Everything the configuration allows is registered; the identity decides what is shown.
|
|
181
|
+
const allowed = listedByPolicy(catalog, policy);
|
|
182
|
+
const wire = options.stdin && options.stdout
|
|
183
|
+
? new StdioServerTransport(options.stdin, options.stdout)
|
|
184
|
+
: new StdioServerTransport();
|
|
185
|
+
return serveStdio(async () => {
|
|
186
|
+
const listed = await identity.refresh();
|
|
187
|
+
const switchable = createSwitchableMcpServer({
|
|
188
|
+
tools: allowed.tools,
|
|
189
|
+
resources: allowed.resources,
|
|
190
|
+
prompts: allowed.prompts,
|
|
191
|
+
api,
|
|
192
|
+
logger,
|
|
193
|
+
...(listed.identity === 'known' ? {} : { toolsListTtlMs: DEGRADED_LIST_TTL_MS }),
|
|
194
|
+
});
|
|
195
|
+
identity.attach(switchable);
|
|
196
|
+
return switchable.server;
|
|
197
|
+
}, {
|
|
198
|
+
onerror: (error) => {
|
|
199
|
+
logger.error('stdio transport error', { error });
|
|
200
|
+
},
|
|
201
|
+
transport: new GatedTransport(wire, (message) => identity.beforeMessage(message)),
|
|
202
|
+
});
|
|
203
|
+
}
|