yontrack-mcp 1.14.1 → 1.14.3
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/build/graphql-documents.test.js +51 -0
- package/build/tools/branches.js +4 -4
- package/build/tools/branches.test.js +3 -3
- package/build/tools/build-links.js +4 -4
- package/build/tools/build-links.test.js +3 -3
- package/build/tools/builds.js +4 -4
- package/build/tools/builds.test.js +3 -3
- package/build/tools/projects.js +4 -4
- package/build/tools/projects.test.js +3 -3
- package/build/tools/promotion-levels.js +4 -4
- package/build/tools/promotion-levels.test.js +3 -3
- package/build/tools/promotion-runs.js +4 -4
- package/build/tools/promotion-runs.test.js +17 -3
- package/build/tools/search.js +32 -28
- package/build/tools/search.test.js +38 -32
- package/build/tools/validation-runs.js +4 -4
- package/build/tools/validation-runs.test.js +3 -3
- package/build/tools/validation-stamps.js +4 -4
- package/build/tools/validation-stamps.test.js +3 -3
- package/package.json +2 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { buildSchema, parse, validate } from "graphql";
|
|
5
|
+
// Validates every GraphQL document sent by the server against the schema snapshots,
|
|
6
|
+
// so that a field the Yontrack server does not expose fails here rather than at runtime.
|
|
7
|
+
const root = (path) => fileURLToPath(new URL(`../${path}`, import.meta.url));
|
|
8
|
+
const schemas = {
|
|
9
|
+
v5: buildSchema(readFileSync(root("yontrack-v5.graphql"), "utf-8")),
|
|
10
|
+
v6: buildSchema(readFileSync(root("yontrack-v6.graphql"), "utf-8")),
|
|
11
|
+
};
|
|
12
|
+
// Yontrack 6 only: never sent to a v5 server (see docs/adr/0001-agent-context-tools-v6-only.md)
|
|
13
|
+
const V6_ONLY = new Set(["src/tools/agent-context.ts", "src/tools/agent-context-support.ts"]);
|
|
14
|
+
// Known invalid documents, as "version file NAME": these must be fixed, then removed from here
|
|
15
|
+
const KNOWN_INVALID = new Set([]);
|
|
16
|
+
const sourceFiles = [
|
|
17
|
+
"src/capabilities.ts",
|
|
18
|
+
"src/utils.ts",
|
|
19
|
+
...readdirSync(root("src/tools"))
|
|
20
|
+
.filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts"))
|
|
21
|
+
.map((f) => `src/tools/${f}`),
|
|
22
|
+
];
|
|
23
|
+
/** `const NAME = \`...\`` constants holding a query or mutation, with `${OTHER}` constants inlined. */
|
|
24
|
+
function documentsOf(file) {
|
|
25
|
+
const source = readFileSync(root(file), "utf-8");
|
|
26
|
+
const constants = new Map();
|
|
27
|
+
for (const [, name, text] of source.matchAll(/const (\w+) = `([^`]*)`;/g))
|
|
28
|
+
constants.set(name, text);
|
|
29
|
+
const inline = (text) => text.replace(/\$\{(\w+)\}/g, (match, name) => (constants.has(name) ? inline(constants.get(name)) : match));
|
|
30
|
+
return [...constants]
|
|
31
|
+
.map(([name, text]) => ({ name, text: inline(text) }))
|
|
32
|
+
.filter(({ text }) => /^\s*(query|mutation)\b/.test(text));
|
|
33
|
+
}
|
|
34
|
+
const cases = sourceFiles.flatMap((file) => documentsOf(file).flatMap(({ name, text }) => (V6_ONLY.has(file) ? ["v6"] : ["v5", "v6"]).map((version) => ({
|
|
35
|
+
file, name, text, version, known: KNOWN_INVALID.has(`${version} ${file} ${name}`),
|
|
36
|
+
}))));
|
|
37
|
+
describe("GraphQL documents", () => {
|
|
38
|
+
it("finds the documents to validate", () => {
|
|
39
|
+
const names = cases.map((c) => c.name);
|
|
40
|
+
expect(names).toContain("CREATE_VALIDATION_RUN");
|
|
41
|
+
expect(names).toContain("PROMOTE_BUILD");
|
|
42
|
+
expect(names).toContain("READINESS");
|
|
43
|
+
});
|
|
44
|
+
it.each(cases.filter((c) => !c.known))("$file $name is valid against the $version schema", ({ text, version }) => {
|
|
45
|
+
const errors = validate(schemas[version], parse(text)).map((e) => e.message);
|
|
46
|
+
expect(errors).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
it.each(cases.filter((c) => c.known))("$file $name is still a known issue against the $version schema", ({ text, version }) => {
|
|
49
|
+
expect(validate(schemas[version], parse(text))).not.toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
});
|
package/build/tools/branches.js
CHANGED
|
@@ -20,7 +20,7 @@ const CREATE_BRANCH = `
|
|
|
20
20
|
description: $description
|
|
21
21
|
}) {
|
|
22
22
|
branch { id name displayName description disabled project { id name } }
|
|
23
|
-
|
|
23
|
+
errors { message }
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
`;
|
|
@@ -41,11 +41,11 @@ export function registerBranchTools(server, allowMutations) {
|
|
|
41
41
|
description: z.string().optional().describe("Branch description"),
|
|
42
42
|
}, async ({ project, name, description }) => {
|
|
43
43
|
const data = await gqlClient.request(CREATE_BRANCH, { projectName: project, name, description });
|
|
44
|
-
const { branch,
|
|
45
|
-
if (
|
|
44
|
+
const { branch, errors } = data.createBranch;
|
|
45
|
+
if (errors?.length) {
|
|
46
46
|
return {
|
|
47
47
|
isError: true,
|
|
48
|
-
content: [{ type: "text", text:
|
|
48
|
+
content: [{ type: "text", text: errors.map((e) => e.message).join(", ") }],
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
return {
|
|
@@ -46,7 +46,7 @@ describe("list_branches", () => {
|
|
|
46
46
|
describe("create_branch", () => {
|
|
47
47
|
it("returns the created branch", async () => {
|
|
48
48
|
const branch = { id: 10, name: "feature-x", displayName: "feature-x", description: "My feature", disabled: false };
|
|
49
|
-
mockRequest.mockResolvedValueOnce({ createBranch: { branch,
|
|
49
|
+
mockRequest.mockResolvedValueOnce({ createBranch: { branch, errors: [] } });
|
|
50
50
|
const client = await createTestClient(registerBranchTools);
|
|
51
51
|
const result = await client.callTool({
|
|
52
52
|
name: "create_branch",
|
|
@@ -56,9 +56,9 @@ describe("create_branch", () => {
|
|
|
56
56
|
const text = result.content[0].text;
|
|
57
57
|
expect(JSON.parse(text)).toEqual(branch);
|
|
58
58
|
});
|
|
59
|
-
it("returns an error when
|
|
59
|
+
it("returns an error when errors is non-empty", async () => {
|
|
60
60
|
mockRequest.mockResolvedValueOnce({
|
|
61
|
-
createBranch: { branch: null,
|
|
61
|
+
createBranch: { branch: null, errors: [{ message: "Branch already exists" }] },
|
|
62
62
|
});
|
|
63
63
|
const client = await createTestClient(registerBranchTools);
|
|
64
64
|
const result = await client.callTool({
|
|
@@ -31,7 +31,7 @@ const SET_BUILD_LINKS = `
|
|
|
31
31
|
fromBuild: $fromBuild
|
|
32
32
|
links: $links
|
|
33
33
|
}) {
|
|
34
|
-
|
|
34
|
+
errors { message }
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
`;
|
|
@@ -59,11 +59,11 @@ export function registerBuildLinkTools(server, allowMutations) {
|
|
|
59
59
|
links: z.array(BuildLinkInput).describe("Target builds to link to"),
|
|
60
60
|
}, async ({ fromProject, fromBuild, links }) => {
|
|
61
61
|
const data = await gqlClient.request(SET_BUILD_LINKS, { fromProject, fromBuild, links });
|
|
62
|
-
const {
|
|
63
|
-
if (
|
|
62
|
+
const { errors } = data.linksBuild;
|
|
63
|
+
if (errors?.length) {
|
|
64
64
|
return {
|
|
65
65
|
isError: true,
|
|
66
|
-
content: [{ type: "text", text:
|
|
66
|
+
content: [{ type: "text", text: errors.map((e) => e.message).join(", ") }],
|
|
67
67
|
};
|
|
68
68
|
}
|
|
69
69
|
return {
|
|
@@ -40,7 +40,7 @@ describe("get_build_links", () => {
|
|
|
40
40
|
});
|
|
41
41
|
describe("set_build_links", () => {
|
|
42
42
|
it("returns success message when links are set", async () => {
|
|
43
|
-
mockRequest.mockResolvedValueOnce({ linksBuild: {
|
|
43
|
+
mockRequest.mockResolvedValueOnce({ linksBuild: { errors: [] } });
|
|
44
44
|
const client = await createTestClient(registerBuildLinkTools);
|
|
45
45
|
const result = await client.callTool({
|
|
46
46
|
name: "set_build_links",
|
|
@@ -54,9 +54,9 @@ describe("set_build_links", () => {
|
|
|
54
54
|
const text = result.content[0].text;
|
|
55
55
|
expect(text).toContain("updated successfully");
|
|
56
56
|
});
|
|
57
|
-
it("returns an error when
|
|
57
|
+
it("returns an error when errors is non-empty", async () => {
|
|
58
58
|
mockRequest.mockResolvedValueOnce({
|
|
59
|
-
linksBuild: {
|
|
59
|
+
linksBuild: { errors: [{ message: "Build not found" }] },
|
|
60
60
|
});
|
|
61
61
|
const client = await createTestClient(registerBuildLinkTools);
|
|
62
62
|
const result = await client.callTool({
|
package/build/tools/builds.js
CHANGED
|
@@ -50,7 +50,7 @@ const CREATE_BUILD = `
|
|
|
50
50
|
description: $description
|
|
51
51
|
}) {
|
|
52
52
|
build { id name displayName description branch { id name displayName project { id name } } }
|
|
53
|
-
|
|
53
|
+
errors { message }
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
`;
|
|
@@ -85,11 +85,11 @@ export function registerBuildTools(server, allowMutations) {
|
|
|
85
85
|
description: z.string().optional().describe("Build description"),
|
|
86
86
|
}, async ({ project, branch, name, description }) => {
|
|
87
87
|
const data = await gqlClient.request(CREATE_BUILD, { project, branch, name, description });
|
|
88
|
-
const { build,
|
|
89
|
-
if (
|
|
88
|
+
const { build, errors } = data.createBuild;
|
|
89
|
+
if (errors?.length) {
|
|
90
90
|
return {
|
|
91
91
|
isError: true,
|
|
92
|
-
content: [{ type: "text", text:
|
|
92
|
+
content: [{ type: "text", text: errors.map((e) => e.message).join(", ") }],
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
95
|
return {
|
|
@@ -61,7 +61,7 @@ describe("find_build", () => {
|
|
|
61
61
|
describe("create_build", () => {
|
|
62
62
|
it("returns the created build", async () => {
|
|
63
63
|
const build = { id: 10, name: "2.0.0", displayName: "2.0.0" };
|
|
64
|
-
mockRequest.mockResolvedValueOnce({ createBuild: { build,
|
|
64
|
+
mockRequest.mockResolvedValueOnce({ createBuild: { build, errors: [] } });
|
|
65
65
|
const client = await createTestClient(registerBuildTools);
|
|
66
66
|
const result = await client.callTool({
|
|
67
67
|
name: "create_build",
|
|
@@ -71,9 +71,9 @@ describe("create_build", () => {
|
|
|
71
71
|
const text = result.content[0].text;
|
|
72
72
|
expect(JSON.parse(text)).toEqual(build);
|
|
73
73
|
});
|
|
74
|
-
it("returns an error when
|
|
74
|
+
it("returns an error when errors is non-empty", async () => {
|
|
75
75
|
mockRequest.mockResolvedValueOnce({
|
|
76
|
-
createBuild: { build: null,
|
|
76
|
+
createBuild: { build: null, errors: [{ message: "Branch not found" }] },
|
|
77
77
|
});
|
|
78
78
|
const client = await createTestClient(registerBuildTools);
|
|
79
79
|
const result = await client.callTool({
|
package/build/tools/projects.js
CHANGED
|
@@ -14,7 +14,7 @@ const CREATE_PROJECT = `
|
|
|
14
14
|
mutation CreateProject($name: String!, $description: String) {
|
|
15
15
|
createProject(input: { name: $name, description: $description }) {
|
|
16
16
|
project { id name description disabled }
|
|
17
|
-
|
|
17
|
+
errors { message }
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
`;
|
|
@@ -33,11 +33,11 @@ export function registerProjectTools(server, allowMutations) {
|
|
|
33
33
|
description: z.string().optional().describe("Project description"),
|
|
34
34
|
}, async ({ name, description }) => {
|
|
35
35
|
const data = await gqlClient.request(CREATE_PROJECT, { name, description });
|
|
36
|
-
const { project,
|
|
37
|
-
if (
|
|
36
|
+
const { project, errors } = data.createProject;
|
|
37
|
+
if (errors?.length) {
|
|
38
38
|
return {
|
|
39
39
|
isError: true,
|
|
40
|
-
content: [{ type: "text", text:
|
|
40
|
+
content: [{ type: "text", text: errors.map((e) => e.message).join(", ") }],
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
43
|
return {
|
|
@@ -40,7 +40,7 @@ describe("list_projects", () => {
|
|
|
40
40
|
describe("create_project", () => {
|
|
41
41
|
it("returns the created project", async () => {
|
|
42
42
|
const project = { id: 1, name: "newproject", description: "New", disabled: false };
|
|
43
|
-
mockRequest.mockResolvedValueOnce({ createProject: { project,
|
|
43
|
+
mockRequest.mockResolvedValueOnce({ createProject: { project, errors: [] } });
|
|
44
44
|
const client = await createTestClient(registerProjectTools);
|
|
45
45
|
const result = await client.callTool({
|
|
46
46
|
name: "create_project",
|
|
@@ -50,9 +50,9 @@ describe("create_project", () => {
|
|
|
50
50
|
const text = result.content[0].text;
|
|
51
51
|
expect(JSON.parse(text)).toEqual(project);
|
|
52
52
|
});
|
|
53
|
-
it("returns an error when
|
|
53
|
+
it("returns an error when errors is non-empty", async () => {
|
|
54
54
|
mockRequest.mockResolvedValueOnce({
|
|
55
|
-
createProject: { project: null,
|
|
55
|
+
createProject: { project: null, errors: [{ message: "Project already exists" }] },
|
|
56
56
|
});
|
|
57
57
|
const client = await createTestClient(registerProjectTools);
|
|
58
58
|
const result = await client.callTool({
|
|
@@ -26,7 +26,7 @@ const CREATE_PROMOTION_LEVEL = `
|
|
|
26
26
|
description: $description
|
|
27
27
|
}) {
|
|
28
28
|
promotionLevel { id name description branch { id name } }
|
|
29
|
-
|
|
29
|
+
errors { message }
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
`;
|
|
@@ -69,11 +69,11 @@ export function registerPromotionLevelTools(server, allowMutations) {
|
|
|
69
69
|
}, async ({ project, branch, name, description }) => {
|
|
70
70
|
const branchId = await resolveBranchId(project, branch);
|
|
71
71
|
const data = await gqlClient.request(CREATE_PROMOTION_LEVEL, { branchId, name, description });
|
|
72
|
-
const { promotionLevel,
|
|
73
|
-
if (
|
|
72
|
+
const { promotionLevel, errors } = data.createPromotionLevelById;
|
|
73
|
+
if (errors?.length) {
|
|
74
74
|
return {
|
|
75
75
|
isError: true,
|
|
76
|
-
content: [{ type: "text", text:
|
|
76
|
+
content: [{ type: "text", text: errors.map((e) => e.message).join(", ") }],
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
return {
|
|
@@ -83,7 +83,7 @@ describe("get_promotion_level_image", () => {
|
|
|
83
83
|
describe("create_promotion_level", () => {
|
|
84
84
|
it("returns the created promotion level", async () => {
|
|
85
85
|
const level = { id: 3, name: "GOLD", description: "Gold level", branch: { id: 42, name: "main" } };
|
|
86
|
-
mockRequest.mockResolvedValueOnce({ createPromotionLevelById: { promotionLevel: level,
|
|
86
|
+
mockRequest.mockResolvedValueOnce({ createPromotionLevelById: { promotionLevel: level, errors: [] } });
|
|
87
87
|
const client = await createTestClient(registerPromotionLevelTools);
|
|
88
88
|
const result = await client.callTool({
|
|
89
89
|
name: "create_promotion_level",
|
|
@@ -93,9 +93,9 @@ describe("create_promotion_level", () => {
|
|
|
93
93
|
const text = result.content[0].text;
|
|
94
94
|
expect(JSON.parse(text)).toEqual(level);
|
|
95
95
|
});
|
|
96
|
-
it("returns an error when
|
|
96
|
+
it("returns an error when errors is non-empty", async () => {
|
|
97
97
|
mockRequest.mockResolvedValueOnce({
|
|
98
|
-
createPromotionLevelById: { promotionLevel: null,
|
|
98
|
+
createPromotionLevelById: { promotionLevel: null, errors: [{ message: "Promotion level already exists" }] },
|
|
99
99
|
});
|
|
100
100
|
const client = await createTestClient(registerPromotionLevelTools);
|
|
101
101
|
const result = await client.callTool({
|
|
@@ -38,7 +38,7 @@ const PROMOTE_BUILD = `
|
|
|
38
38
|
creation { user time }
|
|
39
39
|
promotionLevel { id name }
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
errors { message }
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
`;
|
|
@@ -64,11 +64,11 @@ export function registerPromotionRunTools(server, allowMutations) {
|
|
|
64
64
|
description: z.string().optional().describe("Optional description"),
|
|
65
65
|
}, async ({ project, branch, build, promotion, description }) => {
|
|
66
66
|
const data = await gqlClient.request(PROMOTE_BUILD, { project, branch, build, promotion, description });
|
|
67
|
-
const { promotionRun,
|
|
68
|
-
if (
|
|
67
|
+
const { promotionRun, errors } = data.createPromotionRun;
|
|
68
|
+
if (errors?.length) {
|
|
69
69
|
return {
|
|
70
70
|
isError: true,
|
|
71
|
-
content: [{ type: "text", text:
|
|
71
|
+
content: [{ type: "text", text: errors.map((e) => e.message).join(", ") }],
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
74
|
return {
|
|
@@ -46,7 +46,7 @@ describe("get_promotion_runs", () => {
|
|
|
46
46
|
describe("promote_build", () => {
|
|
47
47
|
it("returns the created promotion run", async () => {
|
|
48
48
|
const run = { id: 5, description: null, creation: { user: "alice", time: "2024-01-01T00:00:00Z" }, promotionLevel: { id: 1, name: "BRONZE" } };
|
|
49
|
-
mockRequest.mockResolvedValueOnce({ createPromotionRun: { promotionRun: run,
|
|
49
|
+
mockRequest.mockResolvedValueOnce({ createPromotionRun: { promotionRun: run, errors: [] } });
|
|
50
50
|
const client = await createTestClient(registerPromotionRunTools);
|
|
51
51
|
const result = await client.callTool({
|
|
52
52
|
name: "promote_build",
|
|
@@ -56,9 +56,9 @@ describe("promote_build", () => {
|
|
|
56
56
|
const text = result.content[0].text;
|
|
57
57
|
expect(JSON.parse(text)).toEqual(run);
|
|
58
58
|
});
|
|
59
|
-
it("returns an error when
|
|
59
|
+
it("returns an error when errors is non-empty", async () => {
|
|
60
60
|
mockRequest.mockResolvedValueOnce({
|
|
61
|
-
createPromotionRun: { promotionRun: null,
|
|
61
|
+
createPromotionRun: { promotionRun: null, errors: [{ message: "Build not found" }] },
|
|
62
62
|
});
|
|
63
63
|
const client = await createTestClient(registerPromotionRunTools);
|
|
64
64
|
const result = await client.callTool({
|
|
@@ -69,6 +69,20 @@ describe("promote_build", () => {
|
|
|
69
69
|
const text = result.content[0].text;
|
|
70
70
|
expect(text).toContain("Build not found");
|
|
71
71
|
});
|
|
72
|
+
it("returns the agent policy refusal as an error", async () => {
|
|
73
|
+
const refusal = "agent x[agent] may not promote to GOLD: GOLD is reserved to humans (agent policy)";
|
|
74
|
+
mockRequest.mockResolvedValueOnce({
|
|
75
|
+
createPromotionRun: { promotionRun: null, errors: [{ message: refusal }] },
|
|
76
|
+
});
|
|
77
|
+
const client = await createTestClient(registerPromotionRunTools);
|
|
78
|
+
const result = await client.callTool({
|
|
79
|
+
name: "promote_build",
|
|
80
|
+
arguments: { project: "myproject", branch: "main", build: "1.0.1", promotion: "GOLD" },
|
|
81
|
+
});
|
|
82
|
+
expect(result.isError).toBe(true);
|
|
83
|
+
const text = result.content[0].text;
|
|
84
|
+
expect(text).toBe(refusal);
|
|
85
|
+
});
|
|
72
86
|
it("is not registered when mutations are disabled", async () => {
|
|
73
87
|
const client = await createTestClient(registerPromotionRunTools, false);
|
|
74
88
|
const { tools } = await client.listTools();
|
package/build/tools/search.js
CHANGED
|
@@ -1,67 +1,71 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { gqlClient } from "../client.js";
|
|
3
|
+
// `type` is required on v5 (and deprecated, but still accepted, on v6)
|
|
3
4
|
const SEARCH = `
|
|
4
|
-
query Search($token: String!, $type: String
|
|
5
|
+
query Search($token: String!, $type: String!, $size: Int) {
|
|
5
6
|
search(token: $token, type: $type, size: $size) {
|
|
6
7
|
pageItems {
|
|
7
8
|
type { id name description }
|
|
8
9
|
title
|
|
9
10
|
description
|
|
10
11
|
accuracy
|
|
12
|
+
data
|
|
11
13
|
}
|
|
12
14
|
pageInfo { totalSize }
|
|
13
15
|
}
|
|
14
16
|
}
|
|
15
17
|
`;
|
|
16
|
-
const
|
|
17
|
-
query
|
|
18
|
-
|
|
19
|
-
pageItems {
|
|
20
|
-
type { id name description }
|
|
21
|
-
title
|
|
22
|
-
description
|
|
23
|
-
accuracy
|
|
24
|
-
data
|
|
25
|
-
}
|
|
26
|
-
pageInfo { totalSize }
|
|
27
|
-
}
|
|
18
|
+
const SEARCH_RESULT_TYPES = `
|
|
19
|
+
query SearchResultTypes {
|
|
20
|
+
searchResultTypes { id }
|
|
28
21
|
}
|
|
29
22
|
`;
|
|
23
|
+
async function searchType(token, type, size) {
|
|
24
|
+
const data = await gqlClient.request(SEARCH, { token, type, size });
|
|
25
|
+
return data.search ?? { pageItems: [], pageInfo: { totalSize: 0 } };
|
|
26
|
+
}
|
|
27
|
+
/** Searches each type of result, keeping the best `size` results overall. */
|
|
28
|
+
async function searchAllTypes(token, size) {
|
|
29
|
+
const { searchResultTypes } = await gqlClient.request(SEARCH_RESULT_TYPES);
|
|
30
|
+
const pages = await Promise.all(searchResultTypes.flatMap(({ id }) => (id ? [searchType(token, id, size)] : [])));
|
|
31
|
+
return {
|
|
32
|
+
pageItems: pages
|
|
33
|
+
.flatMap((page) => page.pageItems)
|
|
34
|
+
.sort((a, b) => (b.accuracy ?? 0) - (a.accuracy ?? 0))
|
|
35
|
+
.slice(0, size),
|
|
36
|
+
pageInfo: { totalSize: pages.reduce((total, page) => total + (page.pageInfo?.totalSize ?? 0), 0) },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
30
39
|
export function registerSearchTools(server) {
|
|
31
40
|
server.tool("search", "Full-text search across all Yontrack entities (projects, branches, builds, etc.)", {
|
|
32
41
|
token: z.string().describe("Search query"),
|
|
33
|
-
type: z
|
|
42
|
+
type: z
|
|
43
|
+
.string()
|
|
44
|
+
.optional()
|
|
45
|
+
.describe("Type of result to search for (e.g. 'project', 'build'); all types when omitted"),
|
|
34
46
|
size: z.number().int().optional().default(10).describe("Max number of results"),
|
|
35
47
|
}, async ({ token, type, size }) => {
|
|
36
|
-
const
|
|
48
|
+
const search = type ? await searchType(token, type, size) : await searchAllTypes(token, size);
|
|
37
49
|
return {
|
|
38
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
50
|
+
content: [{ type: "text", text: JSON.stringify(search, null, 2) }],
|
|
39
51
|
};
|
|
40
52
|
});
|
|
41
53
|
server.tool("search_commits", "Search for SCM commits by keyword or commit message", {
|
|
42
54
|
token: z.string().describe("Search query (keyword or commit message fragment)"),
|
|
43
55
|
size: z.number().int().optional().default(10).describe("Max number of results"),
|
|
44
56
|
}, async ({ token, size }) => {
|
|
45
|
-
const
|
|
46
|
-
token,
|
|
47
|
-
type: "scm-commit",
|
|
48
|
-
size,
|
|
49
|
-
});
|
|
57
|
+
const search = await searchType(token, "scm-commit", size);
|
|
50
58
|
return {
|
|
51
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
59
|
+
content: [{ type: "text", text: JSON.stringify(search, null, 2) }],
|
|
52
60
|
};
|
|
53
61
|
});
|
|
54
62
|
server.tool("search_issues", "Search for SCM issues by keyword or issue key", {
|
|
55
63
|
token: z.string().describe("Search query (keyword or issue key)"),
|
|
56
64
|
size: z.number().int().optional().default(10).describe("Max number of results"),
|
|
57
65
|
}, async ({ token, size }) => {
|
|
58
|
-
const
|
|
59
|
-
token,
|
|
60
|
-
type: "scm-issue",
|
|
61
|
-
size,
|
|
62
|
-
});
|
|
66
|
+
const search = await searchType(token, "scm-issue", size);
|
|
63
67
|
return {
|
|
64
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
68
|
+
content: [{ type: "text", text: JSON.stringify(search, null, 2) }],
|
|
65
69
|
};
|
|
66
70
|
});
|
|
67
71
|
}
|
|
@@ -9,41 +9,47 @@ const mockRequest = gqlClient.request;
|
|
|
9
9
|
beforeEach(() => {
|
|
10
10
|
mockRequest.mockReset();
|
|
11
11
|
});
|
|
12
|
+
const result = (id, title, accuracy) => ({
|
|
13
|
+
type: { id, name: id, description: null },
|
|
14
|
+
title,
|
|
15
|
+
description: null,
|
|
16
|
+
accuracy,
|
|
17
|
+
data: null,
|
|
18
|
+
});
|
|
19
|
+
async function search(args) {
|
|
20
|
+
const client = await createTestClient(registerSearchTools);
|
|
21
|
+
const result = await client.callTool({ name: "search", arguments: args });
|
|
22
|
+
return JSON.parse(result.content[0].text);
|
|
23
|
+
}
|
|
12
24
|
describe("search", () => {
|
|
13
|
-
it("
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
};
|
|
20
|
-
mockRequest.mockResolvedValueOnce({ search });
|
|
21
|
-
const client = await createTestClient(registerSearchTools);
|
|
22
|
-
const result = await client.callTool({
|
|
23
|
-
name: "search",
|
|
24
|
-
arguments: { token: "myproject" },
|
|
25
|
-
});
|
|
26
|
-
const text = result.content[0].text;
|
|
27
|
-
expect(JSON.parse(text)).toEqual(search);
|
|
25
|
+
it("searches the given type only", async () => {
|
|
26
|
+
const page = { pageItems: [result("build", "1.0.0", 1.0)], pageInfo: { totalSize: 1 } };
|
|
27
|
+
mockRequest.mockResolvedValueOnce({ search: page });
|
|
28
|
+
expect(await search({ token: "foo", type: "build", size: 5 })).toEqual(page);
|
|
29
|
+
expect(mockRequest).toHaveBeenCalledTimes(1);
|
|
30
|
+
expect(mockRequest).toHaveBeenCalledWith(expect.any(String), { token: "foo", type: "build", size: 5 });
|
|
28
31
|
});
|
|
29
|
-
it("
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
it("searches every type when none is given, best results first", async () => {
|
|
33
|
+
mockRequest
|
|
34
|
+
.mockResolvedValueOnce({ searchResultTypes: [{ id: "project" }, { id: "build" }] })
|
|
35
|
+
.mockResolvedValueOnce({
|
|
36
|
+
search: { pageItems: [result("project", "p1", 0.5), result("project", "p2", 0.1)], pageInfo: { totalSize: 2 } },
|
|
37
|
+
})
|
|
38
|
+
.mockResolvedValueOnce({
|
|
39
|
+
search: { pageItems: [result("build", "b1", 0.9)], pageInfo: { totalSize: 7 } },
|
|
36
40
|
});
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
it("passes type and size filters to the GraphQL client", async () => {
|
|
41
|
-
mockRequest.mockResolvedValueOnce({ search: { pageItems: [], pageInfo: { totalSize: 0 } } });
|
|
42
|
-
const client = await createTestClient(registerSearchTools);
|
|
43
|
-
await client.callTool({
|
|
44
|
-
name: "search",
|
|
45
|
-
arguments: { token: "foo", type: "build", size: 5 },
|
|
41
|
+
expect(await search({ token: "foo", size: 2 })).toEqual({
|
|
42
|
+
pageItems: [result("build", "b1", 0.9), result("project", "p1", 0.5)],
|
|
43
|
+
pageInfo: { totalSize: 9 },
|
|
46
44
|
});
|
|
47
|
-
expect(mockRequest).toHaveBeenCalledWith(expect.
|
|
45
|
+
expect(mockRequest).toHaveBeenCalledWith(expect.stringContaining("searchResultTypes"));
|
|
46
|
+
expect(mockRequest).toHaveBeenCalledWith(expect.any(String), { token: "foo", type: "project", size: 2 });
|
|
47
|
+
expect(mockRequest).toHaveBeenCalledWith(expect.any(String), { token: "foo", type: "build", size: 2 });
|
|
48
|
+
});
|
|
49
|
+
it("returns empty results when nothing matches", async () => {
|
|
50
|
+
mockRequest
|
|
51
|
+
.mockResolvedValueOnce({ searchResultTypes: [{ id: "project" }] })
|
|
52
|
+
.mockResolvedValueOnce({ search: { pageItems: [], pageInfo: { totalSize: 0 } } });
|
|
53
|
+
expect(await search({ token: "zzznomatch" })).toEqual({ pageItems: [], pageInfo: { totalSize: 0 } });
|
|
48
54
|
});
|
|
49
55
|
});
|
|
@@ -42,7 +42,7 @@ const CREATE_VALIDATION_RUN = `
|
|
|
42
42
|
runOrder
|
|
43
43
|
lastStatus { statusID { id name } }
|
|
44
44
|
}
|
|
45
|
-
|
|
45
|
+
errors { message }
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
`;
|
|
@@ -79,11 +79,11 @@ export function registerValidationRunTools(server, allowMutations) {
|
|
|
79
79
|
status,
|
|
80
80
|
description,
|
|
81
81
|
});
|
|
82
|
-
const { validationRun,
|
|
83
|
-
if (
|
|
82
|
+
const { validationRun, errors } = data.createValidationRun;
|
|
83
|
+
if (errors?.length) {
|
|
84
84
|
return {
|
|
85
85
|
isError: true,
|
|
86
|
-
content: [{ type: "text", text:
|
|
86
|
+
content: [{ type: "text", text: errors.map((e) => e.message).join(", ") }],
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
89
|
return {
|
|
@@ -46,7 +46,7 @@ describe("get_validation_runs", () => {
|
|
|
46
46
|
describe("create_validation_run", () => {
|
|
47
47
|
it("returns the created validation run", async () => {
|
|
48
48
|
const run = { id: 10, runOrder: 1, lastStatus: { statusID: { id: "PASSED", name: "Passed" } } };
|
|
49
|
-
mockRequest.mockResolvedValueOnce({ createValidationRun: { validationRun: run,
|
|
49
|
+
mockRequest.mockResolvedValueOnce({ createValidationRun: { validationRun: run, errors: [] } });
|
|
50
50
|
const client = await createTestClient(registerValidationRunTools);
|
|
51
51
|
const result = await client.callTool({
|
|
52
52
|
name: "create_validation_run",
|
|
@@ -56,9 +56,9 @@ describe("create_validation_run", () => {
|
|
|
56
56
|
const text = result.content[0].text;
|
|
57
57
|
expect(JSON.parse(text)).toEqual(run);
|
|
58
58
|
});
|
|
59
|
-
it("returns an error when
|
|
59
|
+
it("returns an error when errors is non-empty", async () => {
|
|
60
60
|
mockRequest.mockResolvedValueOnce({
|
|
61
|
-
createValidationRun: { validationRun: null,
|
|
61
|
+
createValidationRun: { validationRun: null, errors: [{ message: "Validation stamp not found" }] },
|
|
62
62
|
});
|
|
63
63
|
const client = await createTestClient(registerValidationRunTools);
|
|
64
64
|
const result = await client.callTool({
|
|
@@ -24,7 +24,7 @@ const CREATE_VALIDATION_STAMP = `
|
|
|
24
24
|
description: $description
|
|
25
25
|
}) {
|
|
26
26
|
validationStamp { id name description }
|
|
27
|
-
|
|
27
|
+
errors { message }
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
`;
|
|
@@ -49,11 +49,11 @@ export function registerValidationStampTools(server, allowMutations) {
|
|
|
49
49
|
}, async ({ project, branch, name, description }) => {
|
|
50
50
|
const branchId = await resolveBranchId(project, branch);
|
|
51
51
|
const data = await gqlClient.request(CREATE_VALIDATION_STAMP, { branchId, name, description });
|
|
52
|
-
const { validationStamp,
|
|
53
|
-
if (
|
|
52
|
+
const { validationStamp, errors } = data.createValidationStampById;
|
|
53
|
+
if (errors?.length) {
|
|
54
54
|
return {
|
|
55
55
|
isError: true,
|
|
56
|
-
content: [{ type: "text", text:
|
|
56
|
+
content: [{ type: "text", text: errors.map((e) => e.message).join(", ") }],
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
return {
|
|
@@ -40,7 +40,7 @@ describe("list_validation_stamps", () => {
|
|
|
40
40
|
describe("create_validation_stamp", () => {
|
|
41
41
|
it("returns the created validation stamp", async () => {
|
|
42
42
|
const stamp = { id: 5, name: "SECURITY", description: "Security scan" };
|
|
43
|
-
mockRequest.mockResolvedValueOnce({ createValidationStampById: { validationStamp: stamp,
|
|
43
|
+
mockRequest.mockResolvedValueOnce({ createValidationStampById: { validationStamp: stamp, errors: [] } });
|
|
44
44
|
const client = await createTestClient(registerValidationStampTools);
|
|
45
45
|
const result = await client.callTool({
|
|
46
46
|
name: "create_validation_stamp",
|
|
@@ -50,9 +50,9 @@ describe("create_validation_stamp", () => {
|
|
|
50
50
|
const text = result.content[0].text;
|
|
51
51
|
expect(JSON.parse(text)).toEqual(stamp);
|
|
52
52
|
});
|
|
53
|
-
it("returns an error when
|
|
53
|
+
it("returns an error when errors is non-empty", async () => {
|
|
54
54
|
mockRequest.mockResolvedValueOnce({
|
|
55
|
-
createValidationStampById: { validationStamp: null,
|
|
55
|
+
createValidationStampById: { validationStamp: null, errors: [{ message: "Stamp already exists" }] },
|
|
56
56
|
});
|
|
57
57
|
const client = await createTestClient(registerValidationStampTools);
|
|
58
58
|
const result = await client.callTool({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yontrack-mcp",
|
|
3
|
-
"version": "1.14.
|
|
3
|
+
"version": "1.14.3",
|
|
4
4
|
"description": "MCP server for Yontrack (Ontrack CI/CD monitoring platform)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"@semantic-release/git": "^10.0.1",
|
|
35
35
|
"@types/express": "^5.0.6",
|
|
36
36
|
"@types/node": "^22.0.0",
|
|
37
|
+
"graphql": "^16.14.2",
|
|
37
38
|
"semantic-release": "^24.2.9",
|
|
38
39
|
"tsx": "^4.19.0",
|
|
39
40
|
"typescript": "^5.7.0",
|