@cocaxcode/api-testing-mcp 0.5.7 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -351,11 +351,24 @@ curl -X POST \
351
351
 
352
352
  Save requests for reuse (with tags), manage variables across environments (dev/staging/prod), and switch contexts instantly.
353
353
 
354
+ ### Project-Scoped Environments
355
+
356
+ Different projects can have different active environments. When you switch to an environment for a specific project, it only affects that project — other projects keep their own active environment.
357
+
358
+ ```
359
+ "Switch to dev for this project" → dev is active only in the current project
360
+ "Switch to prod globally" → prod is the default for projects without a specific assignment
361
+ "Show me which projects have environments" → lists all project-environment assignments
362
+ "Clear the project environment for this project" → falls back to the global active environment
363
+ ```
364
+
365
+ Resolution order: project-specific environment → global active environment.
366
+
354
367
  ---
355
368
 
356
369
  ## Tool Reference
357
370
 
358
- 22 tools organized in 8 categories:
371
+ 27 tools organized in 8 categories:
359
372
 
360
373
  | Category | Tools | Count |
361
374
  |----------|-------|-------|
@@ -363,8 +376,8 @@ Save requests for reuse (with tags), manage variables across environments (dev/s
363
376
  | **Testing** | `assert` | 1 |
364
377
  | **Flows** | `flow_run` | 1 |
365
378
  | **Collections** | `collection_save` `collection_list` `collection_get` `collection_delete` | 4 |
366
- | **Environments** | `env_create` `env_list` `env_set` `env_get` `env_switch` `env_rename` `env_delete` | 7 |
367
- | **API Specs** | `api_import` `api_endpoints` `api_endpoint_detail` | 3 |
379
+ | **Environments** | `env_create` `env_list` `env_set` `env_get` `env_switch` `env_rename` `env_delete` `env_spec` `env_project_clear` `env_project_list` | 10 |
380
+ | **API Specs** | `api_import` `api_spec_list` `api_endpoints` `api_endpoint_detail` | 4 |
368
381
  | **Mock** | `mock` | 1 |
369
382
  | **Utilities** | `load_test` `export_curl` `diff_responses` `bulk_test` | 4 |
370
383
 
@@ -378,7 +391,8 @@ All data lives in `~/.api-testing/` (user home directory) as plain JSON — no d
378
391
 
379
392
  ```
380
393
  ~/.api-testing/
381
- ├── active-env # Active environment name
394
+ ├── active-env # Global active environment name
395
+ ├── project-envs.json # Per-project active environments
382
396
  ├── collections/ # Saved requests
383
397
  ├── environments/ # Environment variables (dev, prod, ...)
384
398
  └── specs/ # Imported OpenAPI specs
@@ -412,7 +426,7 @@ Override the default directory in your MCP config:
412
426
  git clone https://github.com/cocaxcode/api-testing-mcp.git
413
427
  cd api-testing-mcp
414
428
  npm install
415
- npm test # 77 tests across 10 suites
429
+ npm test # 83 tests across 10 suites
416
430
  npm run build # ESM bundle via tsup
417
431
  npm run typecheck # Strict TypeScript
418
432
  ```
package/dist/index.js CHANGED
@@ -16,12 +16,14 @@ var Storage = class {
16
16
  environmentsDir;
17
17
  specsDir;
18
18
  activeEnvFile;
19
+ projectEnvsFile;
19
20
  constructor(baseDir) {
20
21
  this.baseDir = baseDir ?? process.env.API_TESTING_DIR ?? join(homedir(), ".api-testing");
21
22
  this.collectionsDir = join(this.baseDir, "collections");
22
23
  this.environmentsDir = join(this.baseDir, "environments");
23
24
  this.specsDir = join(this.baseDir, "specs");
24
25
  this.activeEnvFile = join(this.baseDir, "active-env");
26
+ this.projectEnvsFile = join(this.baseDir, "project-envs.json");
25
27
  }
26
28
  // ── Collections ──
27
29
  async saveCollection(saved) {
@@ -80,7 +82,8 @@ var Storage = class {
80
82
  items.push({
81
83
  name: env.name,
82
84
  active: env.name === activeEnv,
83
- variableCount: Object.keys(env.variables).length
85
+ variableCount: Object.keys(env.variables).length,
86
+ spec: env.spec
84
87
  });
85
88
  }
86
89
  return items;
@@ -95,7 +98,14 @@ var Storage = class {
95
98
  const filePath = join(this.environmentsDir, `${this.sanitizeName(name)}.json`);
96
99
  await this.writeJson(filePath, env);
97
100
  }
98
- async getActiveEnvironment() {
101
+ async getActiveEnvironment(project) {
102
+ const projectPath = project ?? process.cwd();
103
+ const projectEnvs = await this.getProjectEnvs();
104
+ const projectEnv = projectEnvs[projectPath];
105
+ if (projectEnv) {
106
+ const env = await this.getEnvironment(projectEnv);
107
+ if (env) return projectEnv;
108
+ }
99
109
  try {
100
110
  const content = await readFile(this.activeEnvFile, "utf-8");
101
111
  return content.trim() || null;
@@ -103,13 +113,49 @@ var Storage = class {
103
113
  return null;
104
114
  }
105
115
  }
106
- async setActiveEnvironment(name) {
116
+ async setActiveEnvironment(name, project) {
107
117
  const env = await this.getEnvironment(name);
108
118
  if (!env) {
109
119
  throw new Error(`Entorno '${name}' no encontrado`);
110
120
  }
111
- await this.ensureDir("");
112
- await writeFile(this.activeEnvFile, name, "utf-8");
121
+ if (project) {
122
+ const projectEnvs = await this.getProjectEnvs();
123
+ projectEnvs[project] = name;
124
+ await this.ensureDir("");
125
+ await this.writeJson(this.projectEnvsFile, projectEnvs);
126
+ } else {
127
+ await this.ensureDir("");
128
+ await writeFile(this.activeEnvFile, name, "utf-8");
129
+ }
130
+ }
131
+ async clearProjectEnvironment(project) {
132
+ const projectEnvs = await this.getProjectEnvs();
133
+ if (!(project in projectEnvs)) return false;
134
+ delete projectEnvs[project];
135
+ await this.writeJson(this.projectEnvsFile, projectEnvs);
136
+ return true;
137
+ }
138
+ async listProjectEnvironments() {
139
+ return this.getProjectEnvs();
140
+ }
141
+ async getProjectEnvs() {
142
+ return await this.readJson(this.projectEnvsFile) ?? {};
143
+ }
144
+ async setEnvironmentSpec(envName, specName) {
145
+ const env = await this.getEnvironment(envName);
146
+ if (!env) {
147
+ throw new Error(`Entorno '${envName}' no encontrado`);
148
+ }
149
+ env.spec = specName ?? void 0;
150
+ env.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
151
+ const filePath = join(this.environmentsDir, `${this.sanitizeName(envName)}.json`);
152
+ await this.writeJson(filePath, env);
153
+ }
154
+ async getActiveSpec() {
155
+ const activeName = await this.getActiveEnvironment();
156
+ if (!activeName) return null;
157
+ const env = await this.getEnvironment(activeName);
158
+ return env?.spec ?? null;
113
159
  }
114
160
  async renameEnvironment(oldName, newName) {
115
161
  const env = await this.getEnvironment(oldName);
@@ -124,9 +170,23 @@ var Storage = class {
124
170
  env.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
125
171
  await this.createEnvironment(env);
126
172
  await unlink(join(this.environmentsDir, `${this.sanitizeName(oldName)}.json`));
127
- const activeEnv = await this.getActiveEnvironment();
128
- if (activeEnv === oldName) {
129
- await writeFile(this.activeEnvFile, newName, "utf-8");
173
+ try {
174
+ const globalActive = await readFile(this.activeEnvFile, "utf-8");
175
+ if (globalActive.trim() === oldName) {
176
+ await writeFile(this.activeEnvFile, newName, "utf-8");
177
+ }
178
+ } catch {
179
+ }
180
+ const projectEnvs = await this.getProjectEnvs();
181
+ let changed = false;
182
+ for (const [project, envName] of Object.entries(projectEnvs)) {
183
+ if (envName === oldName) {
184
+ projectEnvs[project] = newName;
185
+ changed = true;
186
+ }
187
+ }
188
+ if (changed) {
189
+ await this.writeJson(this.projectEnvsFile, projectEnvs);
130
190
  }
131
191
  }
132
192
  async deleteEnvironment(name) {
@@ -135,13 +195,24 @@ var Storage = class {
135
195
  throw new Error(`Entorno '${name}' no encontrado`);
136
196
  }
137
197
  await unlink(join(this.environmentsDir, `${this.sanitizeName(name)}.json`));
138
- const activeEnv = await this.getActiveEnvironment();
139
- if (activeEnv === name) {
140
- try {
198
+ try {
199
+ const globalActive = await readFile(this.activeEnvFile, "utf-8");
200
+ if (globalActive.trim() === name) {
141
201
  await unlink(this.activeEnvFile);
142
- } catch {
202
+ }
203
+ } catch {
204
+ }
205
+ const projectEnvs = await this.getProjectEnvs();
206
+ let changed = false;
207
+ for (const [project, envName] of Object.entries(projectEnvs)) {
208
+ if (envName === name) {
209
+ delete projectEnvs[project];
210
+ changed = true;
143
211
  }
144
212
  }
213
+ if (changed) {
214
+ await this.writeJson(this.projectEnvsFile, projectEnvs);
215
+ }
145
216
  }
146
217
  /**
147
218
  * Carga las variables del entorno activo.
@@ -590,7 +661,8 @@ function registerEnvironmentTools(server, storage) {
590
661
  "Crea un nuevo entorno (ej: dev, staging, prod) con variables opcionales.",
591
662
  {
592
663
  name: z3.string().describe("Nombre del entorno (ej: dev, staging, prod)"),
593
- variables: z3.record(z3.string()).optional().describe("Variables iniciales como key-value")
664
+ variables: z3.record(z3.string()).optional().describe("Variables iniciales como key-value"),
665
+ spec: z3.string().optional().describe('Nombre del spec API asociado (ej: "cocaxcode-api")')
594
666
  },
595
667
  async (params) => {
596
668
  try {
@@ -598,16 +670,18 @@ function registerEnvironmentTools(server, storage) {
598
670
  const env = {
599
671
  name: params.name,
600
672
  variables: params.variables ?? {},
673
+ spec: params.spec,
601
674
  createdAt: now,
602
675
  updatedAt: now
603
676
  };
604
677
  await storage.createEnvironment(env);
605
678
  const varCount = Object.keys(env.variables).length;
679
+ const specMsg = params.spec ? ` \u2014 spec: '${params.spec}'` : "";
606
680
  return {
607
681
  content: [
608
682
  {
609
683
  type: "text",
610
- text: `Entorno '${params.name}' creado con ${varCount} variable(s)`
684
+ text: `Entorno '${params.name}' creado con ${varCount} variable(s)${specMsg}`
611
685
  }
612
686
  ]
613
687
  };
@@ -762,6 +836,41 @@ function registerEnvironmentTools(server, storage) {
762
836
  }
763
837
  }
764
838
  );
839
+ server.tool(
840
+ "env_spec",
841
+ "Asocia o desasocia un spec API a un entorno. Si no se especifica entorno, usa el activo.",
842
+ {
843
+ spec: z3.string().optional().describe("Nombre del spec a asociar. Si se omite, desasocia el spec actual"),
844
+ environment: z3.string().optional().describe("Entorno destino (default: entorno activo)")
845
+ },
846
+ async (params) => {
847
+ try {
848
+ const envName = params.environment ?? await storage.getActiveEnvironment();
849
+ if (!envName) {
850
+ return {
851
+ content: [
852
+ {
853
+ type: "text",
854
+ text: "No hay entorno activo. Usa env_switch para activar uno."
855
+ }
856
+ ],
857
+ isError: true
858
+ };
859
+ }
860
+ await storage.setEnvironmentSpec(envName, params.spec ?? null);
861
+ const message = params.spec ? `Spec '${params.spec}' asociado al entorno '${envName}'` : `Spec desasociado del entorno '${envName}'`;
862
+ return {
863
+ content: [{ type: "text", text: message }]
864
+ };
865
+ } catch (error) {
866
+ const message = error instanceof Error ? error.message : String(error);
867
+ return {
868
+ content: [{ type: "text", text: `Error: ${message}` }],
869
+ isError: true
870
+ };
871
+ }
872
+ }
873
+ );
765
874
  server.tool(
766
875
  "env_rename",
767
876
  "Renombra un entorno existente. Si es el entorno activo, actualiza la referencia.",
@@ -817,18 +926,95 @@ function registerEnvironmentTools(server, storage) {
817
926
  );
818
927
  server.tool(
819
928
  "env_switch",
820
- "Cambia el entorno activo. Las variables del entorno activo se usan en {{interpolaci\xF3n}}.",
929
+ "Cambia el entorno activo. Si se especifica project, solo aplica a ese directorio de proyecto.",
821
930
  {
822
- name: z3.string().describe("Nombre del entorno a activar")
931
+ name: z3.string().describe("Nombre del entorno a activar"),
932
+ project: z3.string().optional().describe("Ruta del proyecto (ej: C:/cocaxcode). Si se omite, cambia el entorno global")
823
933
  },
824
934
  async (params) => {
825
935
  try {
826
- await storage.setActiveEnvironment(params.name);
936
+ await storage.setActiveEnvironment(params.name, params.project);
937
+ const scope = params.project ? ` para proyecto '${params.project}'` : " (global)";
938
+ return {
939
+ content: [
940
+ {
941
+ type: "text",
942
+ text: `Entorno activo cambiado a '${params.name}'${scope}`
943
+ }
944
+ ]
945
+ };
946
+ } catch (error) {
947
+ const message = error instanceof Error ? error.message : String(error);
948
+ return {
949
+ content: [{ type: "text", text: `Error: ${message}` }],
950
+ isError: true
951
+ };
952
+ }
953
+ }
954
+ );
955
+ server.tool(
956
+ "env_project_clear",
957
+ "Elimina la asociaci\xF3n de entorno espec\xEDfico de un proyecto. El proyecto usar\xE1 el entorno global.",
958
+ {
959
+ project: z3.string().describe("Ruta del proyecto del que eliminar la asociaci\xF3n")
960
+ },
961
+ async (params) => {
962
+ try {
963
+ const removed = await storage.clearProjectEnvironment(params.project);
964
+ if (!removed) {
965
+ return {
966
+ content: [
967
+ {
968
+ type: "text",
969
+ text: `No hay entorno espec\xEDfico para el proyecto '${params.project}'`
970
+ }
971
+ ]
972
+ };
973
+ }
974
+ return {
975
+ content: [
976
+ {
977
+ type: "text",
978
+ text: `Entorno espec\xEDfico eliminado para proyecto '${params.project}'. Usar\xE1 el entorno global.`
979
+ }
980
+ ]
981
+ };
982
+ } catch (error) {
983
+ const message = error instanceof Error ? error.message : String(error);
984
+ return {
985
+ content: [{ type: "text", text: `Error: ${message}` }],
986
+ isError: true
987
+ };
988
+ }
989
+ }
990
+ );
991
+ server.tool(
992
+ "env_project_list",
993
+ "Lista todos los proyectos con entornos espec\xEDficos asignados.",
994
+ {},
995
+ async () => {
996
+ try {
997
+ const projectEnvs = await storage.listProjectEnvironments();
998
+ const entries = Object.entries(projectEnvs);
999
+ if (entries.length === 0) {
1000
+ return {
1001
+ content: [
1002
+ {
1003
+ type: "text",
1004
+ text: "No hay entornos espec\xEDficos por proyecto. Todos usan el entorno global."
1005
+ }
1006
+ ]
1007
+ };
1008
+ }
827
1009
  return {
828
1010
  content: [
829
1011
  {
830
1012
  type: "text",
831
- text: `Entorno activo cambiado a '${params.name}'`
1013
+ text: JSON.stringify(
1014
+ entries.map(([project, env]) => ({ project, environment: env })),
1015
+ null,
1016
+ 2
1017
+ )
832
1018
  }
833
1019
  ]
834
1020
  };
@@ -968,6 +1154,21 @@ function parseOpenApiSpec(doc, name, source) {
968
1154
 
969
1155
  // src/tools/api-spec.ts
970
1156
  import { readFile as readFile2 } from "fs/promises";
1157
+ async function resolveSpecName(name, storage) {
1158
+ if (name) return { name };
1159
+ const activeSpec = await storage.getActiveSpec();
1160
+ if (activeSpec) return { name: activeSpec };
1161
+ const specs = await storage.listSpecs();
1162
+ if (specs.length === 0) {
1163
+ return { error: "No hay specs importados. Usa api_import para importar uno." };
1164
+ }
1165
+ if (specs.length === 1) {
1166
+ return { name: specs[0].name };
1167
+ }
1168
+ return {
1169
+ error: `Hay ${specs.length} specs importados. Especifica cu\xE1l usar: ${specs.map((s) => s.name).join(", ")}`
1170
+ };
1171
+ }
971
1172
  function registerApiSpecTools(server, storage) {
972
1173
  server.tool(
973
1174
  "api_import",
@@ -1030,6 +1231,10 @@ function registerApiSpecTools(server, storage) {
1030
1231
  }
1031
1232
  const spec = parseOpenApiSpec(rawDoc, params.name, params.source);
1032
1233
  await storage.saveSpec(spec);
1234
+ const activeEnv = await storage.getActiveEnvironment();
1235
+ if (activeEnv) {
1236
+ await storage.setEnvironmentSpec(activeEnv, params.name);
1237
+ }
1033
1238
  const tagCounts = {};
1034
1239
  for (const ep of spec.endpoints) {
1035
1240
  for (const tag of ep.tags ?? ["sin-tag"]) {
@@ -1052,6 +1257,7 @@ function registerApiSpecTools(server, storage) {
1052
1257
  "Endpoints por tag:",
1053
1258
  tagSummary,
1054
1259
  "",
1260
+ activeEnv ? `Asociado al entorno '${activeEnv}'.` : "",
1055
1261
  "Usa api_endpoints para ver los endpoints disponibles.",
1056
1262
  "Usa api_endpoint_detail para ver el detalle de un endpoint espec\xEDfico."
1057
1263
  ].join("\n")
@@ -1067,24 +1273,66 @@ function registerApiSpecTools(server, storage) {
1067
1273
  }
1068
1274
  }
1069
1275
  );
1276
+ server.tool(
1277
+ "api_spec_list",
1278
+ "Lista todos los specs de API importados. \xDAsalo para descubrir qu\xE9 APIs est\xE1n disponibles.",
1279
+ {},
1280
+ async () => {
1281
+ try {
1282
+ const items = await storage.listSpecs();
1283
+ if (items.length === 0) {
1284
+ return {
1285
+ content: [
1286
+ {
1287
+ type: "text",
1288
+ text: "No hay specs importados. Usa api_import para importar uno."
1289
+ }
1290
+ ]
1291
+ };
1292
+ }
1293
+ return {
1294
+ content: [
1295
+ {
1296
+ type: "text",
1297
+ text: JSON.stringify(items, null, 2)
1298
+ }
1299
+ ]
1300
+ };
1301
+ } catch (error) {
1302
+ const message = error instanceof Error ? error.message : String(error);
1303
+ return {
1304
+ content: [{ type: "text", text: `Error: ${message}` }],
1305
+ isError: true
1306
+ };
1307
+ }
1308
+ }
1309
+ );
1070
1310
  server.tool(
1071
1311
  "api_endpoints",
1072
- "Lista los endpoints de un API importada. Filtra por tag, m\xE9todo o path.",
1312
+ "Lista los endpoints de un API importada. Filtra por tag, m\xE9todo o path. Si no se especifica nombre y solo hay un spec importado, lo usa autom\xE1ticamente.",
1073
1313
  {
1074
- name: z4.string().describe("Nombre del API importada"),
1314
+ name: z4.string().optional().describe("Nombre del API importada. Si se omite y solo hay un spec, lo usa autom\xE1ticamente"),
1075
1315
  tag: z4.string().optional().describe('Filtrar por tag (ej: "blog", "auth", "users")'),
1076
1316
  method: z4.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]).optional().describe("Filtrar por m\xE9todo HTTP"),
1077
1317
  path: z4.string().optional().describe('Filtrar por path (b\xFAsqueda parcial, ej: "/blog" muestra todos los que contienen /blog)')
1078
1318
  },
1079
1319
  async (params) => {
1080
1320
  try {
1081
- const spec = await storage.getSpec(params.name);
1321
+ const resolved = await resolveSpecName(params.name, storage);
1322
+ if (resolved.error) {
1323
+ return {
1324
+ content: [{ type: "text", text: resolved.error }],
1325
+ isError: true
1326
+ };
1327
+ }
1328
+ const resolvedName = resolved.name;
1329
+ const spec = await storage.getSpec(resolvedName);
1082
1330
  if (!spec) {
1083
1331
  return {
1084
1332
  content: [
1085
1333
  {
1086
1334
  type: "text",
1087
- text: `Error: API '${params.name}' no encontrada. Usa api_import para importarla primero.`
1335
+ text: `Error: API '${resolvedName}' no encontrada. Usa api_import para importarla primero.`
1088
1336
  }
1089
1337
  ],
1090
1338
  isError: true
@@ -1145,19 +1393,27 @@ function registerApiSpecTools(server, storage) {
1145
1393
  "api_endpoint_detail",
1146
1394
  "Muestra el detalle completo de un endpoint: par\xE1metros, body schema, y respuestas. \xDAtil para saber qu\xE9 datos enviar.",
1147
1395
  {
1148
- name: z4.string().describe("Nombre del API importada"),
1396
+ name: z4.string().optional().describe("Nombre del API importada. Si se omite y solo hay un spec, lo usa autom\xE1ticamente"),
1149
1397
  method: z4.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]).describe("M\xE9todo HTTP del endpoint"),
1150
1398
  path: z4.string().describe('Path exacto del endpoint (ej: "/blog", "/auth/login")')
1151
1399
  },
1152
1400
  async (params) => {
1153
1401
  try {
1154
- const spec = await storage.getSpec(params.name);
1402
+ const resolved = await resolveSpecName(params.name, storage);
1403
+ if (resolved.error) {
1404
+ return {
1405
+ content: [{ type: "text", text: resolved.error }],
1406
+ isError: true
1407
+ };
1408
+ }
1409
+ const resolvedName = resolved.name;
1410
+ const spec = await storage.getSpec(resolvedName);
1155
1411
  if (!spec) {
1156
1412
  return {
1157
1413
  content: [
1158
1414
  {
1159
1415
  type: "text",
1160
- text: `Error: API '${params.name}' no encontrada. Usa api_import para importarla primero.`
1416
+ text: `Error: API '${resolvedName}' no encontrada. Usa api_import para importarla primero.`
1161
1417
  }
1162
1418
  ],
1163
1419
  isError: true
@@ -2232,7 +2488,7 @@ function registerLoadTestTool(server, storage) {
2232
2488
  }
2233
2489
 
2234
2490
  // src/server.ts
2235
- var VERSION = "0.5.5";
2491
+ var VERSION = "0.7.0";
2236
2492
  function createServer(storageDir) {
2237
2493
  const server = new McpServer({
2238
2494
  name: "api-testing-mcp",