yontrack-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +305 -0
- package/build/auth.js +228 -0
- package/build/client.js +5 -0
- package/build/config.js +23 -0
- package/build/index.js +127 -0
- package/build/server.js +21 -0
- package/build/test/helpers.js +17 -0
- package/build/test/setup.js +5 -0
- package/build/tools/branches.js +55 -0
- package/build/tools/branches.test.js +82 -0
- package/build/tools/build-links.js +73 -0
- package/build/tools/build-links.test.js +84 -0
- package/build/tools/builds.js +139 -0
- package/build/tools/builds.test.js +156 -0
- package/build/tools/graphql.js +31 -0
- package/build/tools/graphql.test.js +82 -0
- package/build/tools/index.js +23 -0
- package/build/tools/projects.js +47 -0
- package/build/tools/projects.test.js +76 -0
- package/build/tools/promotion-levels.js +83 -0
- package/build/tools/promotion-levels.test.js +118 -0
- package/build/tools/promotion-runs.js +78 -0
- package/build/tools/promotion-runs.test.js +82 -0
- package/build/tools/search.js +27 -0
- package/build/tools/search.test.js +49 -0
- package/build/tools/validation-runs.js +93 -0
- package/build/tools/validation-runs.test.js +82 -0
- package/build/tools/validation-stamps.js +63 -0
- package/build/tools/validation-stamps.test.js +76 -0
- package/build/utils.js +16 -0
- package/package.json +37 -0
- package/yontrack.graphql +11937 -0
package/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, "&")
|
|
69
|
+
.replace(/"/g, """)
|
|
70
|
+
.replace(/</g, "<")
|
|
71
|
+
.replace(/>/g, ">");
|
|
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
|
+
}
|
package/build/client.js
ADDED
package/build/config.js
ADDED
|
@@ -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;
|