yontrack-mcp 1.13.0 → 1.14.1
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 +33 -0
- package/build/capabilities.js +40 -0
- package/build/capabilities.test.js +58 -0
- package/build/client.js +6 -1
- package/build/client.test.js +70 -0
- package/build/config.js +10 -1
- package/build/index.js +8 -3
- package/build/server.js +20 -9
- package/build/server.test.js +28 -0
- package/build/session.js +34 -0
- package/build/tools/agent-context-support.js +107 -0
- package/build/tools/agent-context.js +304 -0
- package/build/tools/agent-context.test.js +473 -0
- package/build/tools/graphql.js +10 -4
- package/build/tools/graphql.test.js +11 -1
- package/build/tools/index.js +4 -2
- package/build/tools/promotion-levels.js +2 -2
- package/build/tools/promotion-levels.test.js +2 -1
- package/package.json +7 -2
- package/yontrack-v6.graphql +14439 -0
- /package/{yontrack.graphql → yontrack-v5.graphql} +0 -0
package/README.md
CHANGED
|
@@ -9,6 +9,8 @@ The server uses the [Streamable HTTP transport](https://modelcontextprotocol.io/
|
|
|
9
9
|
- **Read-only by default** — only query tools are active unless mutations are explicitly enabled
|
|
10
10
|
- **Mutation tools** — create projects, branches, builds, validation stamps/runs, promotion levels/runs, and build links (opt-in via `YONTRACK_MUTATIONS_ENABLED=true`)
|
|
11
11
|
- **Raw GraphQL access** — `graphql_query` tool for queries not covered by the dedicated tools
|
|
12
|
+
- **Agent-context tools (Yontrack 6+)** — readiness, change log since what is deployed, what is deployed where, dependency builds at a level, agent policy
|
|
13
|
+
- **Agent sessions** — forwards the agent session to Yontrack so that what an agent records is attributed to its session
|
|
12
14
|
- **OAuth2 support** — required for claude.ai; enabled by setting two environment variables
|
|
13
15
|
- **Docker image** — published to Docker Hub on every release (`nemerosa/yontrack-mcp:latest`)
|
|
14
16
|
- **Helm chart** — OCI chart for Kubernetes deployments with full secret management support
|
|
@@ -32,6 +34,31 @@ By default only read-only tools are available. Set `YONTRACK_MUTATIONS_ENABLED=t
|
|
|
32
34
|
|
|
33
35
|
> ✝ `graphql_query` is always registered but rejects requests whose query string starts with `mutation` when `YONTRACK_MUTATIONS_ENABLED` is not set.
|
|
34
36
|
|
|
37
|
+
### Agent-context tools (Yontrack 6+)
|
|
38
|
+
|
|
39
|
+
Read-only tools answering the questions an agent asks before promoting or deploying. They return compact JSON (names, `displayName`, statuses, missing items with their kinds, commit assistants) with links to the Yontrack UI when `YONTRACK_UI_URL` is set.
|
|
40
|
+
|
|
41
|
+
| Tool | Answers |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `build_readiness` | Is a build ready for a promotion level or a slot (environment + optional qualifier), and what is missing? |
|
|
44
|
+
| `changes_since_deployed` | What changed (commits with their assistants, issues, dependency changes) between what is deployed in a slot, or the last build at a promotion level on the same branch, and a candidate build? |
|
|
45
|
+
| `deployments` | What is deployed where for a project, and which pipelines are in progress? |
|
|
46
|
+
| `dependency_builds_at_level` | Which builds of a dependency project are at a given promotion level? |
|
|
47
|
+
| `agent_policy` | What may the agent behind the token do on a project? (`{ "agent": false }` for a human token) |
|
|
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.
|
|
50
|
+
|
|
51
|
+
On Yontrack 6, the `yontrack://schema` resource also serves the Yontrack 6 schema instead of the Yontrack 5 one.
|
|
52
|
+
|
|
53
|
+
### Agent sessions
|
|
54
|
+
|
|
55
|
+
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:
|
|
56
|
+
|
|
57
|
+
1. the same headers on the incoming MCP request, when it carries any (a shared HTTP deployment serving several agents), or else
|
|
58
|
+
2. the `YONTRACK_AGENT_SESSION` and `YONTRACK_AGENT_SESSION_LINK` environment variables (one server process per agent, e.g. `stdio`).
|
|
59
|
+
|
|
60
|
+
The session and its link are always taken together from the same source, and never invented: nothing is sent when neither is set. Yontrack validates them (at most 255 characters for the session, an absolute `https` URL for the link) and ignores them for human tokens and on Yontrack 5.
|
|
61
|
+
|
|
35
62
|
## Installation
|
|
36
63
|
|
|
37
64
|
### Cursor
|
|
@@ -124,6 +151,8 @@ helm install yontrack-mcp \
|
|
|
124
151
|
| `yontrack.url` | URL of the Yontrack instance | `""` |
|
|
125
152
|
| `yontrack.token` | Yontrack API token | `""` |
|
|
126
153
|
| `yontrack.mutationsEnabled` | Enable mutation tools (create/promote/link operations) | `false` |
|
|
154
|
+
| `yontrack.agentTools` | Agent-context tools: `auto` (detected on Yontrack 6), `true` or `false` | `auto` |
|
|
155
|
+
| `yontrack.uiUrl` | URL of the Yontrack UI, for the links in the agent-context tools | `""` |
|
|
127
156
|
| `oauth.serverUrl` | Public HTTPS URL of this server — enables OAuth2 when set with `oauth.authPassword` | `""` |
|
|
128
157
|
| `oauth.authPassword` | Password for the browser authorization form — enables OAuth2 when set with `oauth.serverUrl` | `""` |
|
|
129
158
|
| `persistence.enabled` | Create a PVC and mount it at `/data`; sets the clients file to `/data/clients.json` | `true` |
|
|
@@ -295,6 +324,10 @@ An API token is required to authenticate against Yontrack. To generate one, log
|
|
|
295
324
|
| `YONTRACK_URL` | Yes | — | URL of the Yontrack instance (e.g. `https://yontrack.example.com`) |
|
|
296
325
|
| `YONTRACK_TOKEN` | Yes | — | API token for authenticating against Yontrack |
|
|
297
326
|
| `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). |
|
|
328
|
+
| `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
|
+
| `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
|
+
| `YONTRACK_AGENT_SESSION_LINK`| No | — | Link to the agent session (absolute `https` URL), sent together with `YONTRACK_AGENT_SESSION`. |
|
|
298
331
|
| `YONTRACK_MCP_SERVER_URL` | No | — | Public HTTPS URL of this server. When set together with `YONTRACK_MCP_AUTH_PASSWORD`, enables OAuth2 (required for claude.ai). |
|
|
299
332
|
| `YONTRACK_MCP_AUTH_PASSWORD` | No | — | Password users must enter in the browser authorization form. Required together with `YONTRACK_MCP_SERVER_URL` to enable OAuth2. |
|
|
300
333
|
| `YONTRACK_MCP_CLIENTS_FILE` | No | `./yontrack-mcp-clients.json` | File path where registered OAuth2 clients are persisted so they survive restarts. The Helm chart sets this automatically to `/data/clients.json` when `persistence.enabled` is true. |
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { gqlClient } from "./client.js";
|
|
2
|
+
import { config } from "./config.js";
|
|
3
|
+
const PROBE = `
|
|
4
|
+
query ProbeCapabilities {
|
|
5
|
+
readiness: __type(name: "Readiness") { name }
|
|
6
|
+
agentPolicy: __type(name: "AgentPolicy") { name }
|
|
7
|
+
}
|
|
8
|
+
`;
|
|
9
|
+
const NONE = { agentTools: false, agentPolicy: false };
|
|
10
|
+
const ALL = { agentTools: true, agentPolicy: true };
|
|
11
|
+
/**
|
|
12
|
+
* 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.
|
|
14
|
+
*/
|
|
15
|
+
export function createCapabilityProbe(mode) {
|
|
16
|
+
let cached;
|
|
17
|
+
async function get() {
|
|
18
|
+
if (mode === "true")
|
|
19
|
+
return ALL;
|
|
20
|
+
if (mode === "false")
|
|
21
|
+
return NONE;
|
|
22
|
+
if (cached)
|
|
23
|
+
return cached;
|
|
24
|
+
try {
|
|
25
|
+
const data = await gqlClient.request(PROBE);
|
|
26
|
+
cached = {
|
|
27
|
+
agentTools: data.readiness != null,
|
|
28
|
+
agentPolicy: data.agentPolicy != null,
|
|
29
|
+
};
|
|
30
|
+
return cached;
|
|
31
|
+
}
|
|
32
|
+
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;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { get };
|
|
38
|
+
}
|
|
39
|
+
const probe = createCapabilityProbe(config.YONTRACK_AGENT_TOOLS);
|
|
40
|
+
export const getCapabilities = probe.get;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { createCapabilityProbe } from "./capabilities.js";
|
|
3
|
+
vi.mock("./client.js", () => ({
|
|
4
|
+
gqlClient: { request: vi.fn() },
|
|
5
|
+
}));
|
|
6
|
+
const { gqlClient } = await import("./client.js");
|
|
7
|
+
const mockRequest = gqlClient.request;
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
mockRequest.mockReset();
|
|
10
|
+
});
|
|
11
|
+
describe("capability probe (auto)", () => {
|
|
12
|
+
it("detects Yontrack 6 when both Readiness and AgentPolicy exist", async () => {
|
|
13
|
+
mockRequest.mockResolvedValueOnce({
|
|
14
|
+
readiness: { name: "Readiness" },
|
|
15
|
+
agentPolicy: { name: "AgentPolicy" },
|
|
16
|
+
});
|
|
17
|
+
const probe = createCapabilityProbe("auto");
|
|
18
|
+
expect(await probe.get()).toEqual({ agentTools: true, agentPolicy: true });
|
|
19
|
+
});
|
|
20
|
+
it("detects Yontrack 5 when neither type exists", async () => {
|
|
21
|
+
mockRequest.mockResolvedValueOnce({ readiness: null, agentPolicy: null });
|
|
22
|
+
const probe = createCapabilityProbe("auto");
|
|
23
|
+
expect(await probe.get()).toEqual({ agentTools: false, agentPolicy: false });
|
|
24
|
+
});
|
|
25
|
+
it("enables agent tools without agent_policy when only Readiness exists", async () => {
|
|
26
|
+
mockRequest.mockResolvedValueOnce({ readiness: { name: "Readiness" }, agentPolicy: null });
|
|
27
|
+
const probe = createCapabilityProbe("auto");
|
|
28
|
+
expect(await probe.get()).toEqual({ agentTools: true, agentPolicy: false });
|
|
29
|
+
});
|
|
30
|
+
it("caches a definite answer for the life of the probe", async () => {
|
|
31
|
+
mockRequest.mockResolvedValueOnce({ readiness: null, agentPolicy: null });
|
|
32
|
+
const probe = createCapabilityProbe("auto");
|
|
33
|
+
await probe.get();
|
|
34
|
+
await probe.get();
|
|
35
|
+
expect(mockRequest).toHaveBeenCalledTimes(1);
|
|
36
|
+
});
|
|
37
|
+
it("hides the tools on a probe error and probes again next time", async () => {
|
|
38
|
+
mockRequest
|
|
39
|
+
.mockRejectedValueOnce(new Error("connection refused"))
|
|
40
|
+
.mockResolvedValueOnce({ readiness: { name: "Readiness" }, agentPolicy: { name: "AgentPolicy" } });
|
|
41
|
+
const probe = createCapabilityProbe("auto");
|
|
42
|
+
expect(await probe.get()).toEqual({ agentTools: false, agentPolicy: false });
|
|
43
|
+
expect(await probe.get()).toEqual({ agentTools: true, agentPolicy: true });
|
|
44
|
+
expect(mockRequest).toHaveBeenCalledTimes(2);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe("capability probe (override)", () => {
|
|
48
|
+
it("forces the tools on without probing when set to true", async () => {
|
|
49
|
+
const probe = createCapabilityProbe("true");
|
|
50
|
+
expect(await probe.get()).toEqual({ agentTools: true, agentPolicy: true });
|
|
51
|
+
expect(mockRequest).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
it("forces the tools off without probing when set to false", async () => {
|
|
54
|
+
const probe = createCapabilityProbe("false");
|
|
55
|
+
expect(await probe.get()).toEqual({ agentTools: false, agentPolicy: false });
|
|
56
|
+
expect(mockRequest).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
});
|
package/build/client.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { GraphQLClient } from "graphql-request";
|
|
2
2
|
import { config } from "./config.js";
|
|
3
|
+
import { agentSessionHeaders } from "./session.js";
|
|
4
|
+
/** Headers for every request to Yontrack, computed per request to carry the current agent session. */
|
|
5
|
+
export function yontrackHeaders() {
|
|
6
|
+
return { "X-Ontrack-Token": config.YONTRACK_TOKEN, ...agentSessionHeaders() };
|
|
7
|
+
}
|
|
3
8
|
export const gqlClient = new GraphQLClient(`${config.YONTRACK_URL}/graphql`, {
|
|
4
|
-
headers:
|
|
9
|
+
headers: yontrackHeaders,
|
|
5
10
|
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
const fetchMock = vi.fn();
|
|
3
|
+
async function loadClient(env = {}) {
|
|
4
|
+
vi.resetModules();
|
|
5
|
+
delete process.env.YONTRACK_AGENT_SESSION;
|
|
6
|
+
delete process.env.YONTRACK_AGENT_SESSION_LINK;
|
|
7
|
+
Object.assign(process.env, env);
|
|
8
|
+
const client = await import("./client.js");
|
|
9
|
+
const session = await import("./session.js");
|
|
10
|
+
return { ...client, ...session };
|
|
11
|
+
}
|
|
12
|
+
function sentHeaders() {
|
|
13
|
+
const init = fetchMock.mock.calls[0][1];
|
|
14
|
+
return new Headers(init.headers);
|
|
15
|
+
}
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
fetchMock.mockReset();
|
|
18
|
+
fetchMock.mockImplementation(async () => new Response(JSON.stringify({ data: { ok: true } }), {
|
|
19
|
+
headers: { "Content-Type": "application/json" },
|
|
20
|
+
}));
|
|
21
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
22
|
+
});
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.unstubAllGlobals();
|
|
25
|
+
delete process.env.YONTRACK_AGENT_SESSION;
|
|
26
|
+
delete process.env.YONTRACK_AGENT_SESSION_LINK;
|
|
27
|
+
});
|
|
28
|
+
describe("agent session headers", () => {
|
|
29
|
+
it("sends no session headers when none is configured", async () => {
|
|
30
|
+
const { gqlClient } = await loadClient();
|
|
31
|
+
await gqlClient.request("{ ok }");
|
|
32
|
+
const headers = sentHeaders();
|
|
33
|
+
expect(headers.get("X-Ontrack-Token")).toBe("test-token");
|
|
34
|
+
expect(headers.has("X-Yontrack-Agent-Session")).toBe(false);
|
|
35
|
+
expect(headers.has("X-Yontrack-Agent-Session-Link")).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
it("sends the session configured in the environment", async () => {
|
|
38
|
+
const { gqlClient } = await loadClient({
|
|
39
|
+
YONTRACK_AGENT_SESSION: "env-session",
|
|
40
|
+
YONTRACK_AGENT_SESSION_LINK: "https://claude.ai/code/env-session",
|
|
41
|
+
});
|
|
42
|
+
await gqlClient.request("{ ok }");
|
|
43
|
+
const headers = sentHeaders();
|
|
44
|
+
expect(headers.get("X-Yontrack-Agent-Session")).toBe("env-session");
|
|
45
|
+
expect(headers.get("X-Yontrack-Agent-Session-Link")).toBe("https://claude.ai/code/env-session");
|
|
46
|
+
});
|
|
47
|
+
it("prefers the session of the incoming MCP request over the environment", async () => {
|
|
48
|
+
const { gqlClient, runWithAgentSession } = await loadClient({
|
|
49
|
+
YONTRACK_AGENT_SESSION: "env-session",
|
|
50
|
+
YONTRACK_AGENT_SESSION_LINK: "https://claude.ai/code/env-session",
|
|
51
|
+
});
|
|
52
|
+
await runWithAgentSession({ session: "req-session" }, () => gqlClient.request("{ ok }"));
|
|
53
|
+
const headers = sentHeaders();
|
|
54
|
+
expect(headers.get("X-Yontrack-Agent-Session")).toBe("req-session");
|
|
55
|
+
// The environment link belongs to the environment session: never mixed with the request one
|
|
56
|
+
expect(headers.has("X-Yontrack-Agent-Session-Link")).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
it("forwards a request link without a session as is", async () => {
|
|
59
|
+
const { gqlClient, runWithAgentSession } = await loadClient();
|
|
60
|
+
await runWithAgentSession({ link: "https://claude.ai/code/abc" }, () => gqlClient.request("{ ok }"));
|
|
61
|
+
const headers = sentHeaders();
|
|
62
|
+
expect(headers.has("X-Yontrack-Agent-Session")).toBe(false);
|
|
63
|
+
expect(headers.get("X-Yontrack-Agent-Session-Link")).toBe("https://claude.ai/code/abc");
|
|
64
|
+
});
|
|
65
|
+
it("falls back to the environment when the request carries no session headers", async () => {
|
|
66
|
+
const { gqlClient, runWithAgentSession } = await loadClient({ YONTRACK_AGENT_SESSION: "env-session" });
|
|
67
|
+
await runWithAgentSession({}, () => gqlClient.request("{ ok }"));
|
|
68
|
+
expect(sentHeaders().get("X-Yontrack-Agent-Session")).toBe("env-session");
|
|
69
|
+
});
|
|
70
|
+
});
|
package/build/config.js
CHANGED
|
@@ -6,13 +6,22 @@ const ConfigSchema = z.object({
|
|
|
6
6
|
.string()
|
|
7
7
|
.optional()
|
|
8
8
|
.transform((v) => v === "true"),
|
|
9
|
+
YONTRACK_AGENT_TOOLS: z.enum(["auto", "true", "false"]).optional().default("auto"),
|
|
10
|
+
YONTRACK_UI_URL: z
|
|
11
|
+
.preprocess((v) => (v === "" ? undefined : v), z.string().url().optional())
|
|
12
|
+
.transform((v) => v?.replace(/\/+$/, "")),
|
|
13
|
+
YONTRACK_AGENT_SESSION: z.string().optional(),
|
|
14
|
+
YONTRACK_AGENT_SESSION_LINK: z.string().optional(),
|
|
9
15
|
});
|
|
10
16
|
let _config;
|
|
11
17
|
try {
|
|
12
18
|
_config = ConfigSchema.parse(process.env);
|
|
13
19
|
}
|
|
14
20
|
catch (err) {
|
|
15
|
-
|
|
21
|
+
const details = err instanceof z.ZodError
|
|
22
|
+
? err.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")
|
|
23
|
+
: String(err);
|
|
24
|
+
process.stderr.write(`Invalid or missing environment variables (YONTRACK_URL and YONTRACK_TOKEN are required): ${details}\n`);
|
|
16
25
|
process.exit(1);
|
|
17
26
|
}
|
|
18
27
|
export const config = _config;
|
package/build/index.js
CHANGED
|
@@ -6,9 +6,12 @@ import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
|
6
6
|
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
|
7
7
|
import { createServer } from "./server.js";
|
|
8
8
|
import { oauthConfig } from "./config.js";
|
|
9
|
+
import { getCapabilities } from "./capabilities.js";
|
|
10
|
+
import { agentSessionFromHeaders, runWithAgentSession } from "./session.js";
|
|
9
11
|
import { createOAuthProvider, clientsStore, generateAuthCode, renderAuthFormHtml, } from "./auth.js";
|
|
10
12
|
if (process.argv[2] === "stdio") {
|
|
11
|
-
|
|
13
|
+
// One process per agent: capabilities are probed once, the agent session comes from the environment
|
|
14
|
+
const server = createServer(undefined, await getCapabilities());
|
|
12
15
|
const transport = new StdioServerTransport();
|
|
13
16
|
await server.connect(transport);
|
|
14
17
|
}
|
|
@@ -36,14 +39,16 @@ else {
|
|
|
36
39
|
});
|
|
37
40
|
// MCP request handler
|
|
38
41
|
async function handleMcp(req, res) {
|
|
39
|
-
const server = createServer(oauthConfig?.serverUrl);
|
|
42
|
+
const server = createServer(oauthConfig?.serverUrl, await getCapabilities());
|
|
40
43
|
const transport = new StreamableHTTPServerTransport({
|
|
41
44
|
sessionIdGenerator: undefined,
|
|
42
45
|
});
|
|
43
46
|
res.on("close", () => transport.close());
|
|
44
47
|
await server.connect(transport);
|
|
48
|
+
// The agent session headers of this request are forwarded to Yontrack by every call it makes
|
|
49
|
+
await runWithAgentSession(agentSessionFromHeaders(req.headers), () =>
|
|
45
50
|
// req.body is pre-parsed by express.json() for POST requests
|
|
46
|
-
|
|
51
|
+
transport.handleRequest(req, res, req.body));
|
|
47
52
|
}
|
|
48
53
|
function oauthLog(msg) {
|
|
49
54
|
process.stderr.write(`[oauth] ${msg}\n`);
|
package/build/server.js
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { registerAllTools } from "./tools/index.js";
|
|
3
|
-
|
|
3
|
+
const INSTRUCTIONS = `
|
|
4
|
+
Use the specific tools (list_projects, get_build, etc.) for simple, single-entity lookups.
|
|
5
|
+
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.
|
|
6
|
+
Read the yontrack://schema resource before writing a GraphQL query to understand available types and fields.
|
|
7
|
+
When displaying branches, builds, or other entities to the user, always prefer the displayName field over the name field.
|
|
8
|
+
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.
|
|
9
|
+
`.trim();
|
|
10
|
+
const AGENT_CONTEXT_INSTRUCTIONS = `
|
|
11
|
+
To know whether a build is ready for a promotion level or a slot, what changed since what is deployed, what is deployed where, or which dependency builds are at a level, prefer build_readiness, changes_since_deployed, deployments and dependency_builds_at_level over graphql_query.
|
|
12
|
+
`.trim();
|
|
13
|
+
const AGENT_POLICY_INSTRUCTIONS = `
|
|
14
|
+
To know what the agent behind the current token may do on a project (promote, deploy, record evidence), use agent_policy.
|
|
15
|
+
`.trim();
|
|
16
|
+
export function createServer(serverUrl, capabilities) {
|
|
4
17
|
const server = new McpServer({
|
|
5
18
|
name: "yontrack",
|
|
6
19
|
version: "1.0.0",
|
|
@@ -8,14 +21,12 @@ export function createServer(serverUrl) {
|
|
|
8
21
|
icons: [{ src: `${serverUrl}/yontrack.png`, mimeType: "image/png" }],
|
|
9
22
|
}),
|
|
10
23
|
}, {
|
|
11
|
-
instructions:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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(),
|
|
24
|
+
instructions: [
|
|
25
|
+
INSTRUCTIONS,
|
|
26
|
+
...(capabilities.agentTools ? [AGENT_CONTEXT_INSTRUCTIONS] : []),
|
|
27
|
+
...(capabilities.agentPolicy ? [AGENT_POLICY_INSTRUCTIONS] : []),
|
|
28
|
+
].join("\n"),
|
|
18
29
|
});
|
|
19
|
-
registerAllTools(server);
|
|
30
|
+
registerAllTools(server, capabilities);
|
|
20
31
|
return server;
|
|
21
32
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
4
|
+
import { createServer } from "./server.js";
|
|
5
|
+
async function connect(capabilities) {
|
|
6
|
+
const server = createServer(undefined, capabilities);
|
|
7
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
8
|
+
const client = new Client({ name: "test-client", version: "0.0.0" });
|
|
9
|
+
await server.connect(serverTransport);
|
|
10
|
+
await client.connect(clientTransport);
|
|
11
|
+
return client;
|
|
12
|
+
}
|
|
13
|
+
describe("createServer", () => {
|
|
14
|
+
it("exposes the agent-context tools and mentions them on Yontrack 6", async () => {
|
|
15
|
+
const client = await connect({ agentTools: true, agentPolicy: true });
|
|
16
|
+
const names = (await client.listTools()).tools.map((t) => t.name);
|
|
17
|
+
expect(names).toEqual(expect.arrayContaining(["list_projects", "build_readiness", "agent_policy"]));
|
|
18
|
+
expect(client.getInstructions()).toContain("build_readiness");
|
|
19
|
+
expect(client.getInstructions()).toContain("agent_policy");
|
|
20
|
+
});
|
|
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 });
|
|
23
|
+
const names = (await client.listTools()).tools.map((t) => t.name);
|
|
24
|
+
expect(names).toContain("list_projects");
|
|
25
|
+
expect(names).not.toContain("build_readiness");
|
|
26
|
+
expect(client.getInstructions()).not.toContain("build_readiness");
|
|
27
|
+
});
|
|
28
|
+
});
|
package/build/session.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { config } from "./config.js";
|
|
3
|
+
/** Header names, identical on the incoming MCP request and on the outgoing Yontrack request. */
|
|
4
|
+
export const AGENT_SESSION_HEADER = "X-Yontrack-Agent-Session";
|
|
5
|
+
export const AGENT_SESSION_LINK_HEADER = "X-Yontrack-Agent-Session-Link";
|
|
6
|
+
const storage = new AsyncLocalStorage();
|
|
7
|
+
/** Runs `fn` with the agent session carried by the incoming MCP request. */
|
|
8
|
+
export function runWithAgentSession(session, fn) {
|
|
9
|
+
return storage.run(session, fn);
|
|
10
|
+
}
|
|
11
|
+
/** Reads the agent session headers of an incoming HTTP request (Node lower-cases header names). */
|
|
12
|
+
export function agentSessionFromHeaders(headers) {
|
|
13
|
+
const read = (name) => {
|
|
14
|
+
const value = headers[name.toLowerCase()];
|
|
15
|
+
return (Array.isArray(value) ? value[0] : value) || undefined;
|
|
16
|
+
};
|
|
17
|
+
return { session: read(AGENT_SESSION_HEADER), link: read(AGENT_SESSION_LINK_HEADER) };
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Headers identifying the agent session, never invented:
|
|
21
|
+
* the incoming MCP request's session headers when it carries any, the environment otherwise.
|
|
22
|
+
* The session and its link are taken as a pair so that they always belong together.
|
|
23
|
+
* Yontrack validates them and ignores them for non-agent tokens.
|
|
24
|
+
*/
|
|
25
|
+
export function agentSessionHeaders() {
|
|
26
|
+
const fromRequest = storage.getStore();
|
|
27
|
+
const { session, link } = fromRequest && (fromRequest.session || fromRequest.link)
|
|
28
|
+
? fromRequest
|
|
29
|
+
: { session: config.YONTRACK_AGENT_SESSION, link: config.YONTRACK_AGENT_SESSION_LINK };
|
|
30
|
+
return {
|
|
31
|
+
...(session && { [AGENT_SESSION_HEADER]: session }),
|
|
32
|
+
...(link && { [AGENT_SESSION_LINK_HEADER]: link }),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { gqlClient } from "../client.js";
|
|
2
|
+
import { config } from "../config.js";
|
|
3
|
+
/** Link to a page of the Yontrack UI, or `undefined` when `YONTRACK_UI_URL` is not set. */
|
|
4
|
+
export function uiLink(path) {
|
|
5
|
+
return config.YONTRACK_UI_URL ? `${config.YONTRACK_UI_URL}/${path}` : undefined;
|
|
6
|
+
}
|
|
7
|
+
export const slotLink = (slotId) => uiLink(`extension/environments/slot/${slotId}`);
|
|
8
|
+
export const pipelineLink = (pipelineId) => uiLink(`extension/environments/pipeline/${pipelineId}`);
|
|
9
|
+
export function compactBuild(build) {
|
|
10
|
+
return { name: build.name, displayName: build.displayName, link: uiLink(`build/${build.id}`) };
|
|
11
|
+
}
|
|
12
|
+
/** Display name of a slot: `environment` or `environment/qualifier`. */
|
|
13
|
+
export function slotName(slot) {
|
|
14
|
+
return slot.qualifier ? `${slot.environment.name}/${slot.qualifier}` : slot.environment.name;
|
|
15
|
+
}
|
|
16
|
+
export class NotFoundError extends Error {
|
|
17
|
+
}
|
|
18
|
+
const RESOLVE_BUILD = `
|
|
19
|
+
query ResolveBuild(
|
|
20
|
+
$project: String!, $branch: String!, $build: String!,
|
|
21
|
+
$withLevels: Boolean!, $withSlot: Boolean!, $environment: String, $qualifier: String
|
|
22
|
+
) {
|
|
23
|
+
builds(project: $project, branch: $branch, name: $build) {
|
|
24
|
+
id
|
|
25
|
+
name
|
|
26
|
+
displayName
|
|
27
|
+
branch @include(if: $withLevels) { promotionLevels { id name } }
|
|
28
|
+
slots(environment: $environment, qualifier: $qualifier) @include(if: $withSlot) {
|
|
29
|
+
id
|
|
30
|
+
qualifier
|
|
31
|
+
environment { name }
|
|
32
|
+
lastDeployedPipeline { id number build { id name displayName } }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
37
|
+
/**
|
|
38
|
+
* Resolves a build by names and, when given, its target (promotion level or slot).
|
|
39
|
+
* Throws a `NotFoundError` naming the missing entity.
|
|
40
|
+
*/
|
|
41
|
+
export async function resolveBuild(project, branch, build, target) {
|
|
42
|
+
const withLevels = target?.promotionLevel !== undefined;
|
|
43
|
+
const withSlot = target?.environment !== undefined;
|
|
44
|
+
const data = await gqlClient.request(RESOLVE_BUILD, {
|
|
45
|
+
project, branch, build, withLevels, withSlot,
|
|
46
|
+
environment: target?.environment ?? null,
|
|
47
|
+
qualifier: withSlot ? (target?.qualifier ?? "") : null,
|
|
48
|
+
});
|
|
49
|
+
const found = data.builds?.[0];
|
|
50
|
+
if (!found) {
|
|
51
|
+
throw new NotFoundError(`Build '${build}' not found on branch '${branch}' of project '${project}'`);
|
|
52
|
+
}
|
|
53
|
+
const buildRef = { id: found.id, name: found.name, displayName: found.displayName };
|
|
54
|
+
if (withLevels) {
|
|
55
|
+
const level = found.branch?.promotionLevels.find((l) => l.name === target.promotionLevel);
|
|
56
|
+
if (!level) {
|
|
57
|
+
throw new NotFoundError(`Promotion level '${target.promotionLevel}' not found on branch '${branch}' of project '${project}'`);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
build: buildRef,
|
|
61
|
+
target: { kind: "promotionLevel", id: level.id, name: level.name, link: uiLink(`promotionLevel/${level.id}`) },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (withSlot) {
|
|
65
|
+
const slot = found.slots?.[0];
|
|
66
|
+
if (!slot) {
|
|
67
|
+
const qualifier = target.qualifier ? ` with qualifier '${target.qualifier}'` : "";
|
|
68
|
+
throw new NotFoundError(`No slot for project '${project}' in environment '${target.environment}'${qualifier}`);
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
build: buildRef,
|
|
72
|
+
target: { kind: "slot", id: slot.id, name: slotName(slot), link: slotLink(slot.id), slot },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return { build: buildRef };
|
|
76
|
+
}
|
|
77
|
+
/** Reads the exactly-one-of target from tool arguments. */
|
|
78
|
+
export function targetOf(args) {
|
|
79
|
+
const hasLevel = args.promotionLevel !== undefined;
|
|
80
|
+
const hasEnvironment = args.environment !== undefined;
|
|
81
|
+
if (hasLevel === hasEnvironment) {
|
|
82
|
+
return "Exactly one of 'promotionLevel' or 'environment' must be given";
|
|
83
|
+
}
|
|
84
|
+
if (!hasEnvironment && args.qualifier !== undefined) {
|
|
85
|
+
return "'qualifier' can only be used together with 'environment'";
|
|
86
|
+
}
|
|
87
|
+
return hasLevel
|
|
88
|
+
? { promotionLevel: args.promotionLevel }
|
|
89
|
+
: { environment: args.environment, qualifier: args.qualifier };
|
|
90
|
+
}
|
|
91
|
+
export function errorResult(message) {
|
|
92
|
+
return { isError: true, content: [{ type: "text", text: message }] };
|
|
93
|
+
}
|
|
94
|
+
export function jsonResult(value) {
|
|
95
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
|
|
96
|
+
}
|
|
97
|
+
/** Turns a `NotFoundError` into an error result; other errors propagate. */
|
|
98
|
+
export async function withNotFound(fn) {
|
|
99
|
+
try {
|
|
100
|
+
return await fn();
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
if (err instanceof NotFoundError)
|
|
104
|
+
return errorResult(err.message);
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
}
|