@arii/boomtick-mcp 0.6.0 → 0.6.2
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/.mcp/schemas/github.create_issue.json +72 -3
- package/.mcp/schemas/github.get_merge_conflict_files.json +40 -3
- package/.mcp/schemas/github.get_pr_diff.json +30 -3
- package/.mcp/schemas/github.issue_update.json +72 -3
- package/.mcp/schemas/github.search_open_prs.json +130 -3
- package/.mcp/schemas/github_create_issue.json +72 -3
- package/.mcp/schemas/github_get_merge_conflict_files.json +40 -3
- package/.mcp/schemas/github_get_pr_diff.json +30 -3
- package/.mcp/schemas/github_issue_update.json +72 -3
- package/.mcp/schemas/github_search_open_prs.json +130 -3
- package/.mcp/schemas/repo.check_jules_status.json +38 -0
- package/.mcp/schemas/repo.run_tests.json +31 -3
- package/.mcp/schemas/repo_check_jules_status.json +38 -0
- package/.mcp/schemas/repo_run_tests.json +31 -3
- package/CHANGELOG.md +15 -0
- package/README.md +22 -1
- package/actions/run-project-gate/action.yml +1 -1
- package/dist/config/dynamic.js +33 -0
- package/dist/config/github.js +16 -0
- package/dist/config/repo.js +28 -0
- package/dist/config.js +16 -81
- package/dist/http.js +10 -0
- package/dist/mcp/definitions.js +29 -7
- package/dist/mcp/http.js +18 -10
- package/dist/mcp/http.test.js +54 -4
- package/dist/mcp/server.js +47 -10
- package/dist/mcp/server.test.js +52 -1
- package/dist/tools/contract.js +77 -0
- package/dist/tools/github.comment_triage_summary.js +3 -10
- package/dist/tools/github.get_merge_conflict_files.js +4 -2
- package/dist/tools/github.get_pr_commits.js +1 -1
- package/dist/tools/github.get_pr_diff.js +3 -11
- package/dist/tools/github.get_pr_reviews.js +1 -1
- package/dist/tools/jules/cancel-session.js +2 -9
- package/dist/tools/jules/cli.js +31 -0
- package/dist/tools/jules/get-messages.js +2 -9
- package/dist/tools/jules/list-sessions.js +4 -35
- package/dist/tools/jules/send-message.js +2 -9
- package/dist/tools/jules/session.js +26 -0
- package/dist/tools/jules/shared.js +2 -50
- package/dist/tools/jules/trigger-feedback.js +2 -10
- package/dist/tools/repo.check_jules_status.js +148 -0
- package/dist/tools/repo.check_jules_status.test.js +47 -0
- package/dist/tools/repo.get_workflow_runs.js +1 -1
- package/dist/tools/repo.run_tests.js +13 -1
- package/package.json +3 -2
- package/scripts/sync-contracts.ts +11 -2
- package/src/config/dynamic.ts +36 -0
- package/src/config/github.ts +16 -0
- package/src/config/repo.ts +36 -0
- package/src/config.ts +16 -89
- package/src/http.ts +13 -0
- package/src/mcp/definitions.ts +34 -6
- package/src/mcp/http.test.ts +65 -5
- package/src/mcp/http.ts +20 -14
- package/src/mcp/server.test.ts +62 -1
- package/src/mcp/server.ts +48 -11
- package/src/tools/contract.ts +85 -0
- package/src/tools/github.comment_triage_summary.ts +3 -12
- package/src/tools/github.get_merge_conflict_files.ts +5 -2
- package/src/tools/github.get_pr_commits.ts +1 -1
- package/src/tools/github.get_pr_diff.ts +3 -13
- package/src/tools/github.get_pr_reviews.ts +1 -1
- package/src/tools/jules/cancel-session.ts +2 -11
- package/src/tools/jules/cli.ts +42 -0
- package/src/tools/jules/get-messages.ts +2 -11
- package/src/tools/jules/list-sessions.ts +5 -39
- package/src/tools/jules/send-message.ts +2 -11
- package/src/tools/jules/session.ts +28 -0
- package/src/tools/jules/shared.ts +2 -63
- package/src/tools/jules/trigger-feedback.ts +2 -13
- package/src/tools/repo.check_jules_status.test.ts +55 -0
- package/src/tools/repo.check_jules_status.ts +176 -0
- package/src/tools/repo.get_workflow_runs.ts +1 -1
- package/src/tools/repo.run_tests.ts +14 -1
package/src/config.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import { fileURLToPath } from "url";
|
|
4
|
-
import {
|
|
4
|
+
import { findRepoRoot } from "./config/repo.js";
|
|
5
|
+
import { getGithubToken } from "./config/github.js";
|
|
6
|
+
import { initializeConfig, getCachedDynamicConfig } from "./config/dynamic.js";
|
|
5
7
|
|
|
6
8
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
9
|
|
|
@@ -14,93 +16,14 @@ if (fs.existsSync(envPath) && typeof process.loadEnvFile === "function") {
|
|
|
14
16
|
}
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
if (process.env.GITHUB_TOKEN) {
|
|
19
|
-
return process.env.GITHUB_TOKEN;
|
|
20
|
-
}
|
|
21
|
-
try {
|
|
22
|
-
const token = execSync("gh auth token", { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
23
|
-
if (token) {
|
|
24
|
-
return token;
|
|
25
|
-
}
|
|
26
|
-
} catch (e) { /* ignore to allow fallback */ }
|
|
27
|
-
return undefined;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
let cachedDynamicConfig: any = null;
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Explicitly initializes dynamic configuration from the Python CLI.
|
|
34
|
-
* Should be called once during server startup.
|
|
35
|
-
*/
|
|
36
|
-
export function initializeConfig() {
|
|
37
|
-
if (cachedDynamicConfig !== null) {
|
|
38
|
-
return cachedDynamicConfig;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
try {
|
|
42
|
-
// Attempt to load core properties from the Python CLI in the active workspace
|
|
43
|
-
const cwd = process.env.BOOMTICK_REPO_PATH || process.cwd();
|
|
44
|
-
const cmd = `td config view`;
|
|
45
|
-
const output = execSync(cmd, {
|
|
46
|
-
cwd,
|
|
47
|
-
encoding: "utf-8",
|
|
48
|
-
stdio: ["ignore", "pipe", "ignore"]
|
|
49
|
-
});
|
|
50
|
-
cachedDynamicConfig = JSON.parse(output);
|
|
51
|
-
return cachedDynamicConfig;
|
|
52
|
-
} catch (e) {
|
|
53
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
54
|
-
const errorPrefix = `CRITICAL: Failed to load dynamic config from Python CLI. Ensure Python 3 is installed and cli is in your PYTHONPATH. Details: ${message}`;
|
|
55
|
-
|
|
56
|
-
if (process.env.NODE_ENV === "development" || process.env.CI === "true") {
|
|
57
|
-
throw new Error(errorPrefix);
|
|
58
|
-
} else {
|
|
59
|
-
console.warn(errorPrefix);
|
|
60
|
-
}
|
|
61
|
-
// Set an empty object to prevent repeated attempts if initialization fails in non-blocking mode
|
|
62
|
-
cachedDynamicConfig = {};
|
|
63
|
-
}
|
|
64
|
-
return cachedDynamicConfig;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function findRepoRoot() {
|
|
68
|
-
if (process.env.BOOMTICK_REPO_PATH) {
|
|
69
|
-
return process.env.BOOMTICK_REPO_PATH;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Traverse up from process.cwd() to find project_config.json, workspace.json, or .git
|
|
73
|
-
let current = process.cwd();
|
|
74
|
-
while (current !== path.parse(current).root) {
|
|
75
|
-
if (
|
|
76
|
-
fs.existsSync(path.join(current, "project_config.json")) ||
|
|
77
|
-
fs.existsSync(path.join(current, "workspace.json")) ||
|
|
78
|
-
fs.existsSync(path.join(current, ".git"))
|
|
79
|
-
) {
|
|
80
|
-
return current;
|
|
81
|
-
}
|
|
82
|
-
current = path.dirname(current);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Fallback to __dirname search if discovery from cwd fails
|
|
86
|
-
current = __dirname;
|
|
87
|
-
while (current !== path.parse(current).root) {
|
|
88
|
-
if (
|
|
89
|
-
fs.existsSync(path.join(current, "project_config.json")) ||
|
|
90
|
-
fs.existsSync(path.join(current, "workspace.json")) ||
|
|
91
|
-
fs.existsSync(path.join(current, ".git"))
|
|
92
|
-
) {
|
|
93
|
-
return current;
|
|
94
|
-
}
|
|
95
|
-
current = path.dirname(current);
|
|
96
|
-
}
|
|
97
|
-
// Fallback to the monolithic relative path if discovery fails
|
|
98
|
-
return path.resolve(__dirname, "../../../");
|
|
99
|
-
}
|
|
19
|
+
export { initializeConfig };
|
|
100
20
|
|
|
101
21
|
export const config = {
|
|
102
|
-
get githubToken() {
|
|
22
|
+
get githubToken() {
|
|
23
|
+
return getGithubToken();
|
|
24
|
+
},
|
|
103
25
|
get githubOwner() {
|
|
26
|
+
const cachedDynamicConfig = getCachedDynamicConfig();
|
|
104
27
|
const repoString = cachedDynamicConfig?.github_repo;
|
|
105
28
|
if (typeof repoString === "string" && repoString.includes("/")) {
|
|
106
29
|
return repoString.split("/")[0];
|
|
@@ -113,6 +36,7 @@ export const config = {
|
|
|
113
36
|
throw new Error("GITHUB_OWNER must be set via environment variable or project_config.json");
|
|
114
37
|
},
|
|
115
38
|
get githubRepo() {
|
|
39
|
+
const cachedDynamicConfig = getCachedDynamicConfig();
|
|
116
40
|
const repoString = cachedDynamicConfig?.github_repo;
|
|
117
41
|
if (typeof repoString === "string" && repoString.includes("/")) {
|
|
118
42
|
return repoString.split("/")[1];
|
|
@@ -128,16 +52,19 @@ export const config = {
|
|
|
128
52
|
return findRepoRoot();
|
|
129
53
|
},
|
|
130
54
|
get defaultBaseBranch() {
|
|
55
|
+
const cachedDynamicConfig = getCachedDynamicConfig();
|
|
131
56
|
return process.env.DEFAULT_BASE_BRANCH || cachedDynamicConfig?.base_branch?.split("/").pop() || "main";
|
|
132
57
|
},
|
|
133
58
|
get viteBasePath() {
|
|
134
|
-
const
|
|
135
|
-
|
|
59
|
+
const cachedDynamicConfig = getCachedDynamicConfig();
|
|
60
|
+
const pathValue = process.env.VITE_BASE_PATH || cachedDynamicConfig?.vite_base_path;
|
|
61
|
+
if (!pathValue) {
|
|
136
62
|
throw new Error("VITE_BASE_PATH must be set via environment variable or project_config.json");
|
|
137
63
|
}
|
|
138
|
-
return
|
|
64
|
+
return pathValue;
|
|
139
65
|
},
|
|
140
66
|
get ghPath() {
|
|
67
|
+
const cachedDynamicConfig = getCachedDynamicConfig();
|
|
141
68
|
return process.env.GH_PATH || cachedDynamicConfig?.gh_path || "gh";
|
|
142
|
-
}
|
|
69
|
+
},
|
|
143
70
|
};
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { BoomtickMCPServer } from "./mcp/server.js";
|
|
3
|
+
import { initializeConfig } from "./config.js";
|
|
4
|
+
|
|
5
|
+
initializeConfig();
|
|
6
|
+
|
|
7
|
+
process.env.MCP_TRANSPORT = "http";
|
|
8
|
+
|
|
9
|
+
const server = new BoomtickMCPServer();
|
|
10
|
+
server.run().catch((error) => {
|
|
11
|
+
console.error("Fatal error running HTTP server:", error);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
});
|
package/src/mcp/definitions.ts
CHANGED
|
@@ -2,9 +2,12 @@ import { Tool, Prompt, Resource, ResourceTemplate } from "@modelcontextprotocol/
|
|
|
2
2
|
import {
|
|
3
3
|
HealthCheckInputJsonSchema,
|
|
4
4
|
SearchPRsInputJsonSchema,
|
|
5
|
+
SearchPRsResponseJsonSchema,
|
|
5
6
|
GetPrDiffInputJsonSchema,
|
|
7
|
+
PrDiffResponseJsonSchema,
|
|
6
8
|
CheckoutBranchInputJsonSchema,
|
|
7
9
|
GetMergeConflictFilesInputJsonSchema,
|
|
10
|
+
GetMergeConflictFilesResponseJsonSchema,
|
|
8
11
|
GetChangedFilesInputJsonSchema,
|
|
9
12
|
GetPackageScriptsInputJsonSchema,
|
|
10
13
|
ReadCiLogsInputJsonSchema,
|
|
@@ -12,6 +15,7 @@ import {
|
|
|
12
15
|
CreateBranchInputJsonSchema,
|
|
13
16
|
CreateRepairBranchInputJsonSchema,
|
|
14
17
|
RunTestsInputJsonSchema,
|
|
18
|
+
RunTestsResponseJsonSchema,
|
|
15
19
|
CommitPatchInputJsonSchema,
|
|
16
20
|
OpenReplacementPrInputJsonSchema,
|
|
17
21
|
CreatePullRequestInputJsonSchema,
|
|
@@ -19,8 +23,10 @@ import {
|
|
|
19
23
|
GetPrInputJsonSchema,
|
|
20
24
|
IssueViewInputJsonSchema,
|
|
21
25
|
IssueUpdateInputJsonSchema,
|
|
26
|
+
IssueUpdateResponseJsonSchema,
|
|
22
27
|
IssueCommentInputJsonSchema,
|
|
23
28
|
CreateIssueInputJsonSchema,
|
|
29
|
+
CreateIssueResponseJsonSchema,
|
|
24
30
|
GetCommandSchemaInputJsonSchema,
|
|
25
31
|
ReadAgentContextInputJsonSchema as ReadAgentContextSchema,
|
|
26
32
|
CreateJulesSessionInputJsonSchema,
|
|
@@ -78,6 +84,12 @@ export const MCP_RESOURCES: Resource[] = [
|
|
|
78
84
|
mimeType: "application/json",
|
|
79
85
|
description: "List of changed files in the repository.",
|
|
80
86
|
},
|
|
87
|
+
{
|
|
88
|
+
uri: "mcp://ci/latest-run",
|
|
89
|
+
name: "Latest CI Run",
|
|
90
|
+
mimeType: "application/json",
|
|
91
|
+
description: "Recent CI exit statuses, workflow runs, and annotations.",
|
|
92
|
+
},
|
|
81
93
|
{
|
|
82
94
|
uri: "repo://package-json",
|
|
83
95
|
name: "package.json",
|
|
@@ -130,7 +142,7 @@ const BASE_MCP_TOOLS: Tool[] = [
|
|
|
130
142
|
name: "github.search_open_prs",
|
|
131
143
|
description: "Search for open pull requests in the repository.",
|
|
132
144
|
inputSchema: SearchPRsInputJsonSchema as any,
|
|
133
|
-
outputSchema:
|
|
145
|
+
outputSchema: SearchPRsResponseJsonSchema as any,
|
|
134
146
|
},
|
|
135
147
|
{
|
|
136
148
|
name: "repo.read_agent_context",
|
|
@@ -142,7 +154,7 @@ const BASE_MCP_TOOLS: Tool[] = [
|
|
|
142
154
|
name: "github.get_pr_diff",
|
|
143
155
|
description: "Get the diff and changed files for a pull request.",
|
|
144
156
|
inputSchema: GetPrDiffInputJsonSchema as any,
|
|
145
|
-
outputSchema:
|
|
157
|
+
outputSchema: PrDiffResponseJsonSchema as any,
|
|
146
158
|
},
|
|
147
159
|
{
|
|
148
160
|
name: "github.checkout_branch",
|
|
@@ -154,7 +166,7 @@ const BASE_MCP_TOOLS: Tool[] = [
|
|
|
154
166
|
name: "github.get_merge_conflict_files",
|
|
155
167
|
description: "Detect files that conflict when a PR is merged with the base branch.",
|
|
156
168
|
inputSchema: GetMergeConflictFilesInputJsonSchema as any,
|
|
157
|
-
outputSchema:
|
|
169
|
+
outputSchema: GetMergeConflictFilesResponseJsonSchema as any,
|
|
158
170
|
},
|
|
159
171
|
{
|
|
160
172
|
name: "repo.get_changed_files",
|
|
@@ -198,11 +210,27 @@ const BASE_MCP_TOOLS: Tool[] = [
|
|
|
198
210
|
inputSchema: CreateRepairBranchInputJsonSchema as any,
|
|
199
211
|
outputSchema: DEFAULT_OUTPUT_SCHEMA as any,
|
|
200
212
|
},
|
|
213
|
+
{
|
|
214
|
+
name: "repo.check_jules_status",
|
|
215
|
+
description: "Check or poll the status of a Jules remediation session and verify its branch or PR status.",
|
|
216
|
+
inputSchema: {
|
|
217
|
+
type: "object",
|
|
218
|
+
properties: {
|
|
219
|
+
sessionId: { type: "string", description: "The unique ID or task ID of the Jules session." },
|
|
220
|
+
commitSha: { type: "string", description: "Commit SHA for idempotency key verification." },
|
|
221
|
+
failureLog: { type: "string", description: "Failure log content for idempotency key calculation." },
|
|
222
|
+
poll: { type: "boolean", description: "Whether to poll until session completion or timeout." },
|
|
223
|
+
timeoutMs: { type: "integer", description: "Timeout in milliseconds when polling." },
|
|
224
|
+
intervalMs: { type: "integer", description: "Polling interval in milliseconds." },
|
|
225
|
+
},
|
|
226
|
+
} as any,
|
|
227
|
+
outputSchema: DEFAULT_OUTPUT_SCHEMA as any,
|
|
228
|
+
},
|
|
201
229
|
{
|
|
202
230
|
name: "repo.run_tests",
|
|
203
231
|
description: "Run repository tests and checks.",
|
|
204
232
|
inputSchema: RunTestsInputJsonSchema as any,
|
|
205
|
-
outputSchema:
|
|
233
|
+
outputSchema: RunTestsResponseJsonSchema as any,
|
|
206
234
|
},
|
|
207
235
|
{
|
|
208
236
|
name: "repo.commit_patch",
|
|
@@ -262,7 +290,7 @@ const BASE_MCP_TOOLS: Tool[] = [
|
|
|
262
290
|
name: "github.issue_update",
|
|
263
291
|
description: "Update a GitHub issue's body, labels, and/or state.",
|
|
264
292
|
inputSchema: IssueUpdateInputJsonSchema as any,
|
|
265
|
-
outputSchema:
|
|
293
|
+
outputSchema: IssueUpdateResponseJsonSchema as any,
|
|
266
294
|
},
|
|
267
295
|
{
|
|
268
296
|
name: "github.issue_comment",
|
|
@@ -274,7 +302,7 @@ const BASE_MCP_TOOLS: Tool[] = [
|
|
|
274
302
|
name: "github.create_issue",
|
|
275
303
|
description: "Create a new GitHub issue.",
|
|
276
304
|
inputSchema: CreateIssueInputJsonSchema as any,
|
|
277
|
-
outputSchema:
|
|
305
|
+
outputSchema: CreateIssueResponseJsonSchema as any,
|
|
278
306
|
},
|
|
279
307
|
{
|
|
280
308
|
name: "jules.create_session",
|
package/src/mcp/http.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import http from "node:http";
|
|
3
2
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
3
|
import { createMcpHttpServer } from "./http.js";
|
|
4
|
+
import { BoomtickMCPServer } from "./server.js";
|
|
5
5
|
|
|
6
6
|
describe("createMcpHttpServer", () => {
|
|
7
7
|
let mcpServer: Server;
|
|
@@ -18,19 +18,48 @@ describe("createMcpHttpServer", () => {
|
|
|
18
18
|
}
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
it("should respond with OAuth protected resource metadata on /.well-known/oauth-protected-resource", async () => {
|
|
21
|
+
it("should respond with RFC 9728 OAuth protected resource metadata on /.well-known/oauth-protected-resource", async () => {
|
|
22
22
|
httpServerObj = createMcpHttpServer({ port: 0, mcpServer });
|
|
23
23
|
port = await httpServerObj.listen(0);
|
|
24
24
|
|
|
25
25
|
const res = await fetch(`http://localhost:${port}/.well-known/oauth-protected-resource`);
|
|
26
26
|
expect(res.status).toBe(200);
|
|
27
|
+
expect(res.headers.get("content-type")).toContain("application/json");
|
|
28
|
+
|
|
27
29
|
const body = await res.json();
|
|
28
30
|
expect(body.resource).toBe("mcp://boomtick-mcp");
|
|
29
|
-
expect(body.authorization_servers).
|
|
31
|
+
expect(body.authorization_servers).toEqual(["https://auth.boomtick.dev"]);
|
|
30
32
|
expect(body.scopes_supported).toEqual(["mcp:read", "mcp:write"]);
|
|
33
|
+
expect(body.bearer_methods_supported).toEqual(["header"]);
|
|
34
|
+
expect(body.resource_documentation).toBe("https://github.com/arii/boomtick");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should handle HEAD requests for /.well-known/oauth-protected-resource", async () => {
|
|
38
|
+
httpServerObj = createMcpHttpServer({ port: 0, mcpServer });
|
|
39
|
+
port = await httpServerObj.listen(0);
|
|
40
|
+
|
|
41
|
+
const res = await fetch(`http://localhost:${port}/.well-known/oauth-protected-resource`, {
|
|
42
|
+
method: "HEAD",
|
|
43
|
+
});
|
|
44
|
+
expect(res.status).toBe(200);
|
|
45
|
+
const text = await res.text();
|
|
46
|
+
expect(text).toBe("");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should handle OPTIONS preflight CORS requests", async () => {
|
|
50
|
+
httpServerObj = createMcpHttpServer({ port: 0, mcpServer });
|
|
51
|
+
port = await httpServerObj.listen(0);
|
|
52
|
+
|
|
53
|
+
const res = await fetch(`http://localhost:${port}/sse`, {
|
|
54
|
+
method: "OPTIONS",
|
|
55
|
+
});
|
|
56
|
+
expect(res.status).toBe(204);
|
|
57
|
+
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
58
|
+
expect(res.headers.get("access-control-allow-methods")).toContain("GET");
|
|
59
|
+
expect(res.headers.get("access-control-allow-headers")).toContain("Authorization");
|
|
31
60
|
});
|
|
32
61
|
|
|
33
|
-
it("should reject requests when Bearer token is missing or invalid", async () => {
|
|
62
|
+
it("should reject protected requests when Bearer token is missing or invalid", async () => {
|
|
34
63
|
httpServerObj = createMcpHttpServer({ port: 0, bearerToken: "secret123", mcpServer });
|
|
35
64
|
port = await httpServerObj.listen(0);
|
|
36
65
|
|
|
@@ -38,6 +67,7 @@ describe("createMcpHttpServer", () => {
|
|
|
38
67
|
const res1 = await fetch(`http://localhost:${port}/sse`);
|
|
39
68
|
expect(res1.status).toBe(401);
|
|
40
69
|
expect(res1.headers.get("www-authenticate")).toContain('Bearer realm="boomtick-mcp"');
|
|
70
|
+
expect(res1.headers.get("www-authenticate")).toContain('error="invalid_token"');
|
|
41
71
|
|
|
42
72
|
// Request with wrong token
|
|
43
73
|
const res2 = await fetch(`http://localhost:${port}/sse`, {
|
|
@@ -45,7 +75,7 @@ describe("createMcpHttpServer", () => {
|
|
|
45
75
|
});
|
|
46
76
|
expect(res2.status).toBe(401);
|
|
47
77
|
|
|
48
|
-
//
|
|
78
|
+
// Public resource metadata accessible with or without token
|
|
49
79
|
const res3 = await fetch(`http://localhost:${port}/.well-known/oauth-protected-resource`, {
|
|
50
80
|
headers: { Authorization: "Bearer secret123" },
|
|
51
81
|
});
|
|
@@ -69,4 +99,34 @@ describe("createMcpHttpServer", () => {
|
|
|
69
99
|
const body = await res.json();
|
|
70
100
|
expect(body.error).toContain("Server misconfiguration");
|
|
71
101
|
});
|
|
102
|
+
|
|
103
|
+
it("should return 400 for message requests without session or invalid session", async () => {
|
|
104
|
+
httpServerObj = createMcpHttpServer({ port: 0, bearerToken: "secret123", mcpServer });
|
|
105
|
+
port = await httpServerObj.listen(0);
|
|
106
|
+
|
|
107
|
+
const res = await fetch(`http://localhost:${port}/messages?sessionId=invalid_session`, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: {
|
|
110
|
+
Authorization: "Bearer secret123",
|
|
111
|
+
"Content-Type": "application/json",
|
|
112
|
+
},
|
|
113
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "ping", id: 1 }),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(res.status).toBe(400);
|
|
117
|
+
const body = await res.json();
|
|
118
|
+
expect(body.error).toContain("Session not found or expired");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("should allow BoomtickMCPServer to start HTTP server via run() when transport is http", async () => {
|
|
122
|
+
process.env.MCP_TRANSPORT = "http";
|
|
123
|
+
process.env.PORT = "0";
|
|
124
|
+
process.env.BOOMTICK_MCP_BEARER_TOKEN = "test-token";
|
|
125
|
+
|
|
126
|
+
const boomtickServer = new BoomtickMCPServer();
|
|
127
|
+
await boomtickServer.run();
|
|
128
|
+
|
|
129
|
+
// Verify instance initialized properly
|
|
130
|
+
expect(boomtickServer).toBeInstanceOf(BoomtickMCPServer);
|
|
131
|
+
});
|
|
72
132
|
});
|
package/src/mcp/http.ts
CHANGED
|
@@ -21,7 +21,7 @@ export function createMcpHttpServer(options: HttpServerOptions) {
|
|
|
21
21
|
const server = http.createServer(async (req, res) => {
|
|
22
22
|
// CORS Headers
|
|
23
23
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
24
|
-
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
24
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS, HEAD");
|
|
25
25
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
26
26
|
|
|
27
27
|
if (req.method === "OPTIONS") {
|
|
@@ -32,22 +32,28 @@ export function createMcpHttpServer(options: HttpServerOptions) {
|
|
|
32
32
|
|
|
33
33
|
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
34
34
|
|
|
35
|
-
// Endpoint 1: OAuth Protected Resource
|
|
36
|
-
if (url.pathname === "/.well-known/oauth-protected-resource" && req.method === "GET") {
|
|
35
|
+
// Endpoint 1: RFC 9728 OAuth Protected Resource Metadata Discovery
|
|
36
|
+
if (url.pathname === "/.well-known/oauth-protected-resource" && (req.method === "GET" || req.method === "HEAD")) {
|
|
37
|
+
const metadata = {
|
|
38
|
+
resource: process.env.MCP_RESOURCE_URI || process.env.BOOMTICK_RESOURCE_URI || "mcp://boomtick-mcp",
|
|
39
|
+
authorization_servers: [
|
|
40
|
+
process.env.MCP_OAUTH_AUTH_SERVER || "https://auth.boomtick.dev",
|
|
41
|
+
],
|
|
42
|
+
scopes_supported: ["mcp:read", "mcp:write"],
|
|
43
|
+
bearer_methods_supported: ["header"],
|
|
44
|
+
resource_documentation: process.env.MCP_RESOURCE_DOCS || "https://github.com/arii/boomtick",
|
|
45
|
+
};
|
|
46
|
+
|
|
37
47
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
],
|
|
44
|
-
scopes_supported: ["mcp:read", "mcp:write"],
|
|
45
|
-
})
|
|
46
|
-
);
|
|
48
|
+
if (req.method === "HEAD") {
|
|
49
|
+
res.end();
|
|
50
|
+
} else {
|
|
51
|
+
res.end(JSON.stringify(metadata, null, 2));
|
|
52
|
+
}
|
|
47
53
|
return;
|
|
48
54
|
}
|
|
49
55
|
|
|
50
|
-
// Bearer Token Validation for
|
|
56
|
+
// Bearer Token / OAuth 2.1 Validation for Protected Routes (/sse & /messages)
|
|
51
57
|
if (url.pathname === "/sse" || url.pathname === "/messages") {
|
|
52
58
|
if (!bearerToken) {
|
|
53
59
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
@@ -67,7 +73,7 @@ export function createMcpHttpServer(options: HttpServerOptions) {
|
|
|
67
73
|
if (!isMatch) {
|
|
68
74
|
res.writeHead(401, {
|
|
69
75
|
"Content-Type": "application/json",
|
|
70
|
-
"WWW-Authenticate": 'Bearer realm="boomtick-mcp", error="invalid_token"',
|
|
76
|
+
"WWW-Authenticate": 'Bearer realm="boomtick-mcp", error="invalid_token", error_description="Invalid or missing Bearer token"',
|
|
71
77
|
});
|
|
72
78
|
res.end(JSON.stringify({ error: "unauthorized", message: "Invalid or missing Bearer token" }));
|
|
73
79
|
return;
|
package/src/mcp/server.test.ts
CHANGED
|
@@ -5,6 +5,9 @@ import fs from "fs/promises";
|
|
|
5
5
|
import * as healthModule from "./tools.js";
|
|
6
6
|
import * as prDiffModule from "../tools/github.get_pr_diff.js";
|
|
7
7
|
import * as ciLogsModule from "../tools/repo.read_ci_logs.js";
|
|
8
|
+
import * as prReviewsModule from "../tools/github.get_pr_reviews.js";
|
|
9
|
+
import * as prCommitsModule from "../tools/github.get_pr_commits.js";
|
|
10
|
+
import * as workflowRunsModule from "../tools/repo.get_workflow_runs.js";
|
|
8
11
|
|
|
9
12
|
vi.mock("fs/promises");
|
|
10
13
|
vi.mock("../config.js", () => ({
|
|
@@ -97,12 +100,16 @@ describe("BoomtickMCPServer", () => {
|
|
|
97
100
|
});
|
|
98
101
|
|
|
99
102
|
describe("ListResources", () => {
|
|
100
|
-
it("should return resources list", async () => {
|
|
103
|
+
it("should return resources list containing workspace resources", async () => {
|
|
101
104
|
const handler = requestHandlers.get("resources/list");
|
|
102
105
|
expect(handler).toBeDefined();
|
|
103
106
|
const result = await handler!({ method: "resources/list" });
|
|
104
107
|
expect(result.resources).toBeDefined();
|
|
105
108
|
expect(Array.isArray(result.resources)).toBe(true);
|
|
109
|
+
const uris = result.resources.map((r: any) => r.uri);
|
|
110
|
+
expect(uris).toContain("mcp://workspace/agent-contract");
|
|
111
|
+
expect(uris).toContain("mcp://git/changed-files");
|
|
112
|
+
expect(uris).toContain("mcp://ci/latest-run");
|
|
106
113
|
});
|
|
107
114
|
|
|
108
115
|
it("should return resource templates list", async () => {
|
|
@@ -185,6 +192,35 @@ describe("BoomtickMCPServer", () => {
|
|
|
185
192
|
expect(JSON.parse(result.contents[0].text)).toEqual({ status: "success" });
|
|
186
193
|
});
|
|
187
194
|
|
|
195
|
+
it("should handle mcp://git/changed-files", async () => {
|
|
196
|
+
const handler = requestHandlers.get("resources/read");
|
|
197
|
+
const changedFilesModule = await import("../tools/repo.get_changed_files.js");
|
|
198
|
+
vi.spyOn(changedFilesModule, "getChangedFilesHandler").mockResolvedValue({ files: ["src/index.ts"] } as any);
|
|
199
|
+
|
|
200
|
+
const result = await handler!({
|
|
201
|
+
method: "resources/read",
|
|
202
|
+
params: { uri: "mcp://git/changed-files" },
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect(result.contents[0].uri).toBe("mcp://git/changed-files");
|
|
206
|
+
expect(result.contents[0].mimeType).toBe("application/json");
|
|
207
|
+
expect(JSON.parse(result.contents[0].text)).toEqual({ files: ["src/index.ts"] });
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("should handle mcp://ci/latest-run", async () => {
|
|
211
|
+
const handler = requestHandlers.get("resources/read");
|
|
212
|
+
vi.spyOn(workflowRunsModule, "getWorkflowRunsHandler").mockResolvedValue({ workflowRuns: [{ id: 1, status: "completed", conclusion: "success" }] } as any);
|
|
213
|
+
|
|
214
|
+
const result = await handler!({
|
|
215
|
+
method: "resources/read",
|
|
216
|
+
params: { uri: "mcp://ci/latest-run" },
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(result.contents[0].uri).toBe("mcp://ci/latest-run");
|
|
220
|
+
expect(result.contents[0].mimeType).toBe("application/json");
|
|
221
|
+
expect(JSON.parse(result.contents[0].text)).toEqual({ workflowRuns: [{ id: 1, status: "completed", conclusion: "success" }] });
|
|
222
|
+
});
|
|
223
|
+
|
|
188
224
|
it("should throw when resource is unknown", async () => {
|
|
189
225
|
const handler = requestHandlers.get("resources/read");
|
|
190
226
|
|
|
@@ -223,6 +259,31 @@ describe("BoomtickMCPServer", () => {
|
|
|
223
259
|
});
|
|
224
260
|
});
|
|
225
261
|
|
|
262
|
+
it("should execute github_get_pr_reviews and github_get_pr_commits via underscore or dot name", async () => {
|
|
263
|
+
const handler = requestHandlers.get("tools/call");
|
|
264
|
+
vi.spyOn(prReviewsModule, "getPrReviewsHandler").mockResolvedValue({ reviews: [] } as any);
|
|
265
|
+
vi.spyOn(prCommitsModule, "getPrCommitsHandler").mockResolvedValue({ commits: [] } as any);
|
|
266
|
+
vi.spyOn(workflowRunsModule, "getWorkflowRunsHandler").mockResolvedValue({ runs: [] } as any);
|
|
267
|
+
|
|
268
|
+
const resReviews = await handler!({
|
|
269
|
+
method: "tools/call",
|
|
270
|
+
params: { name: "github_get_pr_reviews", arguments: { prNumber: 123 } },
|
|
271
|
+
});
|
|
272
|
+
expect(resReviews.structuredContent).toEqual({ reviews: [] });
|
|
273
|
+
|
|
274
|
+
const resCommits = await handler!({
|
|
275
|
+
method: "tools/call",
|
|
276
|
+
params: { name: "github_get_pr_commits", arguments: { prNumber: 123 } },
|
|
277
|
+
});
|
|
278
|
+
expect(resCommits.structuredContent).toEqual({ commits: [] });
|
|
279
|
+
|
|
280
|
+
const resWorkflow = await handler!({
|
|
281
|
+
method: "tools/call",
|
|
282
|
+
params: { name: "repo_get_workflow_runs", arguments: {} },
|
|
283
|
+
});
|
|
284
|
+
expect(resWorkflow.structuredContent).toEqual({ runs: [] });
|
|
285
|
+
});
|
|
286
|
+
|
|
226
287
|
it("should return error result for unknown tool", async () => {
|
|
227
288
|
const handler = requestHandlers.get("tools/call");
|
|
228
289
|
|
package/src/mcp/server.ts
CHANGED
|
@@ -24,14 +24,18 @@ import { getCommandSchemaHandler, GetCommandSchemaInputSchema } from "../tools/r
|
|
|
24
24
|
import { getPackageScriptsHandler, GetPackageScriptsInputSchema } from "../tools/repo.get_package_scripts.js";
|
|
25
25
|
import { readCiLogsHandler, ReadCiLogsInputSchema } from "../tools/repo.read_ci_logs.js";
|
|
26
26
|
import { repoLogsHandler, RepoLogsInputSchema } from "../tools/repo.logs.js";
|
|
27
|
+
import { getWorkflowRunsHandler, GetWorkflowRunsInputSchema } from "../tools/repo.get_workflow_runs.js";
|
|
27
28
|
import { createRepairBranchHandler, CreateRepairBranchInputSchema } from "../tools/repo.create_repair_branch.js";
|
|
28
29
|
import { createBranchHandler, CreateBranchInputSchema } from "../tools/repo.create_branch.js";
|
|
29
30
|
import { runTestsHandler, RunTestsInputSchema } from "../tools/repo.run_tests.js";
|
|
31
|
+
import { checkJulesStatusHandler, CheckJulesStatusInputSchema } from "../tools/repo.check_jules_status.js";
|
|
30
32
|
import { commitPatchHandler, CommitPatchInputSchema } from "../tools/repo.commit_patch.js";
|
|
31
33
|
import { openReplacementPrHandler, OpenReplacementPrInputSchema } from "../tools/github.open_replacement_pr.js";
|
|
32
34
|
import { commentTriageSummaryHandler, CommentTriageSummaryInputSchema } from "../tools/github.comment_triage_summary.js";
|
|
33
35
|
import { createPullRequestHandler, CreatePullRequestInputSchema } from "../tools/github.create_pull_request.js";
|
|
34
36
|
import { getPrHandler, GetPrInputSchema } from "../tools/github.get_pr.js";
|
|
37
|
+
import { getPrReviewsHandler, GetPrReviewsInputSchema } from "../tools/github.get_pr_reviews.js";
|
|
38
|
+
import { getPrCommitsHandler, GetPrCommitsInputSchema } from "../tools/github.get_pr_commits.js";
|
|
35
39
|
import { issueViewHandler, IssueViewInputSchema } from "../tools/github.issue_view.js";
|
|
36
40
|
import { issueUpdateHandler, IssueUpdateInputSchema } from "../tools/github.issue_update.js";
|
|
37
41
|
import { issueCommentHandler, IssueCommentInputSchema } from "../tools/github.issue_comment.js";
|
|
@@ -147,6 +151,16 @@ export class BoomtickMCPServer {
|
|
|
147
151
|
contents: [{ uri, mimeType: "application/json", text: JSON.stringify(changedFiles, null, 2) }],
|
|
148
152
|
};
|
|
149
153
|
}
|
|
154
|
+
if (
|
|
155
|
+
uri === "mcp://ci/latest-run" ||
|
|
156
|
+
uri === "repo://ci/latest-run" ||
|
|
157
|
+
uri === "repo://ci-latest-run"
|
|
158
|
+
) {
|
|
159
|
+
const workflowRuns = await getWorkflowRunsHandler(GetWorkflowRunsInputSchema.parse({}));
|
|
160
|
+
return {
|
|
161
|
+
contents: [{ uri, mimeType: "application/json", text: JSON.stringify(workflowRuns, null, 2) }],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
150
164
|
if (uri === "repo://package-json" || uri === "mcp://workspace/package-json") {
|
|
151
165
|
const content = await fs.readFile(path.join(config.repoPath, "package.json"), "utf-8");
|
|
152
166
|
return {
|
|
@@ -222,12 +236,18 @@ export class BoomtickMCPServer {
|
|
|
222
236
|
return createSuccessResult(await readCiLogsHandler(ReadCiLogsInputSchema.parse(normArgs)));
|
|
223
237
|
case "repo.logs":
|
|
224
238
|
return createSuccessResult(await repoLogsHandler(RepoLogsInputSchema.parse(normArgs)));
|
|
239
|
+
case "repo.get.workflow.runs":
|
|
240
|
+
case "repo.get_workflow_runs":
|
|
241
|
+
return createSuccessResult(await getWorkflowRunsHandler(GetWorkflowRunsInputSchema.parse(normArgs)));
|
|
225
242
|
case "repo.create.branch":
|
|
226
243
|
case "repo.create_branch":
|
|
227
244
|
return createSuccessResult(await createBranchHandler(CreateBranchInputSchema.parse(normArgs)));
|
|
228
245
|
case "repo.create.repair.branch":
|
|
229
246
|
case "repo.create_repair_branch":
|
|
230
247
|
return createSuccessResult(await createRepairBranchHandler(CreateRepairBranchInputSchema.parse(normArgs)));
|
|
248
|
+
case "repo.check.jules.status":
|
|
249
|
+
case "repo.check_jules_status":
|
|
250
|
+
return createSuccessResult(await checkJulesStatusHandler(CheckJulesStatusInputSchema.parse(normArgs)));
|
|
231
251
|
case "repo.run.tests":
|
|
232
252
|
case "repo.run_tests":
|
|
233
253
|
return createSuccessResult(await runTestsHandler(RunTestsInputSchema.parse(normArgs)));
|
|
@@ -246,6 +266,12 @@ export class BoomtickMCPServer {
|
|
|
246
266
|
case "github.get.pr":
|
|
247
267
|
case "github.get_pr":
|
|
248
268
|
return createSuccessResult(await getPrHandler(GetPrInputSchema.parse(normArgs)));
|
|
269
|
+
case "github.get.pr.reviews":
|
|
270
|
+
case "github.get_pr_reviews":
|
|
271
|
+
return createSuccessResult(await getPrReviewsHandler(GetPrReviewsInputSchema.parse(normArgs)));
|
|
272
|
+
case "github.get.pr.commits":
|
|
273
|
+
case "github.get_pr_commits":
|
|
274
|
+
return createSuccessResult(await getPrCommitsHandler(GetPrCommitsInputSchema.parse(normArgs)));
|
|
249
275
|
case "github.issue.view":
|
|
250
276
|
case "github.issue_view":
|
|
251
277
|
return createSuccessResult(await issueViewHandler(IssueViewInputSchema.parse(normArgs)));
|
|
@@ -371,24 +397,35 @@ export class BoomtickMCPServer {
|
|
|
371
397
|
}
|
|
372
398
|
}
|
|
373
399
|
|
|
374
|
-
async startHttp(port = parseInt(process.env.PORT || process.env.HTTP_PORT || "3000", 10), bearerToken?: string) {
|
|
375
|
-
this.prepareEnvironmentAndPreflight();
|
|
376
|
-
const mcpHttp = createMcpHttpServer({ port, bearerToken, mcpServer: this.server });
|
|
377
|
-
return await mcpHttp.listen(port);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
400
|
async run() {
|
|
381
401
|
this.prepareEnvironmentAndPreflight();
|
|
382
402
|
|
|
403
|
+
const args = process.argv.slice(2);
|
|
404
|
+
let port = parseInt(process.env.PORT || process.env.HTTP_PORT || "3000", 10);
|
|
405
|
+
let bearerToken = process.env.BOOMTICK_MCP_BEARER_TOKEN || process.env.MCP_BEARER_TOKEN || process.env.AUTH_TOKEN;
|
|
406
|
+
|
|
407
|
+
for (let i = 0; i < args.length; i++) {
|
|
408
|
+
if (args[i] === "--port" && i + 1 < args.length) {
|
|
409
|
+
port = parseInt(args[i + 1], 10);
|
|
410
|
+
i++;
|
|
411
|
+
} else if (args[i].startsWith("--port=")) {
|
|
412
|
+
port = parseInt(args[i].split("=")[1], 10);
|
|
413
|
+
} else if (args[i] === "--bearer-token" && i + 1 < args.length) {
|
|
414
|
+
bearerToken = args[i + 1];
|
|
415
|
+
i++;
|
|
416
|
+
} else if (args[i].startsWith("--bearer-token=")) {
|
|
417
|
+
bearerToken = args[i].split("=")[1];
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
383
421
|
const isHttp =
|
|
384
422
|
process.env.MCP_TRANSPORT === "http" ||
|
|
385
|
-
|
|
386
|
-
(process.env.PORT !== undefined && !
|
|
387
|
-
(process.env.HTTP_PORT !== undefined && !
|
|
423
|
+
args.includes("--http") ||
|
|
424
|
+
(process.env.PORT !== undefined && !args.includes("--stdio")) ||
|
|
425
|
+
(process.env.HTTP_PORT !== undefined && !args.includes("--stdio"));
|
|
388
426
|
|
|
389
427
|
if (isHttp) {
|
|
390
|
-
const
|
|
391
|
-
const mcpHttp = createMcpHttpServer({ port, mcpServer: this.server });
|
|
428
|
+
const mcpHttp = createMcpHttpServer({ port, bearerToken, mcpServer: this.server });
|
|
392
429
|
await mcpHttp.listen(port);
|
|
393
430
|
return;
|
|
394
431
|
}
|