yontrack-mcp 1.14.2 → 1.15.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/README.md CHANGED
@@ -46,10 +46,14 @@ Read-only tools answering the questions an agent asks before promoting or deploy
46
46
  | `dependency_builds_at_level` | Which builds of a dependency project are at a given promotion level? |
47
47
  | `agent_policy` | What may the agent behind the token do on a project? (`{ "agent": false }` for a human token) |
48
48
 
49
- These tools are **only available with Yontrack 6**. The server detects them by probing the Yontrack schema (for the `Readiness` and `AgentPolicy` types), not by reading its version: on Yontrack 5 they are not listed at all. The probe runs once per process; when Yontrack cannot be reached, the tools are hidden and the next request probes again (in `stdio` mode the server is built once, so restart it). Set `YONTRACK_AGENT_TOOLS` to `true` or `false` to skip the probe.
49
+ These tools are **only available with Yontrack 6**. The server detects them by probing the Yontrack schema (for the `Readiness` and `AgentPolicy` types), not by reading its version: on Yontrack 5 they are not listed at all. The probe runs once per process; when Yontrack cannot be reached, the tools are hidden and the next request probes again (in `stdio` mode the server is built once, so restart it). Set `YONTRACK_AGENT_TOOLS` to `true` or `false` to force them on or off (the probe still runs, for the [search](#search)).
50
50
 
51
51
  On Yontrack 6, the `yontrack://schema` resource also serves the Yontrack 6 schema instead of the Yontrack 5 one.
52
52
 
53
+ ### Search
54
+
55
+ `search`, `search_commits` and `search_issues` return `{ pageItems, pageInfo: { totalSize } }` on both Yontrack 5 and 6. On Yontrack 5, a search without `type` runs one search per result type and keeps the best results. On Yontrack 6 (detected by probing the schema for the `SearchResults` type, whatever `YONTRACK_AGENT_TOOLS` says), they send a single request and also return `capped` (`true` when `totalSize` is a lower bound) and `message` (e.g. when the search index is being built). Yontrack 6 requires at least 2 characters to search for.
56
+
53
57
  ### Agent sessions
54
58
 
55
59
  When an agent uses this server with the token of an **agent account** (Yontrack 6), Yontrack can attribute what it records (builds, validations, promotions…) to the agent's session. The server sends the `X-Yontrack-Agent-Session` and `X-Yontrack-Agent-Session-Link` headers on every request to Yontrack, taken from:
@@ -324,7 +328,7 @@ An API token is required to authenticate against Yontrack. To generate one, log
324
328
  | `YONTRACK_URL` | Yes | — | URL of the Yontrack instance (e.g. `https://yontrack.example.com`) |
325
329
  | `YONTRACK_TOKEN` | Yes | — | API token for authenticating against Yontrack |
326
330
  | `YONTRACK_MUTATIONS_ENABLED` | No | `false` | Set to `true` to enable mutation tools (create/promote/link operations). When unset or `false`, only read-only query tools are registered. |
327
- | `YONTRACK_AGENT_TOOLS` | No | `auto` | Agent-context tools: `auto` registers them when Yontrack 6 is detected, `true` always, `false` never. See [Agent-context tools](#agent-context-tools-yontrack-6). |
331
+ | `YONTRACK_AGENT_TOOLS` | No | `auto` | Agent-context tools: `auto` registers them when Yontrack 6 is detected, `true` always, `false` never. Does not affect the [search](#search). See [Agent-context tools](#agent-context-tools-yontrack-6). |
328
332
  | `YONTRACK_UI_URL` | No | — | URL of the Yontrack UI (e.g. `https://yontrack.example.com`), used for the links returned by the agent-context tools. Links are left out when unset. |
329
333
  | `YONTRACK_AGENT_SESSION` | No | — | Agent session ID sent to Yontrack when the incoming MCP request carries no session headers. See [Agent sessions](#agent-sessions). |
330
334
  | `YONTRACK_AGENT_SESSION_LINK`| No | — | Link to the agent session (absolute `https` URL), sent together with `YONTRACK_AGENT_SESSION`. |
@@ -4,21 +4,24 @@ const PROBE = `
4
4
  query ProbeCapabilities {
5
5
  readiness: __type(name: "Readiness") { name }
6
6
  agentPolicy: __type(name: "AgentPolicy") { name }
7
+ searchResults: __type(name: "SearchResults") { name }
7
8
  }
8
9
  `;
9
- const NONE = { agentTools: false, agentPolicy: false };
10
- const ALL = { agentTools: true, agentPolicy: true };
10
+ const OVERRIDES = {
11
+ auto: undefined,
12
+ true: { agentTools: true, agentPolicy: true },
13
+ false: { agentTools: false, agentPolicy: false },
14
+ };
11
15
  /**
12
16
  * A definite answer is cached for the life of the probe. A probe error is not
13
- * cached: the tools are hidden for that call and the next call probes again.
17
+ * cached: the tools are hidden, the search falls back to the Yontrack 5 API
18
+ * (still accepted by Yontrack 6) for that call, and the next call probes again.
19
+ * `YONTRACK_AGENT_TOOLS` overrides the agent-context tools only.
14
20
  */
15
21
  export function createCapabilityProbe(mode) {
22
+ const override = OVERRIDES[mode];
16
23
  let cached;
17
24
  async function get() {
18
- if (mode === "true")
19
- return ALL;
20
- if (mode === "false")
21
- return NONE;
22
25
  if (cached)
23
26
  return cached;
24
27
  try {
@@ -26,12 +29,14 @@ export function createCapabilityProbe(mode) {
26
29
  cached = {
27
30
  agentTools: data.readiness != null,
28
31
  agentPolicy: data.agentPolicy != null,
32
+ ...override,
33
+ searchResults: data.searchResults != null,
29
34
  };
30
35
  return cached;
31
36
  }
32
37
  catch (err) {
33
- process.stderr.write(`Could not probe Yontrack capabilities, hiding agent-context tools for now: ${err instanceof Error ? err.message : String(err)}\n`);
34
- return NONE;
38
+ process.stderr.write(`Could not probe Yontrack capabilities${override ? "" : ", hiding agent-context tools for now"}: ${err instanceof Error ? err.message : String(err)}\n`);
39
+ return { agentTools: false, agentPolicy: false, ...override, searchResults: false };
35
40
  }
36
41
  }
37
42
  return { get };
@@ -13,46 +13,64 @@ describe("capability probe (auto)", () => {
13
13
  mockRequest.mockResolvedValueOnce({
14
14
  readiness: { name: "Readiness" },
15
15
  agentPolicy: { name: "AgentPolicy" },
16
+ searchResults: { name: "SearchResults" },
16
17
  });
17
18
  const probe = createCapabilityProbe("auto");
18
- expect(await probe.get()).toEqual({ agentTools: true, agentPolicy: true });
19
+ expect(await probe.get()).toEqual({ agentTools: true, agentPolicy: true, searchResults: true });
19
20
  });
20
21
  it("detects Yontrack 5 when neither type exists", async () => {
21
- mockRequest.mockResolvedValueOnce({ readiness: null, agentPolicy: null });
22
+ mockRequest.mockResolvedValueOnce({ readiness: null, agentPolicy: null, searchResults: null });
22
23
  const probe = createCapabilityProbe("auto");
23
- expect(await probe.get()).toEqual({ agentTools: false, agentPolicy: false });
24
+ expect(await probe.get()).toEqual({ agentTools: false, agentPolicy: false, searchResults: false });
24
25
  });
25
26
  it("enables agent tools without agent_policy when only Readiness exists", async () => {
26
- mockRequest.mockResolvedValueOnce({ readiness: { name: "Readiness" }, agentPolicy: null });
27
+ mockRequest.mockResolvedValueOnce({
28
+ readiness: { name: "Readiness" },
29
+ agentPolicy: null,
30
+ searchResults: { name: "SearchResults" },
31
+ });
27
32
  const probe = createCapabilityProbe("auto");
28
- expect(await probe.get()).toEqual({ agentTools: true, agentPolicy: false });
33
+ expect(await probe.get()).toEqual({ agentTools: true, agentPolicy: false, searchResults: true });
29
34
  });
30
35
  it("caches a definite answer for the life of the probe", async () => {
31
- mockRequest.mockResolvedValueOnce({ readiness: null, agentPolicy: null });
36
+ mockRequest.mockResolvedValueOnce({ readiness: null, agentPolicy: null, searchResults: null });
32
37
  const probe = createCapabilityProbe("auto");
33
38
  await probe.get();
34
39
  await probe.get();
35
40
  expect(mockRequest).toHaveBeenCalledTimes(1);
36
41
  });
37
- it("hides the tools on a probe error and probes again next time", async () => {
42
+ it("hides the tools and falls back to the v5 search on a probe error, and probes again next time", async () => {
38
43
  mockRequest
39
44
  .mockRejectedValueOnce(new Error("connection refused"))
40
- .mockResolvedValueOnce({ readiness: { name: "Readiness" }, agentPolicy: { name: "AgentPolicy" } });
45
+ .mockResolvedValueOnce({
46
+ readiness: { name: "Readiness" },
47
+ agentPolicy: { name: "AgentPolicy" },
48
+ searchResults: { name: "SearchResults" },
49
+ });
41
50
  const probe = createCapabilityProbe("auto");
42
- expect(await probe.get()).toEqual({ agentTools: false, agentPolicy: false });
43
- expect(await probe.get()).toEqual({ agentTools: true, agentPolicy: true });
51
+ expect(await probe.get()).toEqual({ agentTools: false, agentPolicy: false, searchResults: false });
52
+ expect(await probe.get()).toEqual({ agentTools: true, agentPolicy: true, searchResults: true });
44
53
  expect(mockRequest).toHaveBeenCalledTimes(2);
45
54
  });
46
55
  });
47
56
  describe("capability probe (override)", () => {
48
- it("forces the tools on without probing when set to true", async () => {
57
+ it("forces the tools on when set to true, still probing the search API", async () => {
58
+ mockRequest.mockResolvedValueOnce({ readiness: null, agentPolicy: null, searchResults: null });
49
59
  const probe = createCapabilityProbe("true");
50
- expect(await probe.get()).toEqual({ agentTools: true, agentPolicy: true });
51
- expect(mockRequest).not.toHaveBeenCalled();
60
+ expect(await probe.get()).toEqual({ agentTools: true, agentPolicy: true, searchResults: false });
52
61
  });
53
- it("forces the tools off without probing when set to false", async () => {
62
+ it("forces the tools off when set to false, still probing the search API", async () => {
63
+ mockRequest.mockResolvedValueOnce({
64
+ readiness: { name: "Readiness" },
65
+ agentPolicy: { name: "AgentPolicy" },
66
+ searchResults: { name: "SearchResults" },
67
+ });
54
68
  const probe = createCapabilityProbe("false");
55
- expect(await probe.get()).toEqual({ agentTools: false, agentPolicy: false });
56
- expect(mockRequest).not.toHaveBeenCalled();
69
+ expect(await probe.get()).toEqual({ agentTools: false, agentPolicy: false, searchResults: true });
70
+ });
71
+ it("keeps the override on a probe error, with the v5 search", async () => {
72
+ mockRequest.mockRejectedValueOnce(new Error("connection refused"));
73
+ const probe = createCapabilityProbe("true");
74
+ expect(await probe.get()).toEqual({ agentTools: true, agentPolicy: true, searchResults: false });
57
75
  });
58
76
  });
@@ -9,13 +9,14 @@ const schemas = {
9
9
  v5: buildSchema(readFileSync(root("yontrack-v5.graphql"), "utf-8")),
10
10
  v6: buildSchema(readFileSync(root("yontrack-v6.graphql"), "utf-8")),
11
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
- // search(type:) is required on v5, but the `search` tool sends it only when given
17
- "v5 src/tools/search.ts SEARCH",
12
+ // Yontrack 6 only: never sent to a v5 server (see docs/adr/0001 and 0002)
13
+ const V6_ONLY = new Set([
14
+ "src/tools/agent-context.ts",
15
+ "src/tools/agent-context-support.ts",
16
+ "src/tools/search-results.ts",
18
17
  ]);
18
+ // Known invalid documents, as "version file NAME": these must be fixed, then removed from here
19
+ const KNOWN_INVALID = new Set([]);
19
20
  const sourceFiles = [
20
21
  "src/capabilities.ts",
21
22
  "src/utils.ts",
@@ -43,6 +44,7 @@ describe("GraphQL documents", () => {
43
44
  expect(names).toContain("CREATE_VALIDATION_RUN");
44
45
  expect(names).toContain("PROMOTE_BUILD");
45
46
  expect(names).toContain("READINESS");
47
+ expect(names).toContain("SEARCH_RESULTS");
46
48
  });
47
49
  it.each(cases.filter((c) => !c.known))("$file $name is valid against the $version schema", ({ text, version }) => {
48
50
  const errors = validate(schemas[version], parse(text)).map((e) => e.message);
@@ -12,14 +12,14 @@ async function connect(capabilities) {
12
12
  }
13
13
  describe("createServer", () => {
14
14
  it("exposes the agent-context tools and mentions them on Yontrack 6", async () => {
15
- const client = await connect({ agentTools: true, agentPolicy: true });
15
+ const client = await connect({ agentTools: true, agentPolicy: true, searchResults: true });
16
16
  const names = (await client.listTools()).tools.map((t) => t.name);
17
17
  expect(names).toEqual(expect.arrayContaining(["list_projects", "build_readiness", "agent_policy"]));
18
18
  expect(client.getInstructions()).toContain("build_readiness");
19
19
  expect(client.getInstructions()).toContain("agent_policy");
20
20
  });
21
21
  it("hides the agent-context tools and does not mention them on Yontrack 5", async () => {
22
- const client = await connect({ agentTools: false, agentPolicy: false });
22
+ const client = await connect({ agentTools: false, agentPolicy: false, searchResults: false });
23
23
  const names = (await client.listTools()).tools.map((t) => t.name);
24
24
  expect(names).toContain("list_projects");
25
25
  expect(names).not.toContain("build_readiness");
@@ -16,7 +16,7 @@ vi.mock("../config.js", () => ({
16
16
  const { gqlClient } = await import("../client.js");
17
17
  const { config } = await import("../config.js");
18
18
  const mockRequest = gqlClient.request;
19
- const V6 = { agentTools: true, agentPolicy: true };
19
+ const V6 = { agentTools: true, agentPolicy: true, searchResults: true };
20
20
  function connect(capabilities = V6) {
21
21
  return createTestClient((server) => registerAgentContextTools(server, capabilities));
22
22
  }
@@ -460,13 +460,13 @@ describe("registration", () => {
460
460
  expect(await toolNames(V6)).toEqual(expect.arrayContaining([...AGENT_TOOLS, "agent_policy"]));
461
461
  });
462
462
  it("registers no agent-context tool on Yontrack 5", async () => {
463
- expect(await toolNames({ agentTools: false, agentPolicy: false })).toEqual([]);
463
+ expect(await toolNames({ agentTools: false, agentPolicy: false, searchResults: true })).toEqual([]);
464
464
  });
465
465
  it("registers agent_policy on its own probe, independently of readiness", async () => {
466
- expect(await toolNames({ agentTools: false, agentPolicy: true })).toEqual(["agent_policy"]);
466
+ expect(await toolNames({ agentTools: false, agentPolicy: true, searchResults: true })).toEqual(["agent_policy"]);
467
467
  });
468
468
  it("leaves out agent_policy when the instance has readiness but no agent policy", async () => {
469
- const names = await toolNames({ agentTools: true, agentPolicy: false });
469
+ const names = await toolNames({ agentTools: true, agentPolicy: false, searchResults: true });
470
470
  expect(names).toEqual(expect.arrayContaining(AGENT_TOOLS));
471
471
  expect(names).not.toContain("agent_policy");
472
472
  });
@@ -19,7 +19,7 @@ export function registerAllTools(server, capabilities) {
19
19
  registerPromotionLevelTools(server, mutationsEnabled);
20
20
  registerPromotionRunTools(server, mutationsEnabled);
21
21
  registerBuildLinkTools(server, mutationsEnabled);
22
- registerSearchTools(server);
22
+ registerSearchTools(server, capabilities.searchResults);
23
23
  registerGraphQLTools(server, mutationsEnabled, capabilities.agentTools);
24
24
  registerAgentContextTools(server, capabilities);
25
25
  }
@@ -0,0 +1,33 @@
1
+ import { gqlClient } from "../client.js";
2
+ // Yontrack 6 only: `query` and `types` replace the deprecated `token` and `type`,
3
+ // and one request covers every type, best results first (see docs/adr/0002-search-api-by-capability.md)
4
+ const SEARCH_RESULTS = `
5
+ query SearchResults($query: String!, $types: [String!], $size: Int) {
6
+ search(query: $query, types: $types, size: $size) {
7
+ items {
8
+ type { id name description }
9
+ title
10
+ description
11
+ accuracy
12
+ data
13
+ }
14
+ total
15
+ capped
16
+ message
17
+ }
18
+ }
19
+ `;
20
+ /** Searches the given type, or all of them, mapped to the Yontrack 5 page shape. */
21
+ export async function searchResults(query, type, size) {
22
+ const { search } = await gqlClient.request(SEARCH_RESULTS, {
23
+ query,
24
+ size,
25
+ ...(type && { types: [type] }),
26
+ });
27
+ return {
28
+ pageItems: search.items,
29
+ pageInfo: { totalSize: search.total },
30
+ capped: search.capped,
31
+ message: search.message,
32
+ };
33
+ }
@@ -1,67 +1,76 @@
1
1
  import { z } from "zod";
2
2
  import { gqlClient } from "../client.js";
3
+ import { searchResults } from "./search-results.js";
4
+ // Yontrack 5 API: `type` is required on v5 (and deprecated, but still accepted, on v6).
5
+ // On Yontrack 6, `searchResults` is used instead.
3
6
  const SEARCH = `
4
- query Search($token: String!, $type: String, $size: Int) {
7
+ query Search($token: String!, $type: String!, $size: Int) {
5
8
  search(token: $token, type: $type, size: $size) {
6
9
  pageItems {
7
10
  type { id name description }
8
11
  title
9
12
  description
10
13
  accuracy
14
+ data
11
15
  }
12
16
  pageInfo { totalSize }
13
17
  }
14
18
  }
15
19
  `;
16
- const SEARCH_TYPED = `
17
- query SearchTyped($token: String!, $type: String!, $size: Int) {
18
- search(token: $token, type: $type, size: $size) {
19
- pageItems {
20
- type { id name description }
21
- title
22
- description
23
- accuracy
24
- data
25
- }
26
- pageInfo { totalSize }
27
- }
20
+ const SEARCH_RESULT_TYPES = `
21
+ query SearchResultTypes {
22
+ searchResultTypes { id }
28
23
  }
29
24
  `;
30
- export function registerSearchTools(server) {
25
+ async function searchType(token, type, size) {
26
+ const data = await gqlClient.request(SEARCH, { token, type, size });
27
+ return data.search ?? { pageItems: [], pageInfo: { totalSize: 0 } };
28
+ }
29
+ /** Searches each type of result, keeping the best `size` results overall. */
30
+ async function searchAllTypes(token, size) {
31
+ const { searchResultTypes } = await gqlClient.request(SEARCH_RESULT_TYPES);
32
+ const pages = await Promise.all(searchResultTypes.flatMap(({ id }) => (id ? [searchType(token, id, size)] : [])));
33
+ return {
34
+ pageItems: pages
35
+ .flatMap((page) => page.pageItems)
36
+ .sort((a, b) => (b.accuracy ?? 0) - (a.accuracy ?? 0))
37
+ .slice(0, size),
38
+ pageInfo: { totalSize: pages.reduce((total, page) => total + (page.pageInfo?.totalSize ?? 0), 0) },
39
+ };
40
+ }
41
+ /** `searchResultsAvailable`: the Yontrack 6 search API is available (`Capabilities.searchResults`). */
42
+ export function registerSearchTools(server, searchResultsAvailable) {
43
+ const search = (token, type, size) => searchResultsAvailable
44
+ ? searchResults(token, type, size)
45
+ : type
46
+ ? searchType(token, type, size)
47
+ : searchAllTypes(token, size);
31
48
  server.tool("search", "Full-text search across all Yontrack entities (projects, branches, builds, etc.)", {
32
49
  token: z.string().describe("Search query"),
33
- type: z.string().optional().describe("Entity type filter (e.g. 'project', 'build')"),
50
+ type: z
51
+ .string()
52
+ .optional()
53
+ .describe("Type of result to search for (e.g. 'project', 'build'); all types when omitted"),
34
54
  size: z.number().int().optional().default(10).describe("Max number of results"),
35
55
  }, async ({ token, type, size }) => {
36
- const data = await gqlClient.request(SEARCH, { token, type, size });
37
56
  return {
38
- content: [{ type: "text", text: JSON.stringify(data.search, null, 2) }],
57
+ content: [{ type: "text", text: JSON.stringify(await search(token, type, size), null, 2) }],
39
58
  };
40
59
  });
41
60
  server.tool("search_commits", "Search for SCM commits by keyword or commit message", {
42
61
  token: z.string().describe("Search query (keyword or commit message fragment)"),
43
62
  size: z.number().int().optional().default(10).describe("Max number of results"),
44
63
  }, async ({ token, size }) => {
45
- const data = await gqlClient.request(SEARCH_TYPED, {
46
- token,
47
- type: "scm-commit",
48
- size,
49
- });
50
64
  return {
51
- content: [{ type: "text", text: JSON.stringify(data.search, null, 2) }],
65
+ content: [{ type: "text", text: JSON.stringify(await search(token, "scm-commit", size), null, 2) }],
52
66
  };
53
67
  });
54
68
  server.tool("search_issues", "Search for SCM issues by keyword or issue key", {
55
69
  token: z.string().describe("Search query (keyword or issue key)"),
56
70
  size: z.number().int().optional().default(10).describe("Max number of results"),
57
71
  }, async ({ token, size }) => {
58
- const data = await gqlClient.request(SEARCH_TYPED, {
59
- token,
60
- type: "scm-issue",
61
- size,
62
- });
63
72
  return {
64
- content: [{ type: "text", text: JSON.stringify(data.search, null, 2) }],
73
+ content: [{ type: "text", text: JSON.stringify(await search(token, "scm-issue", size), null, 2) }],
65
74
  };
66
75
  });
67
76
  }
@@ -9,41 +9,103 @@ const mockRequest = gqlClient.request;
9
9
  beforeEach(() => {
10
10
  mockRequest.mockReset();
11
11
  });
12
- describe("search", () => {
13
- it("returns search results", async () => {
14
- const search = {
15
- pageItems: [
16
- { type: { id: "project", name: "Project", description: null }, title: "myproject", description: null, accuracy: 1.0 },
17
- ],
18
- pageInfo: { totalSize: 1 },
19
- };
20
- mockRequest.mockResolvedValueOnce({ search });
21
- const client = await createTestClient(registerSearchTools);
22
- const result = await client.callTool({
23
- name: "search",
24
- arguments: { token: "myproject" },
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 call(name, args, searchResults) {
20
+ const client = await createTestClient((server) => registerSearchTools(server, searchResults));
21
+ const result = await client.callTool({ name, arguments: args });
22
+ return JSON.parse(result.content[0].text);
23
+ }
24
+ const search = (args) => call("search", args, false);
25
+ const searchV6 = (args) => call("search", args, true);
26
+ describe("search (Yontrack 5 API)", () => {
27
+ it("searches the given type only", async () => {
28
+ const page = { pageItems: [result("build", "1.0.0", 1.0)], pageInfo: { totalSize: 1 } };
29
+ mockRequest.mockResolvedValueOnce({ search: page });
30
+ expect(await search({ token: "foo", type: "build", size: 5 })).toEqual(page);
31
+ expect(mockRequest).toHaveBeenCalledTimes(1);
32
+ expect(mockRequest).toHaveBeenCalledWith(expect.any(String), { token: "foo", type: "build", size: 5 });
33
+ });
34
+ it("searches every type when none is given, best results first", async () => {
35
+ mockRequest
36
+ .mockResolvedValueOnce({ searchResultTypes: [{ id: "project" }, { id: "build" }] })
37
+ .mockResolvedValueOnce({
38
+ search: { pageItems: [result("project", "p1", 0.5), result("project", "p2", 0.1)], pageInfo: { totalSize: 2 } },
39
+ })
40
+ .mockResolvedValueOnce({
41
+ search: { pageItems: [result("build", "b1", 0.9)], pageInfo: { totalSize: 7 } },
42
+ });
43
+ expect(await search({ token: "foo", size: 2 })).toEqual({
44
+ pageItems: [result("build", "b1", 0.9), result("project", "p1", 0.5)],
45
+ pageInfo: { totalSize: 9 },
25
46
  });
26
- const text = result.content[0].text;
27
- expect(JSON.parse(text)).toEqual(search);
47
+ expect(mockRequest).toHaveBeenCalledWith(expect.stringContaining("searchResultTypes"));
48
+ expect(mockRequest).toHaveBeenCalledWith(expect.any(String), { token: "foo", type: "project", size: 2 });
49
+ expect(mockRequest).toHaveBeenCalledWith(expect.any(String), { token: "foo", type: "build", size: 2 });
28
50
  });
29
51
  it("returns empty results when nothing matches", async () => {
30
- const search = { pageItems: [], pageInfo: { totalSize: 0 } };
31
- mockRequest.mockResolvedValueOnce({ search });
32
- const client = await createTestClient(registerSearchTools);
33
- const result = await client.callTool({
34
- name: "search",
35
- arguments: { token: "zzznomatch" },
52
+ mockRequest
53
+ .mockResolvedValueOnce({ searchResultTypes: [{ id: "project" }] })
54
+ .mockResolvedValueOnce({ search: { pageItems: [], pageInfo: { totalSize: 0 } } });
55
+ expect(await search({ token: "zzznomatch" })).toEqual({ pageItems: [], pageInfo: { totalSize: 0 } });
56
+ });
57
+ it("searches commits through their type", async () => {
58
+ const page = { pageItems: [result("scm-commit", "abc123", 1.0)], pageInfo: { totalSize: 1 } };
59
+ mockRequest.mockResolvedValueOnce({ search: page });
60
+ expect(await call("search_commits", { token: "fix" }, false)).toEqual(page);
61
+ expect(mockRequest).toHaveBeenCalledWith(expect.stringContaining("token: $token"), {
62
+ token: "fix",
63
+ type: "scm-commit",
64
+ size: 10,
36
65
  });
37
- const text = result.content[0].text;
38
- expect(JSON.parse(text)).toEqual(search);
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 },
66
+ });
67
+ });
68
+ describe("search (Yontrack 6 API)", () => {
69
+ const results = (items, total, message = null) => ({
70
+ search: { items, total, capped: false, message },
71
+ });
72
+ it("searches all types in a single request, keeping the output shape", async () => {
73
+ mockRequest.mockResolvedValueOnce(results([result("build", "b1", 0.9), result("project", "p1", 0.5)], 9));
74
+ expect(await searchV6({ token: "foo", size: 2 })).toEqual({
75
+ pageItems: [result("build", "b1", 0.9), result("project", "p1", 0.5)],
76
+ pageInfo: { totalSize: 9 },
77
+ capped: false,
78
+ message: null,
79
+ });
80
+ expect(mockRequest).toHaveBeenCalledTimes(1);
81
+ expect(mockRequest.mock.calls[0][0]).toContain("search(query: $query, types: $types, size: $size)");
82
+ expect(mockRequest.mock.calls[0][1]).toStrictEqual({ query: "foo", size: 2 });
83
+ });
84
+ it("restricts the search to the given type", async () => {
85
+ mockRequest.mockResolvedValueOnce(results([result("build", "1.0.0", 1.0)], 1));
86
+ await searchV6({ token: "foo", type: "build", size: 5 });
87
+ expect(mockRequest).toHaveBeenCalledWith(expect.any(String), { query: "foo", types: ["build"], size: 5 });
88
+ });
89
+ it("passes on the capped flag and the message of the search", async () => {
90
+ mockRequest.mockResolvedValueOnce({
91
+ search: { items: [], total: 1000, capped: true, message: "The search index is being built" },
92
+ });
93
+ expect(await searchV6({ token: "foo" })).toMatchObject({
94
+ pageInfo: { totalSize: 1000 },
95
+ capped: true,
96
+ message: "The search index is being built",
97
+ });
98
+ });
99
+ it.each([
100
+ ["search_commits", "scm-commit"],
101
+ ["search_issues", "scm-issue"],
102
+ ])("%s searches the %s type", async (tool, type) => {
103
+ mockRequest.mockResolvedValueOnce(results([], 0));
104
+ await call(tool, { token: "fix" }, true);
105
+ expect(mockRequest).toHaveBeenCalledWith(expect.stringContaining("query: $query"), {
106
+ query: "fix",
107
+ types: [type],
108
+ size: 10,
46
109
  });
47
- expect(mockRequest).toHaveBeenCalledWith(expect.any(String), { token: "foo", type: "build", size: 5 });
48
110
  });
49
111
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yontrack-mcp",
3
- "version": "1.14.2",
3
+ "version": "1.15.0",
4
4
  "description": "MCP server for Yontrack (Ontrack CI/CD monitoring platform)",
5
5
  "repository": {
6
6
  "type": "git",