Repository navigation
feat(claude/mcp): optional capability filter for genesis-tools MCP #262
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
da0e69a
c26a6df
bc15f8e
de07733
81efb07
0003c2d
62290fd
bf6f20e
6db0de8
19006f0
349a889
6c7d370
76d8e34
d067c01
ac0b854
1823d38
49a794d
31338c4
8bffeaa
85d9376
fa0bc35
07a34aa
22443ad
abd38b7
7ac1ddb
067d76f
8934c32
bcd1dad
b846302
b41b588
2d35604
7035376
8f37b06
6cf5b35
ce8652b
5282fc8
1d4d655
9fbb051
3069477
24bd2ad
6524a96
7c05da0
14ae9ca
806d181
2bb177f
a10f9b5
3c42643
651c3a1
ffc226d
de94c61
147699e
b0d6945
504eae1
dd661b9
16c5b71
294d47b
78858b4
4fc47c9
5a09bde
920c38b
2f38a93
3561061
dd9f83d
be01db4
f5ab3ab
6d64a48
42cc631
c43a5f8
360cf71
d144f63
2d66170
0e381a5
6d6e6da
3049291
282861a
7840cf4
b681abf
a06ff53
087a7af
dd78a66
67b7540
157d611
edf3f62
9d68e6e
8b01f4c
e895468
c6740f4
19989f7
9b50884
21056d2
ef5bfda
02db08d
6f64e57
d580ff2
3c524d9
eea848f
33eb63e
59b864f
5eaf47d
604675c
9cd2bde
edc130e
7beb84d
5c25f43
67b89b5
d312eac
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| import { logger } from "@app/logger"; | ||
| import { env } from "@app/utils/env/envVariables"; | ||
| import { Server } from "@modelcontextprotocol/sdk/server/index.js"; | ||
| import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | ||
| import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; | ||
|
|
@@ -351,8 +352,37 @@ function buildToolRegistry(): Record<string, ToolEntry> { | |
| }; | ||
| } | ||
|
|
||
| /** Known capability names, keyed to the tool-name prefix that identifies membership. */ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value Remove the redundant capability-map comment.
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| const CAPABILITY_PREFIXES: Record<string, string> = { | ||
| question_answer: "question_answer", | ||
| boards: "boards_", | ||
| }; | ||
|
|
||
| /** | ||
| * Filters the tool registry by GENESIS_TOOLS_MCP_CAPABILITIES (comma-delimited, e.g. | ||
| * "question_answer,boards"). Unset or empty -> every capability enabled (unchanged default). | ||
| */ | ||
| function filterRegistryByCapabilities(registry: Record<string, ToolEntry>): Record<string, ToolEntry> { | ||
| const capabilities = env.tools.getMcpCapabilities(); | ||
| if (capabilities === undefined) { | ||
| return registry; | ||
| } | ||
|
|
||
| const enabledPrefixes = capabilities | ||
| .map((capability) => CAPABILITY_PREFIXES[capability]) | ||
| .filter((prefix): prefix is string => prefix !== undefined); | ||
|
|
||
| return Object.fromEntries( | ||
| Object.entries(registry).filter(([name]) => enabledPrefixes.some((prefix) => name.startsWith(prefix))) | ||
| ); | ||
|
Comment on lines
+371
to
+377
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If According to the PR description, "empty list after parse behaves as 'all'". To align with this behavior and prevent a silently broken server, we should fall back to the full registry and log a warning when no valid capabilities are resolved. const enabledPrefixes = capabilities
.map((capability) => CAPABILITY_PREFIXES[capability])
.filter((prefix): prefix is string => prefix !== undefined);
if (enabledPrefixes.length === 0) {
log.warn(
{ capabilities },
"None of the specified GENESIS_TOOLS_MCP_CAPABILITIES are valid. Falling back to all capabilities."
);
return registry;
}
return Object.fromEntries(
Object.entries(registry).filter(([name]) => enabledPrefixes.some((prefix) => name.startsWith(prefix)))
);References
|
||
| } | ||
|
|
||
| export async function startMcpServer(): Promise<void> { | ||
| const registry = buildToolRegistry(); | ||
| const registry = filterRegistryByCapabilities(buildToolRegistry()); | ||
| log.info( | ||
| { capabilities: env.tools.getMcpCapabilities() ?? "all", tools: Object.keys(registry) }, | ||
| "genesis-tools MCP tool registry resolved" | ||
| ); | ||
| const server = new Server( | ||
| { name: "genesis-tools", version: "1.0.0" }, | ||
| { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| import { describe, expect, it } from "bun:test"; | ||
| import { | ||
| type BlueOceanNode, | ||
| blueOceanNodesUrls, | ||
| buildContextMap, | ||
| contextFromBlueOcean, | ||
| dedupeConsecutive, | ||
| } from "./blue-ocean"; | ||
|
|
||
| describe("blueOceanNodesUrls", () => { | ||
| it("prefers multibranch branch URL then full pipeline fallback", () => { | ||
| const urls = blueOceanNodesUrls("job/Digi22/job/col/job/FE/job/col-fe-multibranch-build/job/develop", "7180"); | ||
| expect(urls).toHaveLength(2); | ||
| expect(urls[0]).toContain("/pipelines/Digi22/pipelines/col/pipelines/FE/pipelines/col-fe-multibranch-build/"); | ||
| expect(urls[0]).toContain("/branches/develop/runs/7180/nodes/"); | ||
| expect(urls[1]).toContain("/pipelines/develop/runs/7180/nodes/"); | ||
| expect(urls[1]).not.toContain("/branches/"); | ||
| }); | ||
|
|
||
| it("percent-encodes branch segments (Jenkins stores feature/foo as one segment)", () => { | ||
| // Classic URL already has one path segment for the branch; % must be re-encoded for Blue Ocean. | ||
| const urls = blueOceanNodesUrls("job/pipe/job/feature%2Ffoo", "3"); | ||
| expect(urls[0]).toContain("/branches/feature%252Ffoo/"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("dedupeConsecutive", () => { | ||
| it("collapses PARALLEL+STAGE duplicate names", () => { | ||
| expect(dedupeConsecutive(["Build affected apps", "fee-web", "fee-web", "Tests"])).toEqual([ | ||
| "Build affected apps", | ||
| "fee-web", | ||
| "Tests", | ||
| ]); | ||
| }); | ||
| }); | ||
|
|
||
| describe("contextFromBlueOcean", () => { | ||
| const nodes: BlueOceanNode[] = [ | ||
| { id: "12", displayName: "Clone", type: "STAGE", firstParent: null }, | ||
| { id: "58", displayName: "Repo QA", type: "STAGE", firstParent: "12" }, | ||
| { id: "62", displayName: "Type check", type: "PARALLEL", firstParent: "58" }, | ||
| { id: "75", displayName: "Build affected apps", type: "STAGE", firstParent: null }, | ||
| { id: "82", displayName: "fee-web", type: "PARALLEL", firstParent: "75" }, | ||
| { id: "91", displayName: "fee-web", type: "STAGE", firstParent: "82" }, | ||
| { id: "110", displayName: "Tests", type: "STAGE", firstParent: "91" }, | ||
| { id: "166", displayName: "Build", type: "STAGE", firstParent: "110" }, | ||
| { id: "499", displayName: "SonarQube", type: "STAGE", firstParent: "480" }, | ||
| { id: "480", displayName: "deploy", type: "STAGE", firstParent: "346" }, | ||
| { id: "346", displayName: "Build image", type: "STAGE", firstParent: "166" }, | ||
| ]; | ||
| const byId = new Map(nodes.map((n) => [n.id, n])); | ||
|
|
||
| it("labels Tests with fee-web context", () => { | ||
| const ctx = contextFromBlueOcean("110", byId)!; | ||
| expect(ctx.context).toBe("fee-web"); | ||
| expect(ctx.label).toBe("fee-web · Tests"); | ||
| expect(ctx.path).toEqual(["Build affected apps", "fee-web", "Tests"]); | ||
| }); | ||
|
|
||
| it("labels nested Build under fee-web", () => { | ||
| const ctx = contextFromBlueOcean("166", byId)!; | ||
| expect(ctx.context).toBe("fee-web"); | ||
| expect(ctx.label).toBe("fee-web · Build"); | ||
| }); | ||
|
|
||
| it("labels SonarQube with fee-web (not deploy)", () => { | ||
| const ctx = contextFromBlueOcean("499", byId)!; | ||
| expect(ctx.context).toBe("fee-web"); | ||
| expect(ctx.label).toBe("fee-web · SonarQube"); | ||
| expect(ctx.path).toContain("fee-web"); | ||
| expect(ctx.path[ctx.path.length - 1]).toBe("SonarQube"); | ||
| }); | ||
|
|
||
| it("labels parallel QA under Repo QA", () => { | ||
| const ctx = contextFromBlueOcean("62", byId)!; | ||
| expect(ctx.context).toBe("Repo QA"); | ||
| expect(ctx.label).toBe("Repo QA · Type check"); | ||
| }); | ||
|
|
||
| it("keeps top-level Clone plain", () => { | ||
| const ctx = contextFromBlueOcean("12", byId)!; | ||
| expect(ctx.context).toBeUndefined(); | ||
| expect(ctx.label).toBe("Clone"); | ||
| expect(ctx.path).toEqual(["Clone"]); | ||
| }); | ||
|
|
||
| it("keeps fee-web shell label plain (no fee-web · fee-web)", () => { | ||
| const ctx = contextFromBlueOcean("91", byId)!; | ||
| expect(ctx.label).toBe("fee-web"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("buildContextMap", () => { | ||
| it("indexes every node id", () => { | ||
| const map = buildContextMap([ | ||
| { id: "1", displayName: "A", type: "STAGE", firstParent: null }, | ||
| { id: "2", displayName: "B", type: "STAGE", firstParent: "1" }, | ||
| ]); | ||
| expect(map.get("2")?.label).toBe("A · B"); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Shared-promise fix looks correct; add a regression test.
The synchronous assignment of
shutdownRunbefore any await can interleave correctly prevents the race this replaces (a second signal short-circuiting past in-flight handlers). No functional issue found.Since this fixes a subtle concurrency bug, consider adding a unit test (e.g., push two async handlers, call
runHandlerstwice concurrently, assert the second call awaits the same promise and handlers run exactly once in reverse order) to guard against regression.🤖 Prompt for AI Agents