yontrack-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,305 @@
1
+ # yontrack-mcp
2
+
3
+ MCP server for [Yontrack](https://ontrack.nemerosa.net) (Ontrack CI/CD monitoring platform). It exposes Yontrack's GraphQL API as MCP tools so that AI assistants such as Claude can query and manage your CI/CD pipelines through natural language.
4
+
5
+ The server uses the [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) (`POST /mcp`) and is available as a pre-built Docker image.
6
+
7
+ ## Features
8
+
9
+ - **Read-only by default** — only query tools are active unless mutations are explicitly enabled
10
+ - **Mutation tools** — create projects, branches, builds, validation stamps/runs, promotion levels/runs, and build links (opt-in via `YONTRACK_MUTATIONS_ENABLED=true`)
11
+ - **Raw GraphQL access** — `graphql_query` tool for queries not covered by the dedicated tools
12
+ - **OAuth2 support** — required for claude.ai; enabled by setting two environment variables
13
+ - **Docker image** — published to Docker Hub on every release (`nemerosa/yontrack-mcp:latest`)
14
+ - **Helm chart** — OCI chart for Kubernetes deployments with full secret management support
15
+
16
+ ## Available Tools
17
+
18
+ By default only read-only tools are available. Set `YONTRACK_MUTATIONS_ENABLED=true` to also expose the tools marked with ✎ below.
19
+
20
+ | Category | Read-only tools | Mutation tools (✎) |
21
+ |---|---|---|
22
+ | Projects | `list_projects` | `create_project` |
23
+ | Branches | `list_branches` | `create_branch` |
24
+ | Builds | `list_builds`, `find_build`, `get_build_duration` | `create_build` |
25
+ | Validation stamps | `list_validation_stamps` | `create_validation_stamp` |
26
+ | Validation runs | `get_validation_runs` | `create_validation_run` |
27
+ | Promotion levels | `list_promotion_levels`, `get_promotion_level_image` | `create_promotion_level` |
28
+ | Promotion runs | `get_promotion_runs` | `promote_build` |
29
+ | Build links | `get_build_links` | `set_build_links` |
30
+ | Search | `search` | — |
31
+ | GraphQL | `graphql_query` ✝ | — |
32
+
33
+ > ✝ `graphql_query` is always registered but rejects requests whose query string starts with `mutation` when `YONTRACK_MUTATIONS_ENABLED` is not set.
34
+
35
+ ## Installation
36
+
37
+ ### Cursor
38
+
39
+ Create or edit `.cursor/mcp.json` in your project (or `~/.cursor/mcp.json` for a global configuration):
40
+
41
+ ```json
42
+ {
43
+ "mcpServers": {
44
+ "yontrack": {
45
+ "command": "npx",
46
+ "args": ["-y", "yontrack-mcp@latest", "stdio"],
47
+ "env": {
48
+ "YONTRACK_URL": "https://your-ontrack-instance",
49
+ "YONTRACK_TOKEN": "your-api-token"
50
+ }
51
+ }
52
+ }
53
+ }
54
+ ```
55
+
56
+ Cursor will start the server automatically as a subprocess when needed.
57
+
58
+ ### VSCode and IntelliJ
59
+
60
+ **VSCode** — create or edit `.vscode/mcp.json` in your project:
61
+
62
+ ```json
63
+ {
64
+ "servers": {
65
+ "yontrack": {
66
+ "type": "stdio",
67
+ "command": "npx",
68
+ "args": ["-y", "yontrack-mcp@latest", "stdio"],
69
+ "env": {
70
+ "YONTRACK_URL": "https://your-ontrack-instance",
71
+ "YONTRACK_TOKEN": "your-api-token"
72
+ }
73
+ }
74
+ }
75
+ }
76
+ ```
77
+
78
+ **IntelliJ IDEA** — open **Settings → Tools → AI Assistant → Model Context Protocol (MCP)**, click **+**, and configure:
79
+ - Command: `npx`
80
+ - Arguments: `-y yontrack-mcp@latest stdio`
81
+ - Environment: `YONTRACK_URL=https://your-ontrack-instance`, `YONTRACK_TOKEN=your-api-token`
82
+
83
+ ### Claude Desktop
84
+
85
+ Add the server to your Claude Desktop configuration file (`claude_desktop_config.json`):
86
+
87
+ ```json
88
+ {
89
+ "mcpServers": {
90
+ "yontrack": {
91
+ "command": "npx",
92
+ "args": ["-y", "yontrack-mcp@latest", "stdio"],
93
+ "env": {
94
+ "YONTRACK_URL": "https://your-ontrack-instance",
95
+ "YONTRACK_TOKEN": "your-api-token"
96
+ }
97
+ }
98
+ }
99
+ }
100
+ ```
101
+
102
+ ### Helm
103
+
104
+ A Helm chart is published to Docker Hub as an OCI image alongside every release:
105
+
106
+ ```
107
+ oci://registry-1.docker.io/nemerosa/yontrack-mcp-chart
108
+ ```
109
+
110
+ #### Install
111
+
112
+ ```bash
113
+ helm install yontrack-mcp \
114
+ oci://registry-1.docker.io/nemerosa/yontrack-mcp-chart \
115
+ --version <version> \
116
+ --set yontrack.url=https://your-ontrack-instance \
117
+ --set yontrack.token=your-api-token
118
+ ```
119
+
120
+ #### Configuration
121
+
122
+ | Value | Description | Default |
123
+ |---|---|---|
124
+ | `yontrack.url` | URL of the Yontrack instance | `""` |
125
+ | `yontrack.token` | Yontrack API token | `""` |
126
+ | `yontrack.mutationsEnabled` | Enable mutation tools (create/promote/link operations) | `false` |
127
+ | `oauth.serverUrl` | Public HTTPS URL of this server — enables OAuth2 when set with `oauth.authPassword` | `""` |
128
+ | `oauth.authPassword` | Password for the browser authorization form — enables OAuth2 when set with `oauth.serverUrl` | `""` |
129
+ | `persistence.enabled` | Create a PVC and mount it at `/data`; sets the clients file to `/data/clients.json` | `true` |
130
+ | `persistence.size` | PVC size | `10Mi` |
131
+ | `persistence.storageClass` | Storage class name (empty = cluster default) | `""` |
132
+ | `persistence.accessMode` | PVC access mode | `ReadWriteOnce` |
133
+ | `existingSecret` | Name of a pre-existing Secret (skips Secret creation) | `""` |
134
+ | `externalSecrets.enabled` | Render an `ExternalSecret` CRD instead of creating a Secret | `false` |
135
+ | `externalSecrets.refreshInterval` | How often ESO refreshes credentials from the backend | `"1h"` |
136
+ | `externalSecrets.secretStoreRef.name` | Name of the `SecretStore` or `ClusterSecretStore` | `""` |
137
+ | `externalSecrets.secretStoreRef.kind` | Kind of the store (`SecretStore` or `ClusterSecretStore`) | `SecretStore` |
138
+ | `externalSecrets.data.yontrackToken.key` | Backend path for `YONTRACK_TOKEN` | `""` |
139
+ | `externalSecrets.data.yontrackToken.property` | Sub-field within the backend entry (leave empty for the whole value) | `""` |
140
+ | `externalSecrets.data.authPassword.key` | Backend path for `YONTRACK_MCP_AUTH_PASSWORD` (OAuth2 only) | `""` |
141
+ | `externalSecrets.data.authPassword.property` | Sub-field within the backend entry (leave empty for the whole value) | `""` |
142
+ | `service.type` | Kubernetes Service type | `ClusterIP` |
143
+ | `service.port` | Service port | `3000` |
144
+ | `ingress.enabled` | Enable Ingress resource | `false` |
145
+ | `ingress.className` | Ingress class name | `""` |
146
+ | `ingress.hosts` | Ingress host rules | see `values.yaml` |
147
+ | `replicaCount` | Number of pod replicas | `1` |
148
+ | `image.tag` | Image tag override (defaults to chart `appVersion`) | `""` |
149
+ | `resources` | Pod resource requests/limits | `{}` |
150
+
151
+ #### Secret management
152
+
153
+ The chart supports three mutually exclusive modes for injecting credentials. `existingSecret` takes precedence over `externalSecrets.enabled`; if neither is set the chart creates the Secret itself.
154
+
155
+ **Chart-managed Secret (default)**
156
+
157
+ Credentials are taken from `yontrack.token` and `oauth.authPassword` in `values.yaml` and written into a Kubernetes Secret by the chart:
158
+
159
+ ```bash
160
+ helm install yontrack-mcp \
161
+ oci://registry-1.docker.io/nemerosa/yontrack-mcp-chart \
162
+ --version <version> \
163
+ --set yontrack.url=https://your-ontrack-instance \
164
+ --set yontrack.token=your-api-token
165
+ ```
166
+
167
+ **Existing Secret**
168
+
169
+ Point the chart to a pre-created Secret (e.g. created by Sealed Secrets or manually). The Secret must contain:
170
+
171
+ - `YONTRACK_TOKEN`
172
+ - `YONTRACK_MCP_AUTH_PASSWORD` (when OAuth2 is enabled)
173
+
174
+ ```bash
175
+ helm install yontrack-mcp \
176
+ oci://registry-1.docker.io/nemerosa/yontrack-mcp-chart \
177
+ --version <version> \
178
+ --set yontrack.url=https://your-ontrack-instance \
179
+ --set existingSecret=my-yontrack-secret
180
+ ```
181
+
182
+ **External Secrets Operator (ESO)**
183
+
184
+ When [ESO](https://external-secrets.io) is installed in the cluster, the chart can render an `ExternalSecret` CRD that instructs ESO to fetch credentials from an external backend (e.g. HashiCorp Vault, AWS Secrets Manager) and write them into a Kubernetes Secret automatically.
185
+
186
+ ```yaml
187
+ externalSecrets:
188
+ enabled: true
189
+ refreshInterval: "1h"
190
+ secretStoreRef:
191
+ name: vault-store # name of your SecretStore / ClusterSecretStore
192
+ kind: SecretStore # or ClusterSecretStore
193
+ data:
194
+ yontrackToken:
195
+ key: secret/yontrack # path in the backend
196
+ property: token # sub-field (omit if the whole entry is the value)
197
+ authPassword: # only needed when OAuth2 is enabled
198
+ key: secret/yontrack
199
+ property: authPassword
200
+ ```
201
+
202
+ The `ExternalSecret` targets the same Secret name the chart would otherwise create, so the Deployment references it transparently.
203
+
204
+ #### Ingress example
205
+
206
+ **Without OAuth2** — expose only the `/mcp` endpoint:
207
+
208
+ ```yaml
209
+ ingress:
210
+ enabled: true
211
+ className: nginx
212
+ hosts:
213
+ - host: mcp.example.com
214
+ paths:
215
+ - path: /mcp
216
+ pathType: Prefix
217
+ tls:
218
+ - secretName: mcp-tls
219
+ hosts:
220
+ - mcp.example.com
221
+ ```
222
+
223
+ **With OAuth2** — all paths must be reachable (OAuth2 discovery, `/authorize`, `/token`, etc.):
224
+
225
+ ```yaml
226
+ oauth:
227
+ serverUrl: https://mcp.example.com
228
+ authPassword: some-strong-password
229
+
230
+ ingress:
231
+ enabled: true
232
+ className: nginx
233
+ hosts:
234
+ - host: mcp.example.com
235
+ paths:
236
+ - path: /
237
+ pathType: Prefix
238
+ tls:
239
+ - secretName: mcp-tls
240
+ hosts:
241
+ - mcp.example.com
242
+ ```
243
+
244
+ The MCP endpoint is available at `https://mcp.example.com/mcp`.
245
+
246
+ ### Claude AI
247
+
248
+ Claude.ai requires OAuth2 for remote MCP servers. Start the server with the two additional OAuth environment variables:
249
+
250
+ ```bash
251
+ docker run -d --name yontrack-mcp \
252
+ -e YONTRACK_URL=https://your-ontrack-instance \
253
+ -e YONTRACK_TOKEN=your-api-token \
254
+ -e YONTRACK_MCP_SERVER_URL=https://mcp.example.com \
255
+ -e YONTRACK_MCP_AUTH_PASSWORD=some-strong-password \
256
+ -p 3000:3000 \
257
+ nemerosa/yontrack-mcp:latest
258
+ ```
259
+
260
+ Both `YONTRACK_MCP_SERVER_URL` and `YONTRACK_MCP_AUTH_PASSWORD` must be set together; either alone is ignored and the server starts without authentication.
261
+
262
+ **First-time connection flow:**
263
+
264
+ 1. In Claude.ai go to **Settings → Integrations** and add `https://mcp.example.com/mcp` as a new integration.
265
+ 2. Claude.ai auto-discovers the OAuth2 endpoints via `/.well-known/oauth-authorization-server`.
266
+ 3. Claude.ai registers itself as a client automatically (dynamic client registration — no manual client ID/secret needed).
267
+ 4. You are redirected to the server's authorization page where you enter the configured password.
268
+ 5. After approval, Claude.ai receives a Bearer token valid for 1 hour (refresh tokens last 30 days).
269
+ 6. Subsequent requests use the token silently; you re-authorize only after the refresh token expires.
270
+
271
+ > **HTTPS required.** `YONTRACK_MCP_SERVER_URL` must use `https://`. For local testing only, `http://localhost` is also accepted.
272
+
273
+ > **Tokens are in-memory.** All issued tokens are lost when the server restarts. Users will be prompted to re-authorize after a restart.
274
+
275
+ ## Configuration
276
+
277
+ ### Read-only mode
278
+
279
+ By default the server runs in read-only mode: only query tools are registered and no data can be modified. This is the recommended mode when the AI assistant only needs to inspect CI/CD state.
280
+
281
+ To also expose the mutation tools (create projects, branches, builds, validation stamps/runs, promotion levels/runs, and build links), set:
282
+
283
+ ```bash
284
+ YONTRACK_MUTATIONS_ENABLED=true
285
+ ```
286
+
287
+ ### Yontrack API token
288
+
289
+ An API token is required to authenticate against Yontrack. To generate one, log in to your Yontrack instance, open your user menu, and go to **User information > User profile**. Create a new token and copy it — it will not be shown again.
290
+
291
+ ### Environment variables
292
+
293
+ | Variable | Required | Default | Description |
294
+ |---|---|---|---|
295
+ | `YONTRACK_URL` | Yes | — | URL of the Yontrack instance (e.g. `https://ontrack.example.com`) |
296
+ | `YONTRACK_TOKEN` | Yes | — | API token for authenticating against Yontrack |
297
+ | `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. |
298
+ | `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
+ | `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
+ | `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. |
301
+ | `PORT` | No | `3000` | Port the HTTP server listens on |
302
+
303
+ ## Contributing
304
+
305
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, running tests, and interactive testing with MCP Inspector.
package/build/auth.js ADDED
@@ -0,0 +1,228 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import { InvalidGrantError, InvalidScopeError, InvalidTokenError, } from "@modelcontextprotocol/sdk/server/auth/errors.js";
4
+ function log(msg) {
5
+ process.stderr.write(`[oauth] ${msg}\n`);
6
+ }
7
+ const ACCESS_TOKEN_TTL_S = 3600; // 1 hour
8
+ const REFRESH_TOKEN_TTL_S = 30 * 24 * 3600; // 30 days
9
+ const AUTH_CODE_TTL_S = 600; // 10 minutes
10
+ // Path to persist registered clients across restarts.
11
+ // Configure via YONTRACK_MCP_CLIENTS_FILE; defaults to the current directory.
12
+ // In Kubernetes, mount a PVC at the chosen path to survive pod replacements.
13
+ const CLIENTS_FILE = process.env.YONTRACK_MCP_CLIENTS_FILE ?? "./yontrack-mcp-clients.json";
14
+ // Registered clients are persisted to disk so they survive restarts.
15
+ // Tokens and auth codes remain in-memory (short-lived, re-auth is fast).
16
+ const clients = new Map(loadClients());
17
+ const authCodes = new Map();
18
+ const accessTokens = new Map();
19
+ const refreshTokens = new Map();
20
+ function loadClients() {
21
+ try {
22
+ const raw = fs.readFileSync(CLIENTS_FILE, "utf-8");
23
+ const entries = JSON.parse(raw);
24
+ log(`Loaded ${entries.length} client(s) from ${CLIENTS_FILE}`);
25
+ return entries;
26
+ }
27
+ catch {
28
+ log(`No existing clients file at ${CLIENTS_FILE}, starting fresh`);
29
+ return [];
30
+ }
31
+ }
32
+ function persistClients() {
33
+ try {
34
+ fs.writeFileSync(CLIENTS_FILE, JSON.stringify([...clients]), "utf-8");
35
+ log(`Persisted ${clients.size} client(s) to ${CLIENTS_FILE}`);
36
+ }
37
+ catch (err) {
38
+ log(`ERROR: Failed to persist OAuth clients: ${err}`);
39
+ }
40
+ }
41
+ function generateToken() {
42
+ return crypto.randomBytes(32).toString("hex");
43
+ }
44
+ export function generateAuthCode(params) {
45
+ const code = generateToken();
46
+ authCodes.set(code, {
47
+ ...params,
48
+ expiresAt: Math.floor(Date.now() / 1000) + AUTH_CODE_TTL_S,
49
+ });
50
+ return code;
51
+ }
52
+ export const clientsStore = {
53
+ async getClient(clientId) {
54
+ const client = clients.get(clientId);
55
+ if (!client)
56
+ log(`Client not found: ${clientId}`);
57
+ return client;
58
+ },
59
+ async registerClient(client) {
60
+ clients.set(client.client_id, client);
61
+ log(`Registered client: ${client.client_id} (${client.client_name ?? "unnamed"})`);
62
+ persistClients();
63
+ return client;
64
+ },
65
+ };
66
+ function escHtml(s) {
67
+ return s
68
+ .replace(/&/g, "&amp;")
69
+ .replace(/"/g, "&quot;")
70
+ .replace(/</g, "&lt;")
71
+ .replace(/>/g, "&gt;");
72
+ }
73
+ export function renderAuthFormHtml(opts) {
74
+ return `<!DOCTYPE html>
75
+ <html lang="en">
76
+ <head>
77
+ <meta charset="UTF-8">
78
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
79
+ <title>Authorize — Yontrack MCP</title>
80
+ <style>
81
+ body { font-family: system-ui, sans-serif; max-width: 400px; margin: 80px auto; padding: 0 1rem; color: #111; }
82
+ h1 { font-size: 1.4rem; margin-bottom: .5rem; }
83
+ label { display: block; margin: 1.2rem 0 .3rem; font-weight: 600; }
84
+ input[type=password] { width: 100%; padding: .5rem .6rem; box-sizing: border-box; border: 1px solid #bbb; border-radius: 4px; font-size: 1rem; }
85
+ .actions { margin-top: 1.5rem; }
86
+ button { width: 100%; padding: .65rem; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; background: #0066cc; color: #fff; }
87
+ button:hover { background: #0052a3; }
88
+ .error { color: #c00; background: #fff0f0; border: 1px solid #fcc; border-radius: 4px; padding: .5rem .8rem; margin-top: 1rem; }
89
+ </style>
90
+ </head>
91
+ <body>
92
+ <h1>Authorize Access</h1>
93
+ <p><strong>${escHtml(opts.clientName)}</strong> is requesting access to Yontrack MCP.</p>
94
+ ${opts.error ? `<div class="error">${escHtml(opts.error)}</div>` : ""}
95
+ <form method="POST" action="${escHtml(opts.serverUrl)}/authorize/login">
96
+ <input type="hidden" name="client_id" value="${escHtml(opts.clientId)}">
97
+ <input type="hidden" name="redirect_uri" value="${escHtml(opts.redirectUri)}">
98
+ <input type="hidden" name="code_challenge" value="${escHtml(opts.codeChallenge)}">
99
+ <input type="hidden" name="scopes" value="${escHtml(opts.scopes)}">
100
+ <input type="hidden" name="state" value="${escHtml(opts.state)}">
101
+ <label for="password">Server password</label>
102
+ <input type="password" id="password" name="password" required autofocus>
103
+ <div class="actions">
104
+ <button type="submit">Authorize</button>
105
+ </div>
106
+ </form>
107
+ </body>
108
+ </html>`;
109
+ }
110
+ export function createOAuthProvider(serverUrl) {
111
+ return {
112
+ get clientsStore() {
113
+ return clientsStore;
114
+ },
115
+ async authorize(client, params, res) {
116
+ log(`Authorize request for client: ${client.client_id} (${client.client_name ?? "unnamed"})`);
117
+ const html = renderAuthFormHtml({
118
+ serverUrl,
119
+ clientName: client.client_name ?? client.client_id,
120
+ clientId: client.client_id,
121
+ redirectUri: params.redirectUri,
122
+ codeChallenge: params.codeChallenge,
123
+ scopes: (params.scopes ?? []).join(" "),
124
+ state: params.state ?? "",
125
+ });
126
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
127
+ res.end(html);
128
+ },
129
+ async challengeForAuthorizationCode(client, authorizationCode) {
130
+ const record = authCodes.get(authorizationCode);
131
+ if (!record || record.clientId !== client.client_id) {
132
+ log(`ERROR: Auth code not found or client mismatch for client ${client.client_id}`);
133
+ throw new InvalidGrantError("Invalid authorization code");
134
+ }
135
+ return record.codeChallenge;
136
+ },
137
+ async exchangeAuthorizationCode(client, authorizationCode) {
138
+ const record = authCodes.get(authorizationCode);
139
+ if (!record || record.clientId !== client.client_id) {
140
+ log(`ERROR: Auth code exchange failed — code not found for client ${client.client_id}`);
141
+ throw new InvalidGrantError("Invalid authorization code");
142
+ }
143
+ if (record.expiresAt < Date.now() / 1000) {
144
+ authCodes.delete(authorizationCode);
145
+ log(`ERROR: Auth code expired for client ${client.client_id}`);
146
+ throw new InvalidGrantError("Authorization code has expired");
147
+ }
148
+ authCodes.delete(authorizationCode);
149
+ log(`Auth code exchanged for client ${client.client_id}`);
150
+ const now = Math.floor(Date.now() / 1000);
151
+ const accessToken = generateToken();
152
+ const refreshToken = generateToken();
153
+ accessTokens.set(accessToken, {
154
+ clientId: client.client_id,
155
+ scopes: record.scopes,
156
+ expiresAt: now + ACCESS_TOKEN_TTL_S,
157
+ });
158
+ refreshTokens.set(refreshToken, {
159
+ clientId: client.client_id,
160
+ scopes: record.scopes,
161
+ expiresAt: now + REFRESH_TOKEN_TTL_S,
162
+ });
163
+ return {
164
+ access_token: accessToken,
165
+ token_type: "bearer",
166
+ expires_in: ACCESS_TOKEN_TTL_S,
167
+ refresh_token: refreshToken,
168
+ scope: record.scopes.join(" ") || undefined,
169
+ };
170
+ },
171
+ async exchangeRefreshToken(client, refreshToken, scopes) {
172
+ const record = refreshTokens.get(refreshToken);
173
+ if (!record || record.clientId !== client.client_id) {
174
+ log(`ERROR: Refresh token not found for client ${client.client_id} (likely lost after restart — user must re-authorize)`);
175
+ throw new InvalidGrantError("Invalid refresh token");
176
+ }
177
+ if (record.expiresAt < Date.now() / 1000) {
178
+ refreshTokens.delete(refreshToken);
179
+ log(`ERROR: Refresh token expired for client ${client.client_id}`);
180
+ throw new InvalidGrantError("Refresh token has expired");
181
+ }
182
+ log(`Refresh token exchanged for client ${client.client_id}`);
183
+ const requestedScopes = scopes?.length ? scopes : record.scopes;
184
+ const invalid = requestedScopes.filter((s) => !record.scopes.includes(s));
185
+ if (invalid.length > 0) {
186
+ throw new InvalidScopeError("Requested scopes exceed original grant");
187
+ }
188
+ refreshTokens.delete(refreshToken);
189
+ const now = Math.floor(Date.now() / 1000);
190
+ const newAccessToken = generateToken();
191
+ const newRefreshToken = generateToken();
192
+ accessTokens.set(newAccessToken, {
193
+ clientId: client.client_id,
194
+ scopes: requestedScopes,
195
+ expiresAt: now + ACCESS_TOKEN_TTL_S,
196
+ });
197
+ refreshTokens.set(newRefreshToken, {
198
+ clientId: client.client_id,
199
+ scopes: requestedScopes,
200
+ expiresAt: now + REFRESH_TOKEN_TTL_S,
201
+ });
202
+ return {
203
+ access_token: newAccessToken,
204
+ token_type: "bearer",
205
+ expires_in: ACCESS_TOKEN_TTL_S,
206
+ refresh_token: newRefreshToken,
207
+ scope: requestedScopes.join(" ") || undefined,
208
+ };
209
+ },
210
+ async verifyAccessToken(token) {
211
+ const record = accessTokens.get(token);
212
+ if (!record) {
213
+ log(`ERROR: Access token not found (likely lost after restart — user must re-authorize)`);
214
+ throw new InvalidTokenError("Invalid access token");
215
+ }
216
+ return {
217
+ token,
218
+ clientId: record.clientId,
219
+ scopes: record.scopes,
220
+ expiresAt: record.expiresAt,
221
+ };
222
+ },
223
+ async revokeToken(_client, request) {
224
+ accessTokens.delete(request.token);
225
+ refreshTokens.delete(request.token);
226
+ },
227
+ };
228
+ }
@@ -0,0 +1,5 @@
1
+ import { GraphQLClient } from "graphql-request";
2
+ import { config } from "./config.js";
3
+ export const gqlClient = new GraphQLClient(`${config.YONTRACK_URL}/graphql`, {
4
+ headers: { "X-Ontrack-Token": config.YONTRACK_TOKEN },
5
+ });
@@ -0,0 +1,23 @@
1
+ import { z } from "zod";
2
+ const ConfigSchema = z.object({
3
+ YONTRACK_URL: z.string().url(),
4
+ YONTRACK_TOKEN: z.string().min(1),
5
+ YONTRACK_MUTATIONS_ENABLED: z
6
+ .string()
7
+ .optional()
8
+ .transform((v) => v === "true"),
9
+ });
10
+ let _config;
11
+ try {
12
+ _config = ConfigSchema.parse(process.env);
13
+ }
14
+ catch (err) {
15
+ process.stderr.write("Missing required environment variables: YONTRACK_URL and YONTRACK_TOKEN\n");
16
+ process.exit(1);
17
+ }
18
+ export const config = _config;
19
+ export const mutationsEnabled = _config.YONTRACK_MUTATIONS_ENABLED;
20
+ // OAuth2 is enabled when both SERVER_URL and AUTH_PASSWORD are provided
21
+ const serverUrl = process.env.YONTRACK_MCP_SERVER_URL;
22
+ const authPassword = process.env.YONTRACK_MCP_AUTH_PASSWORD;
23
+ export const oauthConfig = serverUrl && authPassword ? { serverUrl, authPassword } : null;