yontrack-mcp 1.0.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/LICENSE +674 -0
- package/README.md +305 -0
- package/build/auth.js +228 -0
- package/build/client.js +5 -0
- package/build/config.js +23 -0
- package/build/index.js +127 -0
- package/build/server.js +21 -0
- package/build/test/helpers.js +17 -0
- package/build/test/setup.js +5 -0
- package/build/tools/branches.js +55 -0
- package/build/tools/branches.test.js +82 -0
- package/build/tools/build-links.js +73 -0
- package/build/tools/build-links.test.js +84 -0
- package/build/tools/builds.js +139 -0
- package/build/tools/builds.test.js +156 -0
- package/build/tools/graphql.js +31 -0
- package/build/tools/graphql.test.js +82 -0
- package/build/tools/index.js +23 -0
- package/build/tools/projects.js +47 -0
- package/build/tools/projects.test.js +76 -0
- package/build/tools/promotion-levels.js +83 -0
- package/build/tools/promotion-levels.test.js +118 -0
- package/build/tools/promotion-runs.js +78 -0
- package/build/tools/promotion-runs.test.js +82 -0
- package/build/tools/search.js +27 -0
- package/build/tools/search.test.js +49 -0
- package/build/tools/validation-runs.js +93 -0
- package/build/tools/validation-runs.test.js +82 -0
- package/build/tools/validation-stamps.js +63 -0
- package/build/tools/validation-stamps.test.js +76 -0
- package/build/utils.js +16 -0
- package/package.json +37 -0
- package/yontrack.graphql +11937 -0
package/build/index.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import express from "express";
|
|
4
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
5
|
+
import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
6
|
+
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
|
7
|
+
import { createServer } from "./server.js";
|
|
8
|
+
import { oauthConfig } from "./config.js";
|
|
9
|
+
import { createOAuthProvider, clientsStore, generateAuthCode, renderAuthFormHtml, } from "./auth.js";
|
|
10
|
+
if (process.argv[2] === "stdio") {
|
|
11
|
+
const server = createServer();
|
|
12
|
+
const transport = new StdioServerTransport();
|
|
13
|
+
await server.connect(transport);
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
const port = parseInt(process.env.PORT ?? "3000", 10);
|
|
17
|
+
const app = express();
|
|
18
|
+
// Trust the first reverse proxy (e.g. Kubernetes ingress controller).
|
|
19
|
+
// Required for express-rate-limit (used by the OAuth2 handlers) to work correctly
|
|
20
|
+
// when X-Forwarded-For is set by the ingress.
|
|
21
|
+
app.set("trust proxy", 1);
|
|
22
|
+
// Log every request for diagnostics (excluding /health to reduce noise)
|
|
23
|
+
app.use((req, res, next) => {
|
|
24
|
+
if (req.path !== "/health") {
|
|
25
|
+
res.on("finish", () => {
|
|
26
|
+
process.stderr.write(`${req.method} ${req.originalUrl} → ${res.statusCode}\n`);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
next();
|
|
30
|
+
});
|
|
31
|
+
// Static assets (e.g. server icon)
|
|
32
|
+
app.use(express.static("public"));
|
|
33
|
+
// Health check
|
|
34
|
+
app.get("/health", (_req, res) => {
|
|
35
|
+
res.json({ status: "ok" });
|
|
36
|
+
});
|
|
37
|
+
// MCP request handler
|
|
38
|
+
async function handleMcp(req, res) {
|
|
39
|
+
const server = createServer(oauthConfig?.serverUrl);
|
|
40
|
+
const transport = new StreamableHTTPServerTransport({
|
|
41
|
+
sessionIdGenerator: undefined,
|
|
42
|
+
});
|
|
43
|
+
res.on("close", () => transport.close());
|
|
44
|
+
await server.connect(transport);
|
|
45
|
+
// req.body is pre-parsed by express.json() for POST requests
|
|
46
|
+
await transport.handleRequest(req, res, req.body);
|
|
47
|
+
}
|
|
48
|
+
function oauthLog(msg) {
|
|
49
|
+
process.stderr.write(`[oauth] ${msg}\n`);
|
|
50
|
+
}
|
|
51
|
+
if (oauthConfig) {
|
|
52
|
+
const { serverUrl, authPassword } = oauthConfig;
|
|
53
|
+
const provider = createOAuthProvider(serverUrl);
|
|
54
|
+
// Log all incoming OAuth-related requests for easier diagnostics
|
|
55
|
+
app.use([
|
|
56
|
+
"/.well-known/oauth-authorization-server",
|
|
57
|
+
"/.well-known/oauth-protected-resource",
|
|
58
|
+
"/authorize",
|
|
59
|
+
"/token",
|
|
60
|
+
"/register",
|
|
61
|
+
"/revoke",
|
|
62
|
+
], (req, _res, next) => {
|
|
63
|
+
oauthLog(`${req.method} ${req.path}`);
|
|
64
|
+
next();
|
|
65
|
+
});
|
|
66
|
+
// Login form submission must be registered BEFORE mcpAuthRouter because the
|
|
67
|
+
// SDK's /authorize handler (prefix-matched) also runs urlencoded() middleware
|
|
68
|
+
// which would consume the request body before our handler gets to it.
|
|
69
|
+
app.post("/authorize/login", express.urlencoded({ extended: false }), async (req, res) => {
|
|
70
|
+
const { client_id, redirect_uri, code_challenge, scopes, state, password, } = req.body;
|
|
71
|
+
const client = await clientsStore.getClient(client_id);
|
|
72
|
+
if (!client) {
|
|
73
|
+
oauthLog(`ERROR: Login attempt for unknown client: ${client_id}`);
|
|
74
|
+
res
|
|
75
|
+
.status(400)
|
|
76
|
+
.json({ error: "invalid_client", error_description: "Unknown client" });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!password || password !== authPassword) {
|
|
80
|
+
oauthLog(`Login failed — wrong password for client ${client_id}`);
|
|
81
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
82
|
+
res.end(renderAuthFormHtml({
|
|
83
|
+
serverUrl,
|
|
84
|
+
clientName: client.client_name ?? client.client_id,
|
|
85
|
+
clientId: client_id,
|
|
86
|
+
redirectUri: redirect_uri ?? "",
|
|
87
|
+
codeChallenge: code_challenge ?? "",
|
|
88
|
+
scopes: scopes ?? "",
|
|
89
|
+
state: state ?? "",
|
|
90
|
+
error: "Invalid password. Please try again.",
|
|
91
|
+
}));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const code = generateAuthCode({
|
|
95
|
+
clientId: client_id,
|
|
96
|
+
codeChallenge: code_challenge ?? "",
|
|
97
|
+
redirectUri: redirect_uri ?? "",
|
|
98
|
+
scopes: scopes ? scopes.split(" ").filter(Boolean) : [],
|
|
99
|
+
state: state || undefined,
|
|
100
|
+
});
|
|
101
|
+
oauthLog(`Login successful for client ${client_id}, redirecting with auth code`);
|
|
102
|
+
const redirectUrl = new URL(redirect_uri);
|
|
103
|
+
redirectUrl.searchParams.set("code", code);
|
|
104
|
+
if (state)
|
|
105
|
+
redirectUrl.searchParams.set("state", state);
|
|
106
|
+
res.redirect(302, redirectUrl.href);
|
|
107
|
+
});
|
|
108
|
+
// OAuth2 discovery + token endpoints
|
|
109
|
+
app.use(mcpAuthRouter({
|
|
110
|
+
provider,
|
|
111
|
+
issuerUrl: new URL(serverUrl),
|
|
112
|
+
resourceName: "Yontrack MCP",
|
|
113
|
+
}));
|
|
114
|
+
// Protected MCP endpoint — requires a valid Bearer token
|
|
115
|
+
app.all("/mcp", express.json(), requireBearerAuth({ verifier: provider }), handleMcp);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
// No OAuth configured — MCP is accessible without authentication
|
|
119
|
+
app.all("/mcp", express.json(), handleMcp);
|
|
120
|
+
}
|
|
121
|
+
app.listen(port, () => {
|
|
122
|
+
process.stderr.write(`MCP server listening on port ${port}\n`);
|
|
123
|
+
if (oauthConfig) {
|
|
124
|
+
process.stderr.write(`OAuth2 enabled — issuer: ${oauthConfig.serverUrl}\n`);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
package/build/server.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { registerAllTools } from "./tools/index.js";
|
|
3
|
+
export function createServer(serverUrl) {
|
|
4
|
+
const server = new McpServer({
|
|
5
|
+
name: "yontrack",
|
|
6
|
+
version: "1.0.0",
|
|
7
|
+
...(serverUrl && {
|
|
8
|
+
icons: [{ src: `${serverUrl}/yontrack.png`, mimeType: "image/png" }],
|
|
9
|
+
}),
|
|
10
|
+
}, {
|
|
11
|
+
instructions: `
|
|
12
|
+
Use the specific tools (list_projects, get_build, etc.) for simple, single-entity lookups.
|
|
13
|
+
When a task would require calling multiple tools in a loop — for example fetching builds across many branches, or collecting validation runs for a list of builds — use graphql_query instead to retrieve all needed data in a single round-trip.
|
|
14
|
+
Read the yontrack://schema resource before writing a GraphQL query to understand available types and fields.
|
|
15
|
+
When displaying branches, builds, or other entities to the user, always prefer the displayName field over the name field.
|
|
16
|
+
When displaying promotion levels (in any form: list, table, chart, or graph), always fetch their image using get_promotion_level_image for each level whose image field is true, and embed the returned base64 data directly as a data URI (e.g. <img src="data:image/png;base64,...">) alongside the promotion level name. Do not reference the MCP server URL for images as it is not accessible from the browser.
|
|
17
|
+
`.trim(),
|
|
18
|
+
});
|
|
19
|
+
registerAllTools(server);
|
|
20
|
+
return server;
|
|
21
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
4
|
+
/**
|
|
5
|
+
* Creates a linked in-process MCP server+client pair.
|
|
6
|
+
* Pass a register function to attach tools to the server before connecting.
|
|
7
|
+
* `allowMutations` defaults to `true` so existing tests cover all tools.
|
|
8
|
+
*/
|
|
9
|
+
export async function createTestClient(register, allowMutations = true) {
|
|
10
|
+
const server = new McpServer({ name: "test", version: "0.0.0" });
|
|
11
|
+
register(server, allowMutations);
|
|
12
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
13
|
+
const client = new Client({ name: "test-client", version: "0.0.0" });
|
|
14
|
+
await server.connect(serverTransport);
|
|
15
|
+
await client.connect(clientTransport);
|
|
16
|
+
return client;
|
|
17
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { gqlClient } from "../client.js";
|
|
3
|
+
const LIST_BRANCHES = `
|
|
4
|
+
query ListBranches($project: String!, $name: String) {
|
|
5
|
+
branches(project: $project, name: $name) {
|
|
6
|
+
id
|
|
7
|
+
name
|
|
8
|
+
displayName
|
|
9
|
+
description
|
|
10
|
+
disabled
|
|
11
|
+
project { id name }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
`;
|
|
15
|
+
const CREATE_BRANCH = `
|
|
16
|
+
mutation CreateBranch($projectName: String!, $name: String!, $description: String) {
|
|
17
|
+
createBranch(input: {
|
|
18
|
+
projectName: $projectName
|
|
19
|
+
name: $name
|
|
20
|
+
description: $description
|
|
21
|
+
}) {
|
|
22
|
+
branch { id name displayName description disabled project { id name } }
|
|
23
|
+
userErrors { message }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
`;
|
|
27
|
+
export function registerBranchTools(server, allowMutations) {
|
|
28
|
+
server.tool("list_branches", "List branches for a project, optionally filtered by name", {
|
|
29
|
+
project: z.string().describe("Project name"),
|
|
30
|
+
name: z.string().optional().describe("Filter by branch name"),
|
|
31
|
+
}, async ({ project, name }) => {
|
|
32
|
+
const data = await gqlClient.request(LIST_BRANCHES, { project, name });
|
|
33
|
+
return {
|
|
34
|
+
content: [{ type: "text", text: JSON.stringify(data.branches, null, 2) }],
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
if (allowMutations)
|
|
38
|
+
server.tool("create_branch", "Create a new branch in a project", {
|
|
39
|
+
project: z.string().describe("Project name"),
|
|
40
|
+
name: z.string().describe("Branch name"),
|
|
41
|
+
description: z.string().optional().describe("Branch description"),
|
|
42
|
+
}, async ({ project, name, description }) => {
|
|
43
|
+
const data = await gqlClient.request(CREATE_BRANCH, { projectName: project, name, description });
|
|
44
|
+
const { branch, userErrors } = data.createBranch;
|
|
45
|
+
if (userErrors?.length) {
|
|
46
|
+
return {
|
|
47
|
+
isError: true,
|
|
48
|
+
content: [{ type: "text", text: userErrors.map((e) => e.message).join(", ") }],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
content: [{ type: "text", text: JSON.stringify(branch, null, 2) }],
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { createTestClient } from "../test/helpers.js";
|
|
3
|
+
import { registerBranchTools } from "./branches.js";
|
|
4
|
+
vi.mock("../client.js", () => ({
|
|
5
|
+
gqlClient: { request: vi.fn() },
|
|
6
|
+
}));
|
|
7
|
+
const { gqlClient } = await import("../client.js");
|
|
8
|
+
const mockRequest = gqlClient.request;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
mockRequest.mockReset();
|
|
11
|
+
});
|
|
12
|
+
describe("list_branches", () => {
|
|
13
|
+
it("returns branches for a project", async () => {
|
|
14
|
+
const branches = [
|
|
15
|
+
{ id: 1, name: "main", displayName: "main", description: null, disabled: false, project: { id: 1, name: "myproject" } },
|
|
16
|
+
];
|
|
17
|
+
mockRequest.mockResolvedValueOnce({ branches });
|
|
18
|
+
const client = await createTestClient(registerBranchTools);
|
|
19
|
+
const result = await client.callTool({
|
|
20
|
+
name: "list_branches",
|
|
21
|
+
arguments: { project: "myproject" },
|
|
22
|
+
});
|
|
23
|
+
const text = result.content[0].text;
|
|
24
|
+
expect(JSON.parse(text)).toEqual(branches);
|
|
25
|
+
});
|
|
26
|
+
it("returns empty array when no branches match", async () => {
|
|
27
|
+
mockRequest.mockResolvedValueOnce({ branches: [] });
|
|
28
|
+
const client = await createTestClient(registerBranchTools);
|
|
29
|
+
const result = await client.callTool({
|
|
30
|
+
name: "list_branches",
|
|
31
|
+
arguments: { project: "myproject", name: "nonexistent" },
|
|
32
|
+
});
|
|
33
|
+
const text = result.content[0].text;
|
|
34
|
+
expect(JSON.parse(text)).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
it("passes the name filter to the GraphQL client", async () => {
|
|
37
|
+
mockRequest.mockResolvedValueOnce({ branches: [] });
|
|
38
|
+
const client = await createTestClient(registerBranchTools);
|
|
39
|
+
await client.callTool({
|
|
40
|
+
name: "list_branches",
|
|
41
|
+
arguments: { project: "myproject", name: "feature" },
|
|
42
|
+
});
|
|
43
|
+
expect(mockRequest).toHaveBeenCalledWith(expect.any(String), { project: "myproject", name: "feature" });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe("create_branch", () => {
|
|
47
|
+
it("returns the created branch", async () => {
|
|
48
|
+
const branch = { id: 10, name: "feature-x", displayName: "feature-x", description: "My feature", disabled: false };
|
|
49
|
+
mockRequest.mockResolvedValueOnce({ createBranch: { branch, userErrors: [] } });
|
|
50
|
+
const client = await createTestClient(registerBranchTools);
|
|
51
|
+
const result = await client.callTool({
|
|
52
|
+
name: "create_branch",
|
|
53
|
+
arguments: { project: "myproject", name: "feature-x", description: "My feature" },
|
|
54
|
+
});
|
|
55
|
+
expect(result.isError).toBeFalsy();
|
|
56
|
+
const text = result.content[0].text;
|
|
57
|
+
expect(JSON.parse(text)).toEqual(branch);
|
|
58
|
+
});
|
|
59
|
+
it("returns an error when userErrors is non-empty", async () => {
|
|
60
|
+
mockRequest.mockResolvedValueOnce({
|
|
61
|
+
createBranch: { branch: null, userErrors: [{ message: "Branch already exists" }] },
|
|
62
|
+
});
|
|
63
|
+
const client = await createTestClient(registerBranchTools);
|
|
64
|
+
const result = await client.callTool({
|
|
65
|
+
name: "create_branch",
|
|
66
|
+
arguments: { project: "myproject", name: "main" },
|
|
67
|
+
});
|
|
68
|
+
expect(result.isError).toBe(true);
|
|
69
|
+
const text = result.content[0].text;
|
|
70
|
+
expect(text).toContain("Branch already exists");
|
|
71
|
+
});
|
|
72
|
+
it("is not registered when mutations are disabled", async () => {
|
|
73
|
+
const client = await createTestClient(registerBranchTools, false);
|
|
74
|
+
const { tools } = await client.listTools();
|
|
75
|
+
expect(tools.map((t) => t.name)).not.toContain("create_branch");
|
|
76
|
+
});
|
|
77
|
+
it("is registered when mutations are enabled", async () => {
|
|
78
|
+
const client = await createTestClient(registerBranchTools, true);
|
|
79
|
+
const { tools } = await client.listTools();
|
|
80
|
+
expect(tools.map((t) => t.name)).toContain("create_branch");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { gqlClient } from "../client.js";
|
|
3
|
+
const GET_BUILD_LINKS = `
|
|
4
|
+
query GetBuildLinks($project: String!, $branch: String!, $build: String!) {
|
|
5
|
+
builds(project: $project, branch: $branch, name: $build) {
|
|
6
|
+
usingQualified {
|
|
7
|
+
pageItems {
|
|
8
|
+
qualifier
|
|
9
|
+
build { id name branch { id name project { id name } } }
|
|
10
|
+
}
|
|
11
|
+
pageInfo { totalSize }
|
|
12
|
+
}
|
|
13
|
+
usedByQualified {
|
|
14
|
+
pageItems {
|
|
15
|
+
qualifier
|
|
16
|
+
build { id name branch { id name project { id name } } }
|
|
17
|
+
}
|
|
18
|
+
pageInfo { totalSize }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
`;
|
|
23
|
+
const SET_BUILD_LINKS = `
|
|
24
|
+
mutation LinksBuild(
|
|
25
|
+
$fromProject: String!
|
|
26
|
+
$fromBuild: String!
|
|
27
|
+
$links: [LinksBuildInputItem!]!
|
|
28
|
+
) {
|
|
29
|
+
linksBuild(input: {
|
|
30
|
+
fromProject: $fromProject
|
|
31
|
+
fromBuild: $fromBuild
|
|
32
|
+
links: $links
|
|
33
|
+
}) {
|
|
34
|
+
userErrors { message }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
`;
|
|
38
|
+
const BuildLinkInput = z.object({
|
|
39
|
+
project: z.string().describe("Target project name"),
|
|
40
|
+
build: z.string().describe("Target build name"),
|
|
41
|
+
qualifier: z.string().optional().describe("Optional link qualifier"),
|
|
42
|
+
});
|
|
43
|
+
export function registerBuildLinkTools(server, allowMutations) {
|
|
44
|
+
server.tool("get_build_links", "Get dependency links for a build (builds it uses and builds that use it)", {
|
|
45
|
+
project: z.string().describe("Project name"),
|
|
46
|
+
branch: z.string().describe("Branch name"),
|
|
47
|
+
build: z.string().describe("Build name"),
|
|
48
|
+
}, async ({ project, branch, build }) => {
|
|
49
|
+
const data = await gqlClient.request(GET_BUILD_LINKS, { project, branch, build });
|
|
50
|
+
const result = data.builds?.[0] ?? { usingQualified: null, usedByQualified: null };
|
|
51
|
+
return {
|
|
52
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
if (allowMutations)
|
|
56
|
+
server.tool("set_build_links", "Set dependency links from a build to one or more target builds", {
|
|
57
|
+
fromProject: z.string().describe("Source project name"),
|
|
58
|
+
fromBuild: z.string().describe("Source build name"),
|
|
59
|
+
links: z.array(BuildLinkInput).describe("Target builds to link to"),
|
|
60
|
+
}, async ({ fromProject, fromBuild, links }) => {
|
|
61
|
+
const data = await gqlClient.request(SET_BUILD_LINKS, { fromProject, fromBuild, links });
|
|
62
|
+
const { userErrors } = data.linksBuild;
|
|
63
|
+
if (userErrors?.length) {
|
|
64
|
+
return {
|
|
65
|
+
isError: true,
|
|
66
|
+
content: [{ type: "text", text: userErrors.map((e) => e.message).join(", ") }],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
content: [{ type: "text", text: "Build links updated successfully." }],
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { createTestClient } from "../test/helpers.js";
|
|
3
|
+
import { registerBuildLinkTools } from "./build-links.js";
|
|
4
|
+
vi.mock("../client.js", () => ({
|
|
5
|
+
gqlClient: { request: vi.fn() },
|
|
6
|
+
}));
|
|
7
|
+
const { gqlClient } = await import("../client.js");
|
|
8
|
+
const mockRequest = gqlClient.request;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
mockRequest.mockReset();
|
|
11
|
+
});
|
|
12
|
+
describe("get_build_links", () => {
|
|
13
|
+
it("returns using and usedBy links for a build", async () => {
|
|
14
|
+
const links = {
|
|
15
|
+
usingQualified: {
|
|
16
|
+
pageItems: [{ qualifier: null, build: { id: 2, name: "dep-1.0.0", branch: { id: 5, name: "main", project: { id: 3, name: "dep" } } } }],
|
|
17
|
+
pageInfo: { totalSize: 1 },
|
|
18
|
+
},
|
|
19
|
+
usedByQualified: { pageItems: [], pageInfo: { totalSize: 0 } },
|
|
20
|
+
};
|
|
21
|
+
mockRequest.mockResolvedValueOnce({ builds: [links] });
|
|
22
|
+
const client = await createTestClient(registerBuildLinkTools);
|
|
23
|
+
const result = await client.callTool({
|
|
24
|
+
name: "get_build_links",
|
|
25
|
+
arguments: { project: "myproject", branch: "main", build: "1.0.1" },
|
|
26
|
+
});
|
|
27
|
+
const text = result.content[0].text;
|
|
28
|
+
expect(JSON.parse(text)).toEqual(links);
|
|
29
|
+
});
|
|
30
|
+
it("returns nulls when build not found", async () => {
|
|
31
|
+
mockRequest.mockResolvedValueOnce({ builds: [] });
|
|
32
|
+
const client = await createTestClient(registerBuildLinkTools);
|
|
33
|
+
const result = await client.callTool({
|
|
34
|
+
name: "get_build_links",
|
|
35
|
+
arguments: { project: "myproject", branch: "main", build: "missing" },
|
|
36
|
+
});
|
|
37
|
+
const text = result.content[0].text;
|
|
38
|
+
expect(JSON.parse(text)).toEqual({ usingQualified: null, usedByQualified: null });
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
describe("set_build_links", () => {
|
|
42
|
+
it("returns success message when links are set", async () => {
|
|
43
|
+
mockRequest.mockResolvedValueOnce({ linksBuild: { userErrors: [] } });
|
|
44
|
+
const client = await createTestClient(registerBuildLinkTools);
|
|
45
|
+
const result = await client.callTool({
|
|
46
|
+
name: "set_build_links",
|
|
47
|
+
arguments: {
|
|
48
|
+
fromProject: "myproject",
|
|
49
|
+
fromBuild: "1.0.1",
|
|
50
|
+
links: [{ project: "dep", build: "dep-1.0.0" }],
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
expect(result.isError).toBeFalsy();
|
|
54
|
+
const text = result.content[0].text;
|
|
55
|
+
expect(text).toContain("updated successfully");
|
|
56
|
+
});
|
|
57
|
+
it("returns an error when userErrors is non-empty", async () => {
|
|
58
|
+
mockRequest.mockResolvedValueOnce({
|
|
59
|
+
linksBuild: { userErrors: [{ message: "Build not found" }] },
|
|
60
|
+
});
|
|
61
|
+
const client = await createTestClient(registerBuildLinkTools);
|
|
62
|
+
const result = await client.callTool({
|
|
63
|
+
name: "set_build_links",
|
|
64
|
+
arguments: {
|
|
65
|
+
fromProject: "myproject",
|
|
66
|
+
fromBuild: "9.9.9",
|
|
67
|
+
links: [{ project: "dep", build: "dep-1.0.0" }],
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
expect(result.isError).toBe(true);
|
|
71
|
+
const text = result.content[0].text;
|
|
72
|
+
expect(text).toContain("Build not found");
|
|
73
|
+
});
|
|
74
|
+
it("is not registered when mutations are disabled", async () => {
|
|
75
|
+
const client = await createTestClient(registerBuildLinkTools, false);
|
|
76
|
+
const { tools } = await client.listTools();
|
|
77
|
+
expect(tools.map((t) => t.name)).not.toContain("set_build_links");
|
|
78
|
+
});
|
|
79
|
+
it("is registered when mutations are enabled", async () => {
|
|
80
|
+
const client = await createTestClient(registerBuildLinkTools, true);
|
|
81
|
+
const { tools } = await client.listTools();
|
|
82
|
+
expect(tools.map((t) => t.name)).toContain("set_build_links");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { gqlClient } from "../client.js";
|
|
3
|
+
const LIST_BUILDS = `
|
|
4
|
+
query ListBuilds($project: String!, $branch: String!, $count: Int) {
|
|
5
|
+
branches(project: $project, name: $branch) {
|
|
6
|
+
builds(count: $count) {
|
|
7
|
+
id
|
|
8
|
+
name
|
|
9
|
+
displayName
|
|
10
|
+
description
|
|
11
|
+
creation { user time }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
`;
|
|
16
|
+
const FIND_BUILD = `
|
|
17
|
+
query FindBuild($project: String!, $branch: String!, $name: String!) {
|
|
18
|
+
builds(project: $project, branch: $branch, name: $name) {
|
|
19
|
+
id
|
|
20
|
+
name
|
|
21
|
+
displayName
|
|
22
|
+
description
|
|
23
|
+
branch { id name displayName project { id name } }
|
|
24
|
+
creation { user time }
|
|
25
|
+
runInfo { sourceType sourceUri triggerType triggerData runTime }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
`;
|
|
29
|
+
const GET_BUILD_DURATION = `
|
|
30
|
+
query GetBuildDuration($project: String!, $branch: String!, $name: String!, $promotion: String!) {
|
|
31
|
+
builds(project: $project, branch: $branch, name: $name) {
|
|
32
|
+
creation { time }
|
|
33
|
+
promotionRuns(promotion: $promotion) {
|
|
34
|
+
creation { time }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
`;
|
|
39
|
+
const CREATE_BUILD = `
|
|
40
|
+
mutation CreateBuild(
|
|
41
|
+
$project: String!
|
|
42
|
+
$branch: String!
|
|
43
|
+
$name: String!
|
|
44
|
+
$description: String
|
|
45
|
+
) {
|
|
46
|
+
createBuild(input: {
|
|
47
|
+
projectName: $project
|
|
48
|
+
branchName: $branch
|
|
49
|
+
name: $name
|
|
50
|
+
description: $description
|
|
51
|
+
}) {
|
|
52
|
+
build { id name displayName description branch { id name displayName project { id name } } }
|
|
53
|
+
userErrors { message }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
`;
|
|
57
|
+
export function registerBuildTools(server, allowMutations) {
|
|
58
|
+
server.tool("list_builds", "List builds for a branch", {
|
|
59
|
+
project: z.string().describe("Project name"),
|
|
60
|
+
branch: z.string().describe("Branch name"),
|
|
61
|
+
count: z.number().int().optional().default(10).describe("Max number of builds to return"),
|
|
62
|
+
}, async ({ project, branch, count }) => {
|
|
63
|
+
const data = await gqlClient.request(LIST_BUILDS, { project, branch, count });
|
|
64
|
+
const builds = data.branches?.[0]?.builds ?? [];
|
|
65
|
+
return {
|
|
66
|
+
content: [{ type: "text", text: JSON.stringify(builds, null, 2) }],
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
server.tool("find_build", "Find a specific build by project, branch, and build name", {
|
|
70
|
+
project: z.string().describe("Project name"),
|
|
71
|
+
branch: z.string().describe("Branch name"),
|
|
72
|
+
name: z.string().describe("Build name"),
|
|
73
|
+
}, async ({ project, branch, name }) => {
|
|
74
|
+
const data = await gqlClient.request(FIND_BUILD, { project, branch, name });
|
|
75
|
+
const build = data.builds?.[0] ?? null;
|
|
76
|
+
return {
|
|
77
|
+
content: [{ type: "text", text: JSON.stringify(build, null, 2) }],
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
if (allowMutations)
|
|
81
|
+
server.tool("create_build", "Create a new build on a branch", {
|
|
82
|
+
project: z.string().describe("Project name"),
|
|
83
|
+
branch: z.string().describe("Branch name"),
|
|
84
|
+
name: z.string().describe("Build name (e.g. '1.0.42')"),
|
|
85
|
+
description: z.string().optional().describe("Build description"),
|
|
86
|
+
}, async ({ project, branch, name, description }) => {
|
|
87
|
+
const data = await gqlClient.request(CREATE_BUILD, { project, branch, name, description });
|
|
88
|
+
const { build, userErrors } = data.createBuild;
|
|
89
|
+
if (userErrors?.length) {
|
|
90
|
+
return {
|
|
91
|
+
isError: true,
|
|
92
|
+
content: [{ type: "text", text: userErrors.map((e) => e.message).join(", ") }],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
content: [{ type: "text", text: JSON.stringify(build, null, 2) }],
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
server.tool("get_build_duration", "Get the duration of a build, measured from build creation to its first promotion to a given level (defaults to BRONZE)", {
|
|
100
|
+
project: z.string().describe("Project name"),
|
|
101
|
+
branch: z.string().describe("Branch name"),
|
|
102
|
+
build: z.string().describe("Build name"),
|
|
103
|
+
promotion: z.string().optional().default("BRONZE").describe("Promotion level name to measure duration against"),
|
|
104
|
+
}, async ({ project, branch, build, promotion }) => {
|
|
105
|
+
const data = await gqlClient.request(GET_BUILD_DURATION, { project, branch, name: build, promotion });
|
|
106
|
+
const buildData = data.builds?.[0];
|
|
107
|
+
if (!buildData) {
|
|
108
|
+
return { isError: true, content: [{ type: "text", text: `Build not found: ${build}` }] };
|
|
109
|
+
}
|
|
110
|
+
const promotionRun = buildData.promotionRuns?.[0];
|
|
111
|
+
if (!promotionRun) {
|
|
112
|
+
return {
|
|
113
|
+
content: [{
|
|
114
|
+
type: "text",
|
|
115
|
+
text: JSON.stringify({
|
|
116
|
+
project, branch, build, promotion,
|
|
117
|
+
buildCreatedAt: buildData.creation.time,
|
|
118
|
+
promoted: false,
|
|
119
|
+
durationSeconds: null,
|
|
120
|
+
}, null, 2),
|
|
121
|
+
}],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const buildTime = new Date(buildData.creation.time).getTime();
|
|
125
|
+
const promotionTime = new Date(promotionRun.creation.time).getTime();
|
|
126
|
+
const durationSeconds = Math.round((promotionTime - buildTime) / 1000);
|
|
127
|
+
return {
|
|
128
|
+
content: [{
|
|
129
|
+
type: "text",
|
|
130
|
+
text: JSON.stringify({
|
|
131
|
+
project, branch, build, promotion,
|
|
132
|
+
buildCreatedAt: buildData.creation.time,
|
|
133
|
+
promotedAt: promotionRun.creation.time,
|
|
134
|
+
durationSeconds,
|
|
135
|
+
}, null, 2),
|
|
136
|
+
}],
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
}
|