@gudlab/gud-api-mcp 0.1.2

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 ADDED
@@ -0,0 +1,31 @@
1
+ Gud API - Proprietary License
2
+
3
+ Copyright (c) 2024 GudLab. All rights reserved.
4
+
5
+ This software and associated documentation files (the "Software") are the
6
+ proprietary property of GudLab. The Software is licensed, not sold.
7
+
8
+ PERMITTED USE:
9
+ You may install and use the Software as a VS Code / OpenVSX extension for
10
+ personal or commercial development purposes, subject to the terms of the
11
+ applicable free or paid plan.
12
+
13
+ RESTRICTIONS:
14
+ You may not, without prior written consent from GudLab:
15
+ - Copy, modify, merge, or create derivative works of the Software
16
+ - Distribute, sublicense, sell, or otherwise transfer the Software
17
+ - Reverse engineer, decompile, or disassemble the Software
18
+ - Remove or alter any proprietary notices or labels
19
+
20
+ PAID FEATURES:
21
+ Certain features require a valid license key. Use of paid features without
22
+ a valid license is prohibited.
23
+
24
+ DISCLAIMER:
25
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
27
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
28
+ GUDLAB BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY ARISING FROM
29
+ THE USE OF THE SOFTWARE.
30
+
31
+ For licensing inquiries: hello@gudlab.org
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # @gudlab/gud-api-mcp — Gud API for AI agents
2
+
3
+ A [Model Context Protocol](https://modelcontextprotocol.io) server that lets any
4
+ MCP-capable AI agent — **Claude Code, Cursor, Windsurf, Codex, Cline, Zed**, and
5
+ others — **create, run, and save API requests and collections** as real Gud API
6
+ files your team can open in any VS Code-compatible editor.
7
+
8
+ When an agent builds an endpoint, it registers the request, runs it, and captures
9
+ the response as an example. The collection is written to your project's
10
+ `.gud-api/` folder — the same files the [Gud API extension](https://marketplace.visualstudio.com/items?itemName=gudlab.gud-api)
11
+ reads. Open your editor and every endpoint the agent built is in your sidebar,
12
+ ready to click and re-run. It's git-committable, so it travels with the PR.
13
+
14
+ Neither Postman nor Bruno occupies this lane: agent-written, editor-native,
15
+ git-friendly, no cloud account.
16
+
17
+ ## Works with
18
+
19
+ - **MCP clients** (this server): Claude Code, Cursor, Windsurf, Codex, Cline, Zed,
20
+ Continue — any tool that speaks the Model Context Protocol.
21
+ - **Editors** (the companion Gud API extension that reads the files): VS Code, plus
22
+ any VS Code-compatible editor that installs from
23
+ [Open VSX](https://open-vsx.org/extension/gudlab/gud-api) — Cursor, Windsurf,
24
+ VSCodium, Antigravity, Trae, and more.
25
+
26
+ The server itself is editor-agnostic — it just writes files. You don't need the
27
+ extension to use it, but the extension is what makes the collections clickable.
28
+
29
+ ## Install
30
+
31
+ The server runs via `npx` — no global install needed. It's the same config for
32
+ every MCP client; only the file it lives in differs.
33
+
34
+ Add this to your client's MCP config (`.mcp.json` for Claude Code, `~/.cursor/mcp.json`
35
+ for Cursor, the Windsurf/Codex/Cline equivalent, etc.):
36
+
37
+ ```json
38
+ {
39
+ "mcpServers": {
40
+ "gud-api": {
41
+ "command": "npx",
42
+ "args": ["-y", "@gudlab/gud-api-mcp", "--project", "."]
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ `--project .` scopes all reads/writes to the current project's `.gud-api/`
49
+ folder. Pass an absolute path to target a different project.
50
+
51
+ ## What the agent can do
52
+
53
+ | Tool | Purpose |
54
+ |------|---------|
55
+ | `list_collections` | List collections with request counts and folders |
56
+ | `get_collection` | Full contents of one collection (bodies + example summaries) |
57
+ | `create_collection` | Create a collection in `.gud-api/collections` |
58
+ | `upsert_request` | Create/update a request (matched by name), nest under a folder path |
59
+ | `send_request` | Execute a request, resolve `{{variables}}`, run tests, optionally capture an example |
60
+ | `delete_request` | Remove a saved request |
61
+ | `upsert_environment` | Create/update a named variable set (base_url, tokens), optionally set active |
62
+ | `get_active_environment` | Read active variables — secret-looking values are masked |
63
+
64
+ ## How it fits the Gud API format
65
+
66
+ Files are written byte-compatible with the extension (v0.5.7+): slug filenames
67
+ (`payments-api.json`), canonical key order, `schemaVersion`, trailing newline.
68
+ The MCP server targets **workspace scope** — files live in your project and are
69
+ never cloud-synced, so agent output stays local and reviewable.
70
+
71
+ Captured responses are stored as `examples[]` on each request (max 5). The
72
+ extension renders these read-only so you can see exactly what the API returned
73
+ when the agent tested it.
74
+
75
+ ## Security notes
76
+
77
+ - **`send_request` executes arbitrary HTTP** — no more than the `curl` access an
78
+ agent already has, but be aware of it.
79
+ - **Secret masking**: `get_active_environment` masks values whose keys look like
80
+ secrets (`token`, `key`, `secret`, `password`, …). `send_request` still resolves
81
+ the real values server-side, so the agent can *use* a credential without
82
+ *reading* it into its context. This is heuristic, not a guarantee — don't put
83
+ production credentials in an agent-visible environment.
84
+ - **Cookies are in-memory per session** — an agent never inherits your browser
85
+ session cookies.
86
+ - **Writes are confined to `--project`** — collection/environment names are
87
+ slugified, so a name can't traverse out of the `.gud-api/` folder.
88
+
89
+ ## Links
90
+
91
+ - Docs: <https://gudapi-docs.gudlab.org/guide/ai-agents>
92
+ - Gud API extension: [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=gudlab.gud-api) · [Open VSX](https://open-vsx.org/extension/gudlab/gud-api)
93
+ - Issues: <https://github.com/gudlab/gud-api/issues>
94
+
95
+ ## License
96
+
97
+ Proprietary — see the LICENSE file. Free to install and use; redistribution and
98
+ modification are restricted.
package/dist/server.js ADDED
@@ -0,0 +1,989 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire as _cr } from 'module'; const require = _cr(import.meta.url);
3
+
4
+ // src/server.ts
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+ import { randomUUID as randomUUID3 } from "crypto";
8
+ import { z } from "zod";
9
+
10
+ // src/store.ts
11
+ import * as fs from "fs/promises";
12
+ import * as path from "path";
13
+ import { randomUUID } from "crypto";
14
+ var COLLECTION_DIR = ".gud-api/collections";
15
+ var ENV_DIR = ".gud-api/environments";
16
+ var COLLECTION_KEY_ORDER = ["schemaVersion", "id", "name", "variables", "folders", "requests", "createdAt", "updatedAt"];
17
+ var REQUEST_KEY_ORDER = ["id", "name", "method", "url", "headers", "body", "bodyType", "auth", "tests", "preRequest", "extractions", "examples"];
18
+ var FOLDER_KEY_ORDER = ["id", "name", "requests", "folders"];
19
+ var ENV_KEY_ORDER = ["schemaVersion", "id", "name", "variables", "createdAt", "updatedAt"];
20
+ function orderKeys(obj, priority) {
21
+ const out = {};
22
+ for (const k of priority)
23
+ if (obj[k] !== void 0)
24
+ out[k] = obj[k];
25
+ for (const k of Object.keys(obj).sort())
26
+ if (!(k in out) && obj[k] !== void 0)
27
+ out[k] = obj[k];
28
+ return out;
29
+ }
30
+ function orderRequest(req) {
31
+ const { timingHistory: _t, ...rest } = req;
32
+ return orderKeys(rest, REQUEST_KEY_ORDER);
33
+ }
34
+ function orderFolder(folder) {
35
+ const ordered = orderKeys(folder, FOLDER_KEY_ORDER);
36
+ ordered.requests = folder.requests.map(orderRequest);
37
+ ordered.folders = folder.folders.map(orderFolder);
38
+ return ordered;
39
+ }
40
+ function serializeCollection(collection) {
41
+ const { source: _s, ownerId: _o, teamId: _tid, shared: _sh, permission: _p, ...persisted } = collection;
42
+ const ordered = orderKeys({ schemaVersion: 1, ...persisted }, COLLECTION_KEY_ORDER);
43
+ ordered.requests = collection.requests.map(orderRequest);
44
+ ordered.folders = collection.folders.map(orderFolder);
45
+ return JSON.stringify(ordered, null, 2) + "\n";
46
+ }
47
+ function serializeEnvironment(env) {
48
+ const ordered = orderKeys({ schemaVersion: 1, ...env }, ENV_KEY_ORDER);
49
+ return JSON.stringify(ordered, null, 2) + "\n";
50
+ }
51
+ function slugify(name) {
52
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "collection";
53
+ }
54
+ async function fileExists(p) {
55
+ try {
56
+ await fs.stat(p);
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+ function normalizeFolders(folders) {
63
+ if (!folders)
64
+ return [];
65
+ for (const f of folders) {
66
+ if (!Array.isArray(f.folders))
67
+ f.folders = [];
68
+ if (!Array.isArray(f.requests))
69
+ f.requests = [];
70
+ normalizeFolders(f.folders);
71
+ }
72
+ return folders;
73
+ }
74
+ var GudApiStore = class {
75
+ constructor(projectRoot) {
76
+ this.projectRoot = projectRoot;
77
+ }
78
+ /** id → on-disk filename, learned on read so saves land on the same file. */
79
+ collectionFiles = /* @__PURE__ */ new Map();
80
+ envFiles = /* @__PURE__ */ new Map();
81
+ gudApiDir() {
82
+ return path.join(this.projectRoot, ".gud-api");
83
+ }
84
+ colDir() {
85
+ return path.join(this.projectRoot, COLLECTION_DIR);
86
+ }
87
+ envDir() {
88
+ return path.join(this.projectRoot, ENV_DIR);
89
+ }
90
+ async loadCollections() {
91
+ let entries;
92
+ try {
93
+ entries = await fs.readdir(this.colDir());
94
+ } catch {
95
+ return [];
96
+ }
97
+ const out = [];
98
+ for (const name of entries) {
99
+ if (!name.endsWith(".json"))
100
+ continue;
101
+ try {
102
+ const text = await fs.readFile(path.join(this.colDir(), name), "utf-8");
103
+ const parsed = JSON.parse(text);
104
+ parsed.folders = normalizeFolders(parsed.folders);
105
+ if (!Array.isArray(parsed.requests))
106
+ parsed.requests = [];
107
+ if (!Array.isArray(parsed.variables))
108
+ parsed.variables = [];
109
+ parsed.source = "workspace";
110
+ this.collectionFiles.set(parsed.id, name);
111
+ out.push(parsed);
112
+ } catch {
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+ async findCollection(nameOrId) {
118
+ const all = await this.loadCollections();
119
+ return all.find((c) => c.id === nameOrId) ?? all.find((c) => c.name === nameOrId);
120
+ }
121
+ async resolveCollectionFilename(c) {
122
+ const registered = this.collectionFiles.get(c.id);
123
+ if (registered)
124
+ return registered;
125
+ const legacy = `${c.id}.json`;
126
+ if (await fileExists(path.join(this.colDir(), legacy))) {
127
+ this.collectionFiles.set(c.id, legacy);
128
+ return legacy;
129
+ }
130
+ let candidate = `${slugify(c.name)}.json`;
131
+ if (await fileExists(path.join(this.colDir(), candidate))) {
132
+ candidate = `${slugify(c.name)}-${c.id.slice(0, 8)}.json`;
133
+ }
134
+ this.collectionFiles.set(c.id, candidate);
135
+ return candidate;
136
+ }
137
+ /**
138
+ * Write a `.gud-api/README.md` the first time we touch the folder. This is
139
+ * how a human who has NO Gud API extension installed learns that the files
140
+ * an agent just created aren't broken — they're collections, and here's how
141
+ * to view them. It's the durable, self-explanatory fallback: it shows in the
142
+ * file tree, in git, and any editor renders it. Only written if absent, so it
143
+ * never clobbers a customized one.
144
+ */
145
+ async ensureReadme() {
146
+ const readmePath = path.join(this.gudApiDir(), "README.md");
147
+ if (await fileExists(readmePath))
148
+ return;
149
+ await fs.mkdir(this.gudApiDir(), { recursive: true });
150
+ await fs.writeFile(readmePath, GUD_API_README, "utf-8");
151
+ }
152
+ async saveCollection(c) {
153
+ await fs.mkdir(this.colDir(), { recursive: true });
154
+ await this.ensureReadme();
155
+ c.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
156
+ const filename = await this.resolveCollectionFilename(c);
157
+ await fs.writeFile(path.join(this.colDir(), filename), serializeCollection(c), "utf-8");
158
+ }
159
+ async createCollection(name, variables = []) {
160
+ const now = (/* @__PURE__ */ new Date()).toISOString();
161
+ const c = {
162
+ schemaVersion: 1,
163
+ id: randomUUID(),
164
+ name,
165
+ folders: [],
166
+ requests: [],
167
+ variables,
168
+ createdAt: now,
169
+ updatedAt: now,
170
+ source: "workspace"
171
+ };
172
+ await this.saveCollection(c);
173
+ return c;
174
+ }
175
+ async loadEnvironments() {
176
+ let entries;
177
+ try {
178
+ entries = await fs.readdir(this.envDir());
179
+ } catch {
180
+ return [];
181
+ }
182
+ const out = [];
183
+ for (const name of entries) {
184
+ if (!name.endsWith(".json"))
185
+ continue;
186
+ try {
187
+ const text = await fs.readFile(path.join(this.envDir(), name), "utf-8");
188
+ const parsed = JSON.parse(text);
189
+ if (!Array.isArray(parsed.variables))
190
+ parsed.variables = [];
191
+ this.envFiles.set(parsed.id, name);
192
+ out.push(parsed);
193
+ } catch {
194
+ }
195
+ }
196
+ return out;
197
+ }
198
+ async resolveEnvFilename(e) {
199
+ const registered = this.envFiles.get(e.id);
200
+ if (registered)
201
+ return registered;
202
+ const legacy = `${e.id}.json`;
203
+ if (await fileExists(path.join(this.envDir(), legacy))) {
204
+ this.envFiles.set(e.id, legacy);
205
+ return legacy;
206
+ }
207
+ let candidate = `${slugify(e.name)}.json`;
208
+ if (await fileExists(path.join(this.envDir(), candidate))) {
209
+ candidate = `${slugify(e.name)}-${e.id.slice(0, 8)}.json`;
210
+ }
211
+ this.envFiles.set(e.id, candidate);
212
+ return candidate;
213
+ }
214
+ async saveEnvironment(e) {
215
+ await fs.mkdir(this.envDir(), { recursive: true });
216
+ await this.ensureReadme();
217
+ e.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
218
+ const filename = await this.resolveEnvFilename(e);
219
+ await fs.writeFile(path.join(this.envDir(), filename), serializeEnvironment(e), "utf-8");
220
+ }
221
+ async upsertEnvironment(name, variables) {
222
+ const all = await this.loadEnvironments();
223
+ const existing = all.find((e) => e.name === name);
224
+ const now = (/* @__PURE__ */ new Date()).toISOString();
225
+ const env = existing ? { ...existing, variables } : { id: randomUUID(), name, variables, createdAt: now, updatedAt: now };
226
+ await this.saveEnvironment(env);
227
+ return env;
228
+ }
229
+ };
230
+ var GUD_API_README = `# Gud API collections
231
+
232
+ These files were created by an AI agent through the **Gud API MCP server**
233
+ (\`@gudlab/gud-api-mcp\`). They are standard Gud API collections \u2014 plain JSON, safe to
234
+ read and to commit to git. **Nothing is broken** if you don't see a UI yet: you
235
+ just need the Gud API extension to view and run them.
236
+
237
+ ## View & run them
238
+
239
+ Install the **Gud API** extension in your editor, then open the Gud API view in
240
+ the activity bar:
241
+
242
+ - **VS Code** \u2014 https://marketplace.visualstudio.com/items?itemName=gudlab.gud-api
243
+ - **Cursor, Windsurf, VSCodium, Antigravity, and other VS Code-compatible
244
+ editors** \u2014 https://open-vsx.org/extension/gudlab/gud-api (usually the default
245
+ extension source in those editors)
246
+
247
+ Once installed, your collections appear in the **Gud API sidebar**. Click any
248
+ request to open it and press **Send** to run it. Responses the agent captured
249
+ show on the request's **Examples** tab.
250
+
251
+ ## Don't want the extension?
252
+
253
+ These are readable JSON files under \`collections/\` and \`environments/\`. You can
254
+ inspect them directly, or feed a request to any HTTP client \u2014 but the extension
255
+ is what makes them clickable and runnable.
256
+
257
+ ## Learn more
258
+
259
+ https://gudapi-docs.gudlab.org/guide/ai-agents
260
+
261
+ _This file was generated automatically and is safe to delete or edit \u2014 it won't
262
+ be recreated unless it's missing._
263
+ `;
264
+
265
+ // src/tree.ts
266
+ import { randomUUID as randomUUID2 } from "crypto";
267
+ var MAX_EXAMPLES = 5;
268
+ function ensureFolderPath(collection, folderPath) {
269
+ if (!folderPath || folderPath.length === 0)
270
+ return null;
271
+ let list = collection.folders;
272
+ let current = null;
273
+ for (const name of folderPath) {
274
+ let next = list.find((f) => f.name === name);
275
+ if (!next) {
276
+ next = { id: randomUUID2(), name, requests: [], folders: [] };
277
+ list.push(next);
278
+ }
279
+ current = next;
280
+ list = next.folders;
281
+ }
282
+ return current;
283
+ }
284
+ function findRequestDeep(collection, idOrName) {
285
+ const scan = (list) => {
286
+ const byId = list.find((r) => r.id === idOrName);
287
+ if (byId)
288
+ return { request: byId, container: list };
289
+ const byName = list.find((r) => r.name === idOrName);
290
+ if (byName)
291
+ return { request: byName, container: list };
292
+ return null;
293
+ };
294
+ const rootHit = scan(collection.requests);
295
+ if (rootHit)
296
+ return rootHit;
297
+ const walk = (folders) => {
298
+ for (const f of folders) {
299
+ const hit = scan(f.requests);
300
+ if (hit)
301
+ return hit;
302
+ const nested = walk(f.folders);
303
+ if (nested)
304
+ return nested;
305
+ }
306
+ return null;
307
+ };
308
+ return walk(collection.folders);
309
+ }
310
+ function upsertRequest(collection, folderPath, incoming) {
311
+ const folder = ensureFolderPath(collection, folderPath);
312
+ const container = folder ? folder.requests : collection.requests;
313
+ const existing = incoming.id && container.find((r) => r.id === incoming.id) || container.find((r) => r.name === incoming.name);
314
+ if (existing) {
315
+ const preservedExamples = existing.examples;
316
+ Object.assign(existing, incoming, { id: existing.id });
317
+ if (preservedExamples && !incoming.examples)
318
+ existing.examples = preservedExamples;
319
+ return { requestId: existing.id, created: false };
320
+ }
321
+ const req = { ...incoming, id: incoming.id ?? randomUUID2() };
322
+ container.push(req);
323
+ return { requestId: req.id, created: true };
324
+ }
325
+ function deleteRequest(collection, idOrName) {
326
+ const hit = findRequestDeep(collection, idOrName);
327
+ if (!hit)
328
+ return false;
329
+ const idx = hit.container.indexOf(hit.request);
330
+ if (idx >= 0) {
331
+ hit.container.splice(idx, 1);
332
+ return true;
333
+ }
334
+ return false;
335
+ }
336
+ function addExample(request, example) {
337
+ const list = request.examples ?? [];
338
+ list.push(example);
339
+ request.examples = list.slice(-MAX_EXAMPLES);
340
+ }
341
+ function countRequests(collection) {
342
+ let n = collection.requests.length;
343
+ const walk = (folders) => {
344
+ for (const f of folders) {
345
+ n += f.requests.length;
346
+ walk(f.folders);
347
+ }
348
+ };
349
+ walk(collection.folders);
350
+ return n;
351
+ }
352
+ function folderPaths(collection) {
353
+ const out = [];
354
+ const walk = (folders, prefix) => {
355
+ for (const f of folders) {
356
+ const p = [...prefix, f.name];
357
+ out.push(p.join(" / "));
358
+ walk(f.folders, p);
359
+ }
360
+ };
361
+ walk(collection.folders, []);
362
+ return out;
363
+ }
364
+
365
+ // src/util.ts
366
+ var SECRET_RE = /(token|key|secret|password|passwd|pwd|auth|bearer|credential|api[-_]?key)/i;
367
+ function isSecretKey(key) {
368
+ return SECRET_RE.test(key);
369
+ }
370
+ function maskSecrets(vars) {
371
+ return vars.map((v) => {
372
+ const masked = isSecretKey(v.key) && v.value.length > 0;
373
+ return {
374
+ key: v.key,
375
+ value: masked ? "\u2022\u2022\u2022\u2022 (set)" : v.value,
376
+ enabled: v.enabled,
377
+ masked
378
+ };
379
+ });
380
+ }
381
+ function truncateBody(body, cap) {
382
+ const fullLength = body.length;
383
+ if (fullLength <= cap)
384
+ return { body, truncated: false, fullLength };
385
+ return {
386
+ body: body.slice(0, cap) + `
387
+ \u2026[truncated ${fullLength - cap} of ${fullLength} chars]`,
388
+ truncated: true,
389
+ fullLength
390
+ };
391
+ }
392
+
393
+ // src/core/services/httpClient.ts
394
+ import * as fs2 from "fs";
395
+ import * as path2 from "path";
396
+
397
+ // src/core/services/variableResolver.ts
398
+ var VAR_PATTERN = /\{\{([^}]+)\}\}/g;
399
+ var MAX_PASSES = 5;
400
+ function resolveRequest(url, headers, body, collectionVars, envVars, globalVars = []) {
401
+ const lookup = buildLookup(globalVars, collectionVars, envVars);
402
+ const replacer = (input) => multiPassResolve(input, lookup);
403
+ const resolvedHeaders = {};
404
+ for (const [k, v] of Object.entries(headers)) {
405
+ resolvedHeaders[replacer(k)] = replacer(v);
406
+ }
407
+ return {
408
+ url: replacer(url),
409
+ headers: resolvedHeaders,
410
+ body: replacer(body)
411
+ };
412
+ }
413
+ function multiPassResolve(input, lookup) {
414
+ let result = input;
415
+ for (let pass = 0; pass < MAX_PASSES; pass++) {
416
+ const next = result.replace(VAR_PATTERN, (match, key) => {
417
+ const trimmed = key.trim();
418
+ return lookup.has(trimmed) ? lookup.get(trimmed) : match;
419
+ });
420
+ if (next === result)
421
+ break;
422
+ result = next;
423
+ }
424
+ return result;
425
+ }
426
+ function buildLookup(globalVars, collectionVars, envVars) {
427
+ const map = /* @__PURE__ */ new Map();
428
+ for (const v of globalVars) {
429
+ if (v.enabled && v.key.trim()) {
430
+ map.set(v.key.trim(), v.value);
431
+ }
432
+ }
433
+ for (const v of collectionVars) {
434
+ if (v.enabled && v.key.trim()) {
435
+ map.set(v.key.trim(), v.value);
436
+ }
437
+ }
438
+ for (const v of envVars) {
439
+ if (v.enabled && v.key.trim()) {
440
+ map.set(v.key.trim(), v.value);
441
+ }
442
+ }
443
+ return map;
444
+ }
445
+
446
+ // src/core/services/cookieJar.ts
447
+ var jar = /* @__PURE__ */ new Map();
448
+ function extractDomain(urlStr) {
449
+ try {
450
+ return new URL(urlStr).hostname;
451
+ } catch {
452
+ return "";
453
+ }
454
+ }
455
+ function parseCookieString(setCookieStr, requestUrl) {
456
+ const parts = setCookieStr.split(";").map((s) => s.trim());
457
+ if (parts.length === 0)
458
+ return null;
459
+ const [nameValue, ...attrs] = parts;
460
+ const eqIdx = nameValue.indexOf("=");
461
+ if (eqIdx === -1)
462
+ return null;
463
+ const name = nameValue.slice(0, eqIdx).trim();
464
+ const value = nameValue.slice(eqIdx + 1).trim();
465
+ if (!name)
466
+ return null;
467
+ const cookie = {
468
+ name,
469
+ value,
470
+ domain: extractDomain(requestUrl),
471
+ path: "/",
472
+ httpOnly: false,
473
+ secure: false,
474
+ enabled: true
475
+ };
476
+ for (const attr of attrs) {
477
+ const lower = attr.toLowerCase();
478
+ if (lower.startsWith("domain=")) {
479
+ cookie.domain = attr.slice(7).trim().replace(/^\./, "");
480
+ } else if (lower.startsWith("path=")) {
481
+ cookie.path = attr.slice(5).trim();
482
+ } else if (lower.startsWith("expires=")) {
483
+ cookie.expires = attr.slice(8).trim();
484
+ } else if (lower === "httponly") {
485
+ cookie.httpOnly = true;
486
+ } else if (lower === "secure") {
487
+ cookie.secure = true;
488
+ }
489
+ }
490
+ return cookie;
491
+ }
492
+ function capture(rawSetCookies, requestUrl) {
493
+ for (const raw of rawSetCookies) {
494
+ const cookie = parseCookieString(raw, requestUrl);
495
+ if (!cookie)
496
+ continue;
497
+ const domain = cookie.domain;
498
+ const existing = jar.get(domain) ?? [];
499
+ const idx = existing.findIndex((c) => c.name === cookie.name && c.path === cookie.path);
500
+ if (idx >= 0) {
501
+ existing[idx] = cookie;
502
+ } else {
503
+ existing.push(cookie);
504
+ }
505
+ jar.set(domain, existing);
506
+ }
507
+ }
508
+ function getCookieHeader(requestUrl) {
509
+ const domain = extractDomain(requestUrl);
510
+ if (!domain)
511
+ return null;
512
+ const pairs = [];
513
+ for (const [cookieDomain, cookies] of jar) {
514
+ if (domain === cookieDomain || domain.endsWith("." + cookieDomain)) {
515
+ for (const c of cookies) {
516
+ if (!c.enabled)
517
+ continue;
518
+ if (c.expires && new Date(c.expires).getTime() < Date.now())
519
+ continue;
520
+ pairs.push(`${c.name}=${c.value}`);
521
+ }
522
+ }
523
+ }
524
+ return pairs.length > 0 ? pairs.join("; ") : null;
525
+ }
526
+
527
+ // src/core/services/httpClient.ts
528
+ async function execute(request, collectionVars = [], envVars = [], multipartFields, globalVars = []) {
529
+ const resolved = resolveRequest(
530
+ request.url,
531
+ request.headers,
532
+ request.body,
533
+ collectionVars,
534
+ envVars,
535
+ globalVars
536
+ );
537
+ const init = {
538
+ method: request.method,
539
+ headers: { ...resolved.headers }
540
+ };
541
+ const cookieHeader = getCookieHeader(resolved.url);
542
+ if (cookieHeader) {
543
+ init.headers["Cookie"] = cookieHeader;
544
+ }
545
+ if (request.method !== "GET" && request.method !== "HEAD") {
546
+ if (multipartFields && multipartFields.length > 0) {
547
+ const formData = new FormData();
548
+ for (const field of multipartFields) {
549
+ if (!field.key.trim())
550
+ continue;
551
+ if (field.type === "file" && field.value) {
552
+ try {
553
+ const buffer = fs2.readFileSync(field.value);
554
+ const blob = new Blob([buffer]);
555
+ const fileName = field.fileName || path2.basename(field.value);
556
+ formData.append(field.key.trim(), blob, fileName);
557
+ } catch {
558
+ }
559
+ } else {
560
+ formData.append(field.key.trim(), field.value);
561
+ }
562
+ }
563
+ init.body = formData;
564
+ delete init.headers["Content-Type"];
565
+ } else if (resolved.body) {
566
+ init.body = resolved.body;
567
+ }
568
+ }
569
+ const start = performance.now();
570
+ try {
571
+ const res = await fetch(resolved.url, init);
572
+ const body = await res.text();
573
+ const timeMs = Math.round(performance.now() - start);
574
+ const headers = {};
575
+ res.headers.forEach((value, key) => {
576
+ headers[key] = value;
577
+ });
578
+ try {
579
+ const setCookies = res.headers.getSetCookie?.() ?? [];
580
+ if (setCookies.length > 0) {
581
+ capture(setCookies, resolved.url);
582
+ }
583
+ } catch {
584
+ }
585
+ return {
586
+ status: res.status,
587
+ statusText: res.statusText,
588
+ headers,
589
+ body,
590
+ timeMs,
591
+ size: new TextEncoder().encode(body).byteLength,
592
+ resolvedUrl: resolved.url
593
+ };
594
+ } catch (err) {
595
+ const timeMs = Math.round(performance.now() - start);
596
+ const message = err instanceof Error ? err.message : "Unknown error";
597
+ return {
598
+ status: 0,
599
+ statusText: "Error",
600
+ headers: {},
601
+ body: message,
602
+ timeMs,
603
+ size: 0,
604
+ resolvedUrl: resolved.url
605
+ };
606
+ }
607
+ }
608
+
609
+ // src/core/services/jsonPath.ts
610
+ function evaluateJsonPath(body, path3) {
611
+ let obj;
612
+ try {
613
+ obj = JSON.parse(body);
614
+ } catch {
615
+ return void 0;
616
+ }
617
+ const segments = [];
618
+ for (const part of path3.split(".")) {
619
+ const bracketMatch = part.match(/^([^[]+)(?:\[(\d+)\])?$/);
620
+ if (bracketMatch) {
621
+ if (bracketMatch[1])
622
+ segments.push(bracketMatch[1]);
623
+ if (bracketMatch[2] !== void 0)
624
+ segments.push(bracketMatch[2]);
625
+ } else {
626
+ segments.push(part);
627
+ }
628
+ }
629
+ let current = obj;
630
+ for (const seg of segments) {
631
+ if (current === null || current === void 0)
632
+ return void 0;
633
+ if (typeof current !== "object")
634
+ return void 0;
635
+ current = current[seg];
636
+ }
637
+ if (current === void 0 || current === null)
638
+ return void 0;
639
+ if (typeof current === "object")
640
+ return JSON.stringify(current);
641
+ return String(current);
642
+ }
643
+
644
+ // src/core/services/testRunner.ts
645
+ function getActualValue(response, assertion) {
646
+ switch (assertion.source) {
647
+ case "status":
648
+ return response.status.toString();
649
+ case "time":
650
+ return response.timeMs.toString();
651
+ case "body":
652
+ return response.body;
653
+ case "header":
654
+ return assertion.property ? response.headers[assertion.property.toLowerCase()] : void 0;
655
+ case "jsonpath":
656
+ return assertion.property ? evaluateJsonPath(response.body, assertion.property) : void 0;
657
+ default:
658
+ return void 0;
659
+ }
660
+ }
661
+ function evaluateOperator(actual, operator, expected) {
662
+ if (operator === "exists") {
663
+ return actual !== void 0 && actual !== null && actual !== "";
664
+ }
665
+ if (actual === void 0 || actual === null)
666
+ return false;
667
+ switch (operator) {
668
+ case "eq":
669
+ return actual === expected;
670
+ case "neq":
671
+ return actual !== expected;
672
+ case "contains":
673
+ return actual.includes(expected);
674
+ case "not_contains":
675
+ return !actual.includes(expected);
676
+ case "gt": {
677
+ const a = parseFloat(actual);
678
+ const b = parseFloat(expected);
679
+ return !isNaN(a) && !isNaN(b) && a > b;
680
+ }
681
+ case "lt": {
682
+ const a = parseFloat(actual);
683
+ const b = parseFloat(expected);
684
+ return !isNaN(a) && !isNaN(b) && a < b;
685
+ }
686
+ default:
687
+ return false;
688
+ }
689
+ }
690
+ function describeResult(assertion, actual, passed) {
691
+ const source = assertion.source === "jsonpath" || assertion.source === "header" ? `${assertion.source}(${assertion.property})` : assertion.source;
692
+ if (assertion.operator === "exists") {
693
+ return passed ? `${source} exists` : `${source} does not exist`;
694
+ }
695
+ const truncated = actual !== void 0 && actual.length > 60 ? actual.slice(0, 60) + "..." : actual ?? "undefined";
696
+ return passed ? `${source} ${assertion.operator} "${assertion.expected}"` : `Expected ${source} ${assertion.operator} "${assertion.expected}", got "${truncated}"`;
697
+ }
698
+ function runAssertions(response, assertions) {
699
+ return assertions.map((assertion) => {
700
+ const actual = getActualValue(response, assertion);
701
+ const passed = evaluateOperator(actual, assertion.operator, assertion.expected);
702
+ return {
703
+ assertionId: assertion.id,
704
+ passed,
705
+ actual: actual ?? "",
706
+ message: describeResult(assertion, actual, passed)
707
+ };
708
+ });
709
+ }
710
+
711
+ // src/server.ts
712
+ function argValue(flag, fallback) {
713
+ const i = process.argv.indexOf(flag);
714
+ return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
715
+ }
716
+ var PROJECT_ROOT = argValue("--project", process.cwd());
717
+ var BODY_CAP = parseInt(argValue("--body-cap", "4096"), 10) || 4096;
718
+ var store = new GudApiStore(PROJECT_ROOT);
719
+ var activeEnvName = null;
720
+ var requestShape = z.object({
721
+ name: z.string().describe("Human-readable request name; also the upsert match key within a collection."),
722
+ method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]),
723
+ url: z.string().describe("Request URL. May contain {{variable}} placeholders resolved from the environment and collection."),
724
+ headers: z.record(z.string()).optional(),
725
+ body: z.string().optional(),
726
+ bodyType: z.enum(["json", "form", "text", "graphql", "multipart", "none"]).optional(),
727
+ auth: z.object({
728
+ type: z.enum(["none", "bearer", "basic", "apikey"]),
729
+ bearer: z.object({ token: z.string() }).optional(),
730
+ basic: z.object({ username: z.string(), password: z.string() }).optional(),
731
+ apikey: z.object({ key: z.string(), value: z.string(), addTo: z.enum(["header", "query"]) }).optional()
732
+ }).optional(),
733
+ tests: z.array(
734
+ z.object({
735
+ id: z.string().optional(),
736
+ source: z.enum(["status", "time", "body", "header", "jsonpath"]),
737
+ property: z.string().optional(),
738
+ operator: z.enum(["eq", "neq", "contains", "not_contains", "gt", "lt", "exists"]),
739
+ expected: z.string()
740
+ })
741
+ ).optional().describe("Assertions run automatically after send_request.")
742
+ });
743
+ function toSavedRequest(input) {
744
+ return {
745
+ id: randomUUID3(),
746
+ name: input.name,
747
+ method: input.method,
748
+ url: input.url,
749
+ headers: input.headers ?? {},
750
+ body: input.body ?? "",
751
+ bodyType: input.bodyType ?? "none",
752
+ auth: input.auth ?? { type: "none" },
753
+ tests: input.tests?.map((t) => ({ ...t, id: t.id ?? randomUUID3() }))
754
+ };
755
+ }
756
+ function ok(obj) {
757
+ return { content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] };
758
+ }
759
+ function fail(message) {
760
+ return { content: [{ type: "text", text: JSON.stringify({ error: message }, null, 2) }], isError: true };
761
+ }
762
+ var VERSION = true ? "0.1.2" : "0.0.0-dev";
763
+ var server = new McpServer({ name: "gud-api", version: VERSION });
764
+ server.tool(
765
+ "list_collections",
766
+ "List all Gud API collections in the project's .gud-api folder, with request counts and folder names.",
767
+ {},
768
+ async () => {
769
+ const cols = await store.loadCollections();
770
+ return ok(
771
+ cols.map((c) => ({
772
+ id: c.id,
773
+ name: c.name,
774
+ requestCount: countRequests(c),
775
+ folders: folderPaths(c),
776
+ variables: c.variables.map((v) => v.key)
777
+ }))
778
+ );
779
+ }
780
+ );
781
+ server.tool(
782
+ "get_collection",
783
+ "Get the full contents of one collection: folders, requests (with method/url/headers/body), and variables. Example response bodies are summarized.",
784
+ { collection: z.string().describe("Collection id or name.") },
785
+ async ({ collection }) => {
786
+ const c = await store.findCollection(collection);
787
+ if (!c)
788
+ return fail(`Collection not found: ${collection}`);
789
+ const summarizeReq = (r) => ({
790
+ id: r.id,
791
+ name: r.name,
792
+ method: r.method,
793
+ url: r.url,
794
+ headers: r.headers,
795
+ bodyType: r.bodyType,
796
+ hasBody: !!r.body,
797
+ hasTests: !!r.tests?.length,
798
+ examples: (r.examples ?? []).map((e) => ({ label: e.label, status: e.status, capturedAt: e.capturedAt }))
799
+ });
800
+ const summarizeFolder = (f) => ({
801
+ name: f.name,
802
+ requests: f.requests.map(summarizeReq),
803
+ folders: f.folders.map(summarizeFolder)
804
+ });
805
+ return ok({
806
+ id: c.id,
807
+ name: c.name,
808
+ variables: c.variables,
809
+ requests: c.requests.map(summarizeReq),
810
+ folders: c.folders.map(summarizeFolder)
811
+ });
812
+ }
813
+ );
814
+ server.tool(
815
+ "create_collection",
816
+ "Create a new collection in the project (.gud-api/collections). Returns its id.",
817
+ {
818
+ name: z.string(),
819
+ variables: z.array(z.object({ key: z.string(), value: z.string(), enabled: z.boolean().optional() })).optional()
820
+ },
821
+ async ({ name, variables }) => {
822
+ const vars = (variables ?? []).map((v) => ({ key: v.key, value: v.value, enabled: v.enabled ?? true }));
823
+ const c = await store.createCollection(name, vars);
824
+ return ok({
825
+ id: c.id,
826
+ name: c.name,
827
+ path: `.gud-api/collections`,
828
+ // Surfaced so the agent can tell the human how to view the result — they
829
+ // may not have the Gud API extension yet and would otherwise think nothing
830
+ // happened. A .gud-api/README.md with the same guidance is also written.
831
+ viewHint: "To view and run this, install the Gud API extension (VS Code Marketplace, or Open VSX for Cursor/Windsurf/VSCodium). See .gud-api/README.md."
832
+ });
833
+ }
834
+ );
835
+ server.tool(
836
+ "upsert_request",
837
+ "Create or update a request inside a collection (matched by name). The collection is created if it doesn't exist. Optionally nest it under a folder path.",
838
+ {
839
+ collection: z.string().describe("Collection name or id. Created if missing."),
840
+ folderPath: z.array(z.string()).optional().describe('Folder path, e.g. ["Auth","JWT"]. Created as needed.'),
841
+ request: requestShape
842
+ },
843
+ async ({ collection, folderPath, request }) => {
844
+ let c = await store.findCollection(collection);
845
+ if (!c)
846
+ c = await store.createCollection(collection);
847
+ const { requestId, created } = upsertRequest(c, folderPath ?? [], toSavedRequest(request));
848
+ await store.saveCollection(c);
849
+ return ok({ requestId, created, collection: c.name });
850
+ }
851
+ );
852
+ server.tool(
853
+ "send_request",
854
+ "Execute a request and return status, timing, headers, and a (truncated) body. Resolves {{variables}} from the active environment and the request's collection. Optionally capture the response as a saved example.",
855
+ {
856
+ collection: z.string().optional().describe("Collection name/id when sending a saved request."),
857
+ requestName: z.string().optional().describe("Saved request name or id within the collection."),
858
+ request: requestShape.optional().describe("Inline request definition (alternative to collection+requestName)."),
859
+ saveExample: z.union([z.boolean(), z.string()]).optional().describe("When set, capture the response as an example on the saved request. Pass a string to label it.")
860
+ },
861
+ async ({ collection, requestName, request, saveExample }) => {
862
+ let def;
863
+ let col;
864
+ let saved;
865
+ if (request) {
866
+ def = toSavedRequest(request);
867
+ } else if (collection && requestName) {
868
+ col = await store.findCollection(collection);
869
+ if (!col)
870
+ return fail(`Collection not found: ${collection}`);
871
+ const hit = findRequestDeep(col, requestName);
872
+ if (!hit)
873
+ return fail(`Request not found in ${collection}: ${requestName}`);
874
+ def = hit.request;
875
+ saved = hit.request;
876
+ } else {
877
+ return fail("Provide either `request`, or `collection` + `requestName`.");
878
+ }
879
+ const collectionVars = col?.variables ?? [];
880
+ const envs = await store.loadEnvironments();
881
+ const activeEnv = activeEnvName ? envs.find((e) => e.name === activeEnvName) : void 0;
882
+ const envVars = activeEnv?.variables ?? [];
883
+ let multipart;
884
+ if (def.bodyType === "multipart" && def.body) {
885
+ try {
886
+ multipart = JSON.parse(def.body);
887
+ } catch {
888
+ }
889
+ }
890
+ let response;
891
+ try {
892
+ response = await execute(def, collectionVars, envVars, multipart, []);
893
+ } catch (err) {
894
+ return fail(`Request failed: ${err instanceof Error ? err.message : String(err)}`);
895
+ }
896
+ const testResults = def.tests?.length ? runAssertions(response, def.tests) : void 0;
897
+ const { body, truncated, fullLength } = truncateBody(response.body, BODY_CAP);
898
+ if (saveExample && saved && col) {
899
+ addExample(saved, {
900
+ label: typeof saveExample === "string" ? saveExample : `${response.status} ${response.statusText}`.trim(),
901
+ status: response.status,
902
+ headers: response.headers,
903
+ body: response.body.slice(0, 16 * 1024),
904
+ timeMs: response.timeMs,
905
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString()
906
+ });
907
+ await store.saveCollection(col);
908
+ }
909
+ return ok({
910
+ status: response.status,
911
+ statusText: response.statusText,
912
+ timeMs: response.timeMs,
913
+ size: response.size,
914
+ resolvedUrl: response.resolvedUrl,
915
+ headers: response.headers,
916
+ body,
917
+ bodyTruncated: truncated,
918
+ bodyFullLength: fullLength,
919
+ testResults: testResults?.map((t) => ({ passed: t.passed, message: t.message })),
920
+ exampleSaved: !!(saveExample && saved)
921
+ });
922
+ }
923
+ );
924
+ server.tool(
925
+ "delete_request",
926
+ "Delete a saved request from a collection by id or name.",
927
+ { collection: z.string(), request: z.string().describe("Request id or name.") },
928
+ async ({ collection, request }) => {
929
+ const c = await store.findCollection(collection);
930
+ if (!c)
931
+ return fail(`Collection not found: ${collection}`);
932
+ const removed = deleteRequest(c, request);
933
+ if (!removed)
934
+ return fail(`Request not found: ${request}`);
935
+ await store.saveCollection(c);
936
+ return ok({ deleted: true });
937
+ }
938
+ );
939
+ server.tool(
940
+ "upsert_environment",
941
+ "Create or update an environment (a named set of variables, e.g. base_url and tokens). Matched by name. Optionally set it active for subsequent send_request calls.",
942
+ {
943
+ name: z.string(),
944
+ variables: z.array(z.object({ key: z.string(), value: z.string(), enabled: z.boolean().optional() })),
945
+ setActive: z.boolean().optional()
946
+ },
947
+ async ({ name, variables, setActive }) => {
948
+ const vars = variables.map((v) => ({ key: v.key, value: v.value, enabled: v.enabled ?? true }));
949
+ const env = await store.upsertEnvironment(name, vars);
950
+ if (setActive)
951
+ activeEnvName = env.name;
952
+ return ok({ id: env.id, name: env.name, active: activeEnvName === env.name, variableCount: vars.length });
953
+ }
954
+ );
955
+ server.tool(
956
+ "get_active_environment",
957
+ "Return the active environment's variables. Secret-looking values (token/key/secret/password/\u2026) are masked \u2014 send_request still resolves the real values server-side.",
958
+ {},
959
+ async () => {
960
+ if (!activeEnvName)
961
+ return ok({ active: null, note: "No active environment. Use upsert_environment with setActive: true." });
962
+ const envs = await store.loadEnvironments();
963
+ const env = envs.find((e) => e.name === activeEnvName);
964
+ if (!env)
965
+ return ok({ active: null, note: `Active environment '${activeEnvName}' no longer exists.` });
966
+ return ok({ active: env.name, variables: maskSecrets(env.variables) });
967
+ }
968
+ );
969
+ async function main() {
970
+ const transport = new StdioServerTransport();
971
+ await server.connect(transport);
972
+ process.stderr.write(`[gud-api mcp] ready \u2014 project: ${PROJECT_ROOT}
973
+ `);
974
+ if (process.stdin.isTTY) {
975
+ process.stderr.write(
976
+ `[gud-api mcp] This is a Model Context Protocol server, not an interactive CLI.
977
+ [gud-api mcp] It is now waiting for an MCP client to talk to it over stdin \u2014 nothing is broken.
978
+ [gud-api mcp] Configure it in Claude Code / Cursor / Windsurf instead of running it by hand:
979
+ [gud-api mcp] { "mcpServers": { "gud-api": { "command": "npx", "args": ["-y", "@gudlab/gud-api-mcp", "--project", "."] } } }
980
+ [gud-api mcp] Docs: https://gudapi-docs.gudlab.org/guide/ai-agents \xB7 Press Ctrl+C to exit.
981
+ `
982
+ );
983
+ }
984
+ }
985
+ main().catch((err) => {
986
+ process.stderr.write(`[gud-api mcp] fatal: ${err instanceof Error ? err.stack : String(err)}
987
+ `);
988
+ process.exit(1);
989
+ });
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@gudlab/gud-api-mcp",
3
+ "version": "0.1.2",
4
+ "mcpName": "io.github.gudlab/gud-api-mcp",
5
+ "description": "Model Context Protocol server for Gud API — let AI coding agents (Claude Code, Cursor, Windsurf, Codex, Cline…) create, run, and save API requests and collections your team can open in any VS Code-compatible editor.",
6
+ "type": "module",
7
+ "license": "SEE LICENSE IN LICENSE",
8
+ "publisher": "gudlab",
9
+ "keywords": ["mcp", "model-context-protocol", "api", "rest", "graphql", "api-client", "gud-api", "ai-agent", "claude-code", "cursor", "windsurf", "codex", "cline"],
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/gudlab/gud-api"
13
+ },
14
+ "bin": {
15
+ "gud-api-mcp": "./dist/server.js"
16
+ },
17
+ "files": ["dist"],
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "scripts": {
22
+ "build": "node build.mjs",
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "vitest run",
25
+ "test:watch": "vitest",
26
+ "start": "node dist/server.js"
27
+ },
28
+ "dependencies": {
29
+ "@modelcontextprotocol/sdk": "^1.0.0",
30
+ "zod": "^3.23.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^20.11.0",
34
+ "esbuild": "^0.20.0",
35
+ "typescript": "^5.4.0",
36
+ "vitest": "^4.1.3"
37
+ }
38
+ }