From 30e121a2a53cea735455f18aa3264b7ab1893b75 Mon Sep 17 00:00:00 2001 From: apicircle-dev Date: Sat, 11 Jul 2026 00:26:58 +0530 Subject: [PATCH 01/16] feat(assets): spec-typed global file assets Uploading an OpenAPI 3.x / Swagger 2.0 document into the Global Assets Files library now parses it once on upload and records a SpecAssetMeta summary (dialect, format, title, version, operationCount, warnings) on the asset. - shared: additive GlobalFileAsset.spec + SpecAssetMeta type (+ index export) - mock-server-core: browser-safe summarizeSpec() (detect dialect + count ops, no $ref resolution, no endpoint build) - ui-components: parse-on-upload in addGlobalFileAsset + fillGlobalFileAssetBytes; Assets-dock spec badge (SpecAssetBadge) + parsed summary in the file editor - mcp-server: assets.list_files envelope gains spec (null for ordinary files) - docs: CHANGELOG, mcp-tools-reference envelope, Help "Files" section Purely additive: existing assets and non-spec files are unaffected. Foundation for spec-driven mock servers (Increment B) and code-vs-spec drift. --- CHANGELOG.md | 11 ++ docs/mcp-tools-reference.md | 4 +- .../mcp-server/src/tools/globalAssets.test.ts | 30 +++++ packages/mcp-server/src/tools/globalAssets.ts | 3 +- packages/mock-server-core/src/index.ts | 2 + packages/mock-server-core/src/parsing.ts | 2 + .../mock-server-core/src/specSummary.test.ts | 118 ++++++++++++++++++ packages/mock-server-core/src/specSummary.ts | 100 +++++++++++++++ packages/shared/src/index.ts | 1 + packages/shared/src/types.ts | 34 +++++ .../dock/GlobalAssetsDockPanel.test.tsx | 22 ++++ .../src/layout/dock/GlobalAssetsDockPanel.tsx | 32 ++++- .../src/panels/help/helpContent.ts | 2 + .../src/primitives/SpecAssetBadge.test.tsx | 50 ++++++++ .../src/primitives/SpecAssetBadge.tsx | 50 ++++++++ .../src/store/globalAssetsActions.test.ts | 49 ++++++++ .../src/store/globalAssetsActions.ts | 4 + .../src/store/globalFileAssetSpec.test.ts | 60 +++++++++ .../src/store/specUpload.test.ts | 58 +++++++++ .../ui-components/src/store/specUpload.ts | 39 ++++++ .../ui-components/src/store/workspaceStore.ts | 20 +++ 21 files changed, 687 insertions(+), 4 deletions(-) create mode 100644 packages/mock-server-core/src/specSummary.test.ts create mode 100644 packages/mock-server-core/src/specSummary.ts create mode 100644 packages/ui-components/src/primitives/SpecAssetBadge.test.tsx create mode 100644 packages/ui-components/src/primitives/SpecAssetBadge.tsx create mode 100644 packages/ui-components/src/store/globalFileAssetSpec.test.ts create mode 100644 packages/ui-components/src/store/specUpload.test.ts create mode 100644 packages/ui-components/src/store/specUpload.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ef8d76e2..4e0fc310 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,17 @@ ### Added +- **Spec-typed Global File Assets (additive).** Uploading an OpenAPI 3.x / + Swagger 2.0 document (`.json` / `.yaml` / `.yml`) into the Global Assets + **Files** library now parses it once on upload and records a `SpecAssetMeta` + summary (`dialect`, `format`, `title`, `version`, `operationCount`, + `warnings`) on the asset. The Assets panel shows a spec badge + ("OpenAPI 3 · N ops") plus a parsed summary in the file editor, and the MCP + `assets.list_files` envelope gains a `spec` field (`null` for ordinary files). + Purely additive — existing assets and non-spec files are untouched. Foundation + for spec-driven mock servers and code-vs-spec drift. + (`@apicircle/shared`, `@apicircle/mock-server-core`, `@apicircle/ui-components`, + `@apicircle/mcp-server`) - **UI sections/mode seam (open-core, additive).** Building on the `extraPanels` seam, the React shell (`@apicircle/ui-components`) now accepts edition-contributed top-level **sections** ("modes") via an optional `App` `sections` prop diff --git a/docs/mcp-tools-reference.md b/docs/mcp-tools-reference.md index 6197bb59..e78eddd4 100644 --- a/docs/mcp-tools-reference.md +++ b/docs/mcp-tools-reference.md @@ -122,9 +122,11 @@ Each `list` entry carries the asset's **provenance state**, derived from `workin - `missing` — both refs dropped, no local copy. - `diverged` — both refs hold different blob shas (audit before pushing). +Each entry also carries `spec`: `null` for an ordinary file, or `{ dialect, format, title?, version?, operationCount, parsedAt, warnings }` when the file is a recognised OpenAPI 3.x / Swagger 2.0 document (parsed once on upload). + | Tool | Input | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `assets.list_files` | `{}` → `{ count, files: [{ id, name, filename, size, mimeType, sha256, state, workingBranchRef, baseBranchRef, usage: { requests, mockEndpoints, total } }] }` | +| `assets.list_files` | `{}` → `{ count, files: [{ id, name, filename, size, mimeType, sha256, state, spec, workingBranchRef, baseBranchRef, usage: { requests, mockEndpoints, total } }] }` | | `assets.create_file` | `{ name, description?, filename, size, mimeType?, sha256? }` — metadata only; MCP cannot carry bytes. Returns `{ id, slotId }`. Asset starts in `missing` state until the desktop / web supplies bytes on the next foreground reconciliation. | | `assets.update_file` | `{ id, patch: { name?, description? } }` — rename / re-describe. Provenance refs preserved. | | `assets.delete_file` | `{ id }` — cascade. Returns `{ found, id, filename, unbound: { requests, mockEndpoints, total } }` describing every consumer that was cleared. If the asset had any push provenance (`workingBranchRef` or `baseBranchRef`), the slotId is queued for remote-blob deletion; the next desktop push emits a `{path: '.apicircle/workspace-/attachments/', sha: null}` tree entry so the orphan blob is removed from the working branch and (after PR merge) from the base branch. Local-only assets that were never pushed skip the queue. | diff --git a/packages/mcp-server/src/tools/globalAssets.test.ts b/packages/mcp-server/src/tools/globalAssets.test.ts index cf07f0f4..29c51a92 100644 --- a/packages/mcp-server/src/tools/globalAssets.test.ts +++ b/packages/mcp-server/src/tools/globalAssets.test.ts @@ -104,6 +104,36 @@ describe('globalAssets.files.list', () => { expect(out.files).toEqual([]); }); + it('surfaces the parsed spec summary when the asset is an OpenAPI document', async () => { + const specAsset = asset({ + id: 'spec1', + filename: 'petstore.json', + mimeType: 'application/json', + spec: { + dialect: 'openapi-3', + format: 'json', + title: 'Petstore', + version: '1.0', + operationCount: 4, + parsedAt: T0, + warnings: [], + }, + }); + setupCtx(freshState({ spec1: specAsset })); + const out = (await globalAssetsFilesListTool.handler({}, ctx)) as { + files: Array<{ spec: unknown }>; + }; + expect(out.files[0]?.spec).toMatchObject({ dialect: 'openapi-3', operationCount: 4 }); + }); + + it('returns spec: null for ordinary file assets', async () => { + setupCtx(freshState({ a1: asset({ id: 'a1' }) })); + const out = (await globalAssetsFilesListTool.handler({}, ctx)) as { + files: Array<{ spec: unknown }>; + }; + expect(out.files[0]?.spec).toBeNull(); + }); + it('derives each asset state from refs + pending bytes', async () => { const a1 = asset({ id: 'a1' }); const a2 = asset({ diff --git a/packages/mcp-server/src/tools/globalAssets.ts b/packages/mcp-server/src/tools/globalAssets.ts index b4d47ef9..4160f8ca 100644 --- a/packages/mcp-server/src/tools/globalAssets.ts +++ b/packages/mcp-server/src/tools/globalAssets.ts @@ -52,7 +52,7 @@ function deriveState( export const globalAssetsFilesListTool: AnyToolDef = { name: 'assets.list_files', description: - 'List every Global File Asset with its provenance state and reference count. Each entry includes id, name, filename, size, mimeType, sha256, state (uploading | workingOnly | merged | baseOnly | missing | diverged), workingBranchRef, baseBranchRef, and usage { requests, mockEndpoints, total }.', + 'List every Global File Asset with its provenance state and reference count. Each entry includes id, name, filename, size, mimeType, sha256, state (uploading | workingOnly | merged | baseOnly | missing | diverged), spec (present when the file is an OpenAPI/Swagger document — { dialect, format, title?, version?, operationCount, parsedAt, warnings } — otherwise null), workingBranchRef, baseBranchRef, and usage { requests, mockEndpoints, total }.', inputSchema: z.object({}).strict(), async handler(_input, ctx) { const state = await ctx.workspace.read(); @@ -71,6 +71,7 @@ export const globalAssetsFilesListTool: AnyToolDef = { mimeType: asset.mimeType, sha256: asset.sha256 ?? null, state: deriveState(asset, hasPending), + spec: asset.spec ?? null, workingBranchRef: asset.workingBranchRef ?? null, baseBranchRef: asset.baseBranchRef ?? null, usage: { ...u }, diff --git a/packages/mock-server-core/src/index.ts b/packages/mock-server-core/src/index.ts index 4c5b8b33..6232daee 100644 --- a/packages/mock-server-core/src/index.ts +++ b/packages/mock-server-core/src/index.ts @@ -31,6 +31,8 @@ export { parsePostmanToEndpoints } from './parsers/postman'; export { parseInsomniaToEndpoints } from './parsers/insomnia'; export { schemaToExample } from './faker/schemaToExample'; export { dereferenceInternal } from './parsers/refDeref'; +export { summarizeSpec } from './specSummary'; +export type { SpecSummary } from './specSummary'; export type { ParseSourceResult } from './parsing'; export { getFreePort, isPortFree } from './runtime/portFinder'; export { buildRouter }; diff --git a/packages/mock-server-core/src/parsing.ts b/packages/mock-server-core/src/parsing.ts index b77e873b..750f1296 100644 --- a/packages/mock-server-core/src/parsing.ts +++ b/packages/mock-server-core/src/parsing.ts @@ -26,6 +26,8 @@ export { parseInsomniaToEndpoints } from './parsers/insomnia'; export { schemaToExample } from './faker/schemaToExample'; export type { JsonSchemaLike } from './faker/schemaToExample'; export { dereferenceInternal, type DereferenceResult } from './parsers/refDeref'; +export { summarizeSpec } from './specSummary'; +export type { SpecSummary } from './specSummary'; export interface ParseSourceResult { endpoints: MockEndpoint[]; diff --git a/packages/mock-server-core/src/specSummary.test.ts b/packages/mock-server-core/src/specSummary.test.ts new file mode 100644 index 00000000..3e3e0890 --- /dev/null +++ b/packages/mock-server-core/src/specSummary.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; +import yaml from 'js-yaml'; +import { summarizeSpec } from './specSummary'; + +const openapiDoc = { + openapi: '3.0.3', + info: { title: 'Petstore', version: '1.2.0' }, + paths: { + '/pets': { + // `parameters` and `summary` are NOT operations and must not be counted. + parameters: [], + summary: 'pets collection', + get: { responses: { '200': { description: 'ok' } } }, + post: { responses: { '201': { description: 'created' } } }, + }, + '/pets/{id}': { + get: { responses: { '200': { description: 'ok' } } }, + delete: { responses: { '204': { description: 'gone' } } }, + }, + }, +}; + +describe('summarizeSpec', () => { + it('summarises an OpenAPI 3 JSON document', () => { + const summary = summarizeSpec(JSON.stringify(openapiDoc), 'petstore.json'); + expect(summary).toEqual({ + dialect: 'openapi-3', + format: 'json', + title: 'Petstore', + version: '1.2.0', + operationCount: 4, + warnings: [], + }); + }); + + it('summarises a Swagger 2.0 document', () => { + const swagger = { + swagger: '2.0', + info: { title: 'Legacy', version: '0.9' }, + paths: { '/health': { get: { responses: { '200': {} } } } }, + }; + const summary = summarizeSpec(JSON.stringify(swagger), 'swagger.json'); + expect(summary?.dialect).toBe('swagger-2'); + expect(summary?.operationCount).toBe(1); + expect(summary?.title).toBe('Legacy'); + }); + + it('parses YAML by filename hint', () => { + const summary = summarizeSpec(yaml.dump(openapiDoc), 'petstore.yaml'); + expect(summary?.format).toBe('yaml'); + expect(summary?.operationCount).toBe(4); + }); + + it('parses YAML by filename hint (.yml)', () => { + const summary = summarizeSpec(yaml.dump(openapiDoc), 'petstore.yml'); + expect(summary?.format).toBe('yaml'); + }); + + it('sniffs JSON from leading brace when no filename hint is given', () => { + expect(summarizeSpec(JSON.stringify(openapiDoc))?.format).toBe('json'); + }); + + it('sniffs YAML from content when no filename hint and no leading brace', () => { + expect(summarizeSpec(yaml.dump(openapiDoc))?.format).toBe('yaml'); + }); + + it('falls back to the other format when the hinted one fails', () => { + // YAML content but a .json filename → JSON.parse fails, YAML succeeds. + const summary = summarizeSpec(yaml.dump(openapiDoc), 'mislabelled.json'); + expect(summary?.format).toBe('yaml'); + expect(summary?.dialect).toBe('openapi-3'); + }); + + it('omits title/version when info is absent', () => { + const doc = { openapi: '3.1.0', paths: { '/x': { get: { responses: { '200': {} } } } } }; + const summary = summarizeSpec(JSON.stringify(doc)); + expect(summary?.title).toBeUndefined(); + expect(summary?.version).toBeUndefined(); + }); + + it('warns when the spec declares no paths', () => { + const doc = { openapi: '3.0.0', info: { title: 'Empty' } }; + const summary = summarizeSpec(JSON.stringify(doc)); + expect(summary?.operationCount).toBe(0); + expect(summary?.warnings).toContain('The spec declares no paths.'); + }); + + it('warns when paths exist but declare no operations', () => { + const doc = { openapi: '3.0.0', paths: { '/x': { parameters: [] } } }; + const summary = summarizeSpec(JSON.stringify(doc)); + expect(summary?.operationCount).toBe(0); + expect(summary?.warnings).toContain('The spec declares paths but no operations.'); + }); + + it('skips non-object path items when counting', () => { + const doc = { + openapi: '3.0.0', + paths: { '/ref': null, '/ok': { get: { responses: { '200': {} } } } }, + }; + expect(summarizeSpec(JSON.stringify(doc))?.operationCount).toBe(1); + }); + + it('returns null for a JSON document that is not a spec', () => { + expect(summarizeSpec(JSON.stringify({ foo: 1, paths: {} }))).toBeNull(); + }); + + it('returns null when openapi/swagger is present but not a string', () => { + expect(summarizeSpec(JSON.stringify({ openapi: 3, paths: {} }))).toBeNull(); + }); + + it('returns null for a non-object payload', () => { + expect(summarizeSpec(JSON.stringify('just a string'))).toBeNull(); + }); + + it('returns null for unparseable bytes', () => { + expect(summarizeSpec('{ this is neither json nor: [yaml')).toBeNull(); + }); +}); diff --git a/packages/mock-server-core/src/specSummary.ts b/packages/mock-server-core/src/specSummary.ts new file mode 100644 index 00000000..35aacedd --- /dev/null +++ b/packages/mock-server-core/src/specSummary.ts @@ -0,0 +1,100 @@ +// OpenAPI / Swagger 2.0 spec detection + summary. +// +// A lightweight, browser-safe pass over a file's text that answers "is this +// an API spec, and if so, what does it declare?" WITHOUT the full endpoint +// materialization (`parseOpenApiToEndpoints`) or `$ref` dereferencing. It is +// what a Global File Asset stores as `SpecAssetMeta` on upload, so every +// consumer — the Assets panel, the mock "run/import from spec" flows, and (in +// the Lens edition) the code-vs-spec drift check — reads one authoritative +// summary instead of re-parsing the blob. + +import yaml from 'js-yaml'; +import type { SpecAssetMeta } from '@apicircle/shared'; + +/** The eight OpenAPI Path-Item operation keys. Everything else under a path + * (`parameters`, `summary`, `$ref`, `servers`, …) is not an operation. */ +const HTTP_METHODS: ReadonlySet = new Set([ + 'get', + 'put', + 'post', + 'delete', + 'options', + 'head', + 'patch', + 'trace', +]); + +/** The spec summary minus the caller-stamped `parsedAt` (kept side-effect-free). */ +export type SpecSummary = Omit; + +function tryParse(text: string, format: 'json' | 'yaml'): unknown { + try { + return format === 'yaml' ? yaml.load(text) : JSON.parse(text); + } catch { + return undefined; + } +} + +/** Sniff JSON vs YAML from a filename hint first, then the content. */ +function sniffFormat(text: string, filename?: string): 'json' | 'yaml' { + const lower = filename?.toLowerCase() ?? ''; + if (lower.endsWith('.json')) return 'json'; + if (lower.endsWith('.yaml') || lower.endsWith('.yml')) return 'yaml'; + return text.trimStart().startsWith('{') ? 'json' : 'yaml'; +} + +/** + * Detect whether `text` is an OpenAPI 3.x / Swagger 2.0 document and, if so, + * summarise it. Returns `null` for anything that isn't a spec (a plain JSON + * file, a random upload, unparseable bytes) so the caller leaves the asset's + * `spec` field undefined. + * + * `filenameHint` disambiguates JSON vs YAML; the parser also falls back to the + * other format if the hinted one fails. Pure + synchronous — no `$ref` + * resolution, no network, no endpoint build. Operation count is the sum of + * HTTP methods across `paths`, which is exactly the presence-drift denominator + * the Lens edition compares code against. + */ +export function summarizeSpec(text: string, filenameHint?: string): SpecSummary | null { + const primary = sniffFormat(text, filenameHint); + const secondary = primary === 'json' ? 'yaml' : 'json'; + let format: 'json' | 'yaml' = primary; + let parsed = tryParse(text, primary); + if (parsed === undefined) { + parsed = tryParse(text, secondary); + format = secondary; + } + if (!parsed || typeof parsed !== 'object') return null; + + const doc = parsed as Record; + const dialect: SpecSummary['dialect'] | null = + typeof doc.openapi === 'string' + ? 'openapi-3' + : typeof doc.swagger === 'string' + ? 'swagger-2' + : null; + if (!dialect) return null; + + const info = + doc.info && typeof doc.info === 'object' ? (doc.info as Record) : {}; + const title = typeof info.title === 'string' ? info.title : undefined; + const version = typeof info.version === 'string' ? info.version : undefined; + + const warnings: string[] = []; + const paths = + doc.paths && typeof doc.paths === 'object' ? (doc.paths as Record) : undefined; + let operationCount = 0; + if (!paths || Object.keys(paths).length === 0) { + warnings.push('The spec declares no paths.'); + } else { + for (const item of Object.values(paths)) { + if (!item || typeof item !== 'object') continue; + for (const key of Object.keys(item)) { + if (HTTP_METHODS.has(key.toLowerCase())) operationCount += 1; + } + } + if (operationCount === 0) warnings.push('The spec declares paths but no operations.'); + } + + return { dialect, format, title, version, operationCount, warnings }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ee150978..d6afc90d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -109,6 +109,7 @@ export type { GlobalSchema, GlobalGraphQL, GlobalFileAsset, + SpecAssetMeta, AssetGitRef, PendingFileUpload, AssetUsage, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 2e934cc2..d8bdd092 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -412,6 +412,33 @@ export interface AssetGitRef { verifiedAt: string; } +/** + * Parsed summary of a Global File Asset that IS an OpenAPI 3.x / Swagger 2.0 + * document. Present only on spec files — an ordinary file asset leaves `spec` + * undefined. Derived once when the bytes are uploaded (and re-derived when they + * change) by `summarizeSpec` in `@apicircle/mock-server-core`, so the Assets + * panel, the mock "run/import from spec" pickers, and (in the Lens edition) the + * code-vs-spec drift check all read one authoritative parse instead of + * re-parsing the blob. Purely additive — existing assets and non-spec files are + * unaffected. + */ +export interface SpecAssetMeta { + /** `openapi-3` when the doc has a top-level `openapi:` string; `swagger-2` for `swagger:`. */ + dialect: 'openapi-3' | 'swagger-2'; + /** How the bytes are encoded, so consumers parse with the right reader. */ + format: 'json' | 'yaml'; + /** `info.title`, when present. */ + title?: string; + /** `info.version`, when present. */ + version?: string; + /** Operations declared — the sum of HTTP methods across `paths`. */ + operationCount: number; + /** ISO timestamp of the parse; re-derived whenever the bytes (sha256) change. */ + parsedAt: string; + /** Non-fatal structural warnings surfaced in the Assets panel. */ + warnings: string[]; +} + export interface GlobalFileAsset { id: string; name: string; @@ -438,6 +465,13 @@ export interface GlobalFileAsset { * never been merged leave it `undefined`. */ baseBranchRef?: AssetGitRef | null; + /** + * Present when this file asset is a recognised OpenAPI/Swagger document — a + * parsed summary derived on upload (see {@link SpecAssetMeta}). Absent on + * ordinary file assets. Additive; drives the Assets-panel spec badge and the + * mock "run/import from spec" pickers. + */ + spec?: SpecAssetMeta; } /** diff --git a/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.test.tsx b/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.test.tsx index 9f9e48dd..15e5a649 100644 --- a/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.test.tsx +++ b/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.test.tsx @@ -58,6 +58,28 @@ describe('GlobalAssetsDockPanel', () => { expect(useWorkspaceStore.getState().synced!.globalAssets.schemas[id]).toBeUndefined(); }); + it('shows a spec badge and summary for an uploaded OpenAPI file', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: /^Files/ })); + const input = screen.getByLabelText('Global file asset'); + const openapi = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Petstore', version: '1.0' }, + paths: { '/pets': { get: { responses: { '200': {} } } } }, + }); + await userEvent.upload( + input, + new File([openapi], 'petstore.json', { type: 'application/json' }), + ); + + // Upload auto-selects the asset, so the editor summary mounts with the + // labelled dialect + op count + title; the list row also carries an + // (icon-only) aria-labelled spec badge. + expect(await screen.findByText('OpenAPI 3 · 1 op')).toBeInTheDocument(); + expect(screen.getByText('Petstore')).toBeInTheDocument(); + expect(screen.getAllByLabelText(/API spec:/).length).toBeGreaterThan(0); + }); + it('uploads and edits a reusable file asset', async () => { render(); await userEvent.click(screen.getByRole('button', { name: /^Files/ })); diff --git a/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.tsx b/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.tsx index 01801362..31c7fa57 100644 --- a/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.tsx +++ b/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.tsx @@ -12,7 +12,7 @@ // request referencing the deleted id has its mapping cleared. import { useEffect, useMemo, useRef, useState } from 'react'; -import { ArrowLeft, FileArchive, Plus, Trash2, Upload } from 'lucide-react'; +import { AlertTriangle, ArrowLeft, FileArchive, Plus, Trash2, Upload } from 'lucide-react'; import { formatBytes, type AssetUsage, @@ -25,6 +25,7 @@ import { ConfirmDialog } from '../../primitives/ConfirmDialog'; import { MonacoEditorBase } from '../../editors/MonacoEditorBase'; import { cn } from '../../primitives/cn'; import { deriveFileAssetState, FileAssetStatusPill } from '../../primitives/FileAssetStatusPill'; +import { SpecAssetBadge } from '../../primitives/SpecAssetBadge'; interface FileAssetConsumer { kind: 'request' | 'mock'; @@ -517,7 +518,10 @@ function FileAssetListRow({ @@ -832,6 +836,30 @@ function FileAssetEditor({ id }: { id: string | null }) { )} + {file.spec && ( +
+
+ + {file.spec.title && ( + + {file.spec.title} + + )} + {file.spec.version && v{file.spec.version}} +
+ {file.spec.warnings.length > 0 && ( +
    + {file.spec.warnings.map((w) => ( +
  • +
  • + ))} +
+ )} +
+ )} +
{consumers.length === 0 diff --git a/packages/ui-components/src/panels/help/helpContent.ts b/packages/ui-components/src/panels/help/helpContent.ts index 37eeba69..e4245e72 100644 --- a/packages/ui-components/src/panels/help/helpContent.ts +++ b/packages/ui-components/src/panels/help/helpContent.ts @@ -1337,6 +1337,8 @@ A request with a graphql body that references the definition gets field and argu Every file you drop — into the Global Assets sidebar, a binary request body, a form-data file row, or a mock binary response — becomes a reusable Global Asset entry. The workspace tracks filename, size, MIME type, checksum, and the requests or mock responses that bind to the file. +When the file you drop is an OpenAPI 3.x or Swagger 2.0 document (\`.json\` / \`.yaml\` / \`.yml\`), Studio recognises it on upload and tags the asset with a **spec badge** ("OpenAPI 3 · N ops"). Selecting the asset shows the parsed title, version, operation count, and any parse warnings — so the workspace knows which of its files are API contracts. + Each asset shows a small status pill next to its name. The pill tells you where the bytes live: - **Uploaded locally** — bytes are in your local IDB; the next push uploads them. diff --git a/packages/ui-components/src/primitives/SpecAssetBadge.test.tsx b/packages/ui-components/src/primitives/SpecAssetBadge.test.tsx new file mode 100644 index 00000000..a5ae4965 --- /dev/null +++ b/packages/ui-components/src/primitives/SpecAssetBadge.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import type { SpecAssetMeta } from '@apicircle/shared'; +import { SpecAssetBadge } from './SpecAssetBadge'; + +const meta = (o: Partial = {}): SpecAssetMeta => ({ + dialect: 'openapi-3', + format: 'json', + title: 'Petstore', + version: '1.0', + operationCount: 3, + parsedAt: 't', + warnings: [], + ...o, +}); + +describe('SpecAssetBadge', () => { + it('shows the dialect and a pluralised operation count', () => { + render(); + expect(screen.getByText('OpenAPI 3 · 3 ops')).toBeInTheDocument(); + }); + + it('uses the singular for a single operation', () => { + render(); + expect(screen.getByText('OpenAPI 3 · 1 op')).toBeInTheDocument(); + }); + + it('labels Swagger 2 documents', () => { + render(); + expect(screen.getByText(/Swagger 2/)).toBeInTheDocument(); + }); + + it('builds a descriptive aria-label with title and version', () => { + render(); + expect( + screen.getByLabelText('API spec: Petstore · OpenAPI 3 · 3 ops · v1.0'), + ).toBeInTheDocument(); + }); + + it('drops title/version from the aria-label when absent', () => { + render(); + expect(screen.getByLabelText('API spec: OpenAPI 3 · 3 ops')).toBeInTheDocument(); + }); + + it('hides the text label in iconOnly mode but keeps the aria-label', () => { + render(); + expect(screen.queryByText('OpenAPI 3 · 3 ops')).not.toBeInTheDocument(); + expect(screen.getByLabelText(/API spec:/)).toBeInTheDocument(); + }); +}); diff --git a/packages/ui-components/src/primitives/SpecAssetBadge.tsx b/packages/ui-components/src/primitives/SpecAssetBadge.tsx new file mode 100644 index 00000000..5042f093 --- /dev/null +++ b/packages/ui-components/src/primitives/SpecAssetBadge.tsx @@ -0,0 +1,50 @@ +import { FileJson } from 'lucide-react'; +import type { SpecAssetMeta } from '@apicircle/shared'; +import { cn } from './cn'; + +// Small badge marking a Global File Asset as a parsed OpenAPI / Swagger spec. +// Shows the dialect + declared operation count ("OpenAPI 3 · 12 ops"); the full +// title/version land in the tooltip. Rendered on the Assets list row (icon +// only) and in the file editor's spec summary (with label). + +const DIALECT_LABEL: Record = { + 'openapi-3': 'OpenAPI 3', + 'swagger-2': 'Swagger 2', +}; + +export interface SpecAssetBadgeProps { + spec: SpecAssetMeta; + className?: string; + /** When true, drops the label text and shows just the icon + tooltip. */ + iconOnly?: boolean; +} + +export function SpecAssetBadge({ + spec, + className, + iconOnly = false, +}: SpecAssetBadgeProps): JSX.Element { + const dialect = DIALECT_LABEL[spec.dialect]; + const ops = `${spec.operationCount} op${spec.operationCount === 1 ? '' : 's'}`; + const titleParts: string[] = []; + if (spec.title) titleParts.push(spec.title); + titleParts.push(dialect, ops); + if (spec.version) titleParts.push(`v${spec.version}`); + const title = titleParts.join(' · '); + + return ( + + + ); +} diff --git a/packages/ui-components/src/store/globalAssetsActions.test.ts b/packages/ui-components/src/store/globalAssetsActions.test.ts index fcbd224a..d614f995 100644 --- a/packages/ui-components/src/store/globalAssetsActions.test.ts +++ b/packages/ui-components/src/store/globalAssetsActions.test.ts @@ -54,6 +54,55 @@ const seedRequest = (synced: WorkspaceSynced, partial: Partial): Wor }; }; +describe('file asset spec typing', () => { + const spec = { + dialect: 'openapi-3' as const, + format: 'json' as const, + title: 'Petstore', + version: '1.0', + operationCount: 3, + parsedAt: 't', + warnings: [], + }; + + it('threads the spec summary onto the created asset', () => { + const { file } = addGlobalFileAsset(baseSynced(), { + name: 'petstore', + slotId: 's', + filename: 'petstore.json', + size: 10, + mimeType: 'application/json', + spec, + }); + expect(file.spec).toEqual(spec); + }); + + it('leaves spec undefined for an ordinary file', () => { + const { file } = addGlobalFileAsset(baseSynced(), { + name: 'logo', + slotId: 's', + filename: 'logo.png', + size: 4, + mimeType: 'image/png', + }); + expect(file.spec).toBeUndefined(); + }); + + it('preserves spec across a rename', () => { + const { synced, file } = addGlobalFileAsset(baseSynced(), { + name: 'petstore', + slotId: 's', + filename: 'petstore.json', + size: 10, + mimeType: 'application/json', + spec, + }); + const next = updateGlobalFileAsset(synced, file.id, { name: 'renamed' }); + expect(next.globalAssets.files?.[file.id]?.name).toBe('renamed'); + expect(next.globalAssets.files?.[file.id]?.spec).toEqual(spec); + }); +}); + describe('addGlobalSchema', () => { it('appends a new entry with a fresh id and updates meta.updatedAt', () => { const before = baseSynced(); diff --git a/packages/ui-components/src/store/globalAssetsActions.ts b/packages/ui-components/src/store/globalAssetsActions.ts index 9391e77f..7955d5cd 100644 --- a/packages/ui-components/src/store/globalAssetsActions.ts +++ b/packages/ui-components/src/store/globalAssetsActions.ts @@ -11,6 +11,7 @@ import type { MockResponseBody, MockResponseConfig, RequestBody, + SpecAssetMeta, WorkspaceSynced, } from '@apicircle/shared'; import { generateId } from '@apicircle/shared'; @@ -200,6 +201,7 @@ function createGlobalFileAsset(args: { size: number; mimeType: string; sha256?: string; + spec?: SpecAssetMeta; }): GlobalFileAsset { const now = new Date().toISOString(); return { @@ -211,6 +213,7 @@ function createGlobalFileAsset(args: { size: args.size, mimeType: args.mimeType, sha256: args.sha256, + spec: args.spec, createdAt: now, updatedAt: now, }; @@ -226,6 +229,7 @@ export function addGlobalFileAsset( size: number; mimeType: string; sha256?: string; + spec?: SpecAssetMeta; }, ): { synced: WorkspaceSynced; file: GlobalFileAsset } { const file = createGlobalFileAsset(init); diff --git a/packages/ui-components/src/store/globalFileAssetSpec.test.ts b/packages/ui-components/src/store/globalFileAssetSpec.test.ts new file mode 100644 index 00000000..8c51dbe7 --- /dev/null +++ b/packages/ui-components/src/store/globalFileAssetSpec.test.ts @@ -0,0 +1,60 @@ +import { act } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { useWorkspaceStore } from './workspaceStore'; + +// Parse-on-upload, exercised through the real store thunks (fake-indexeddb). +// Uploading an OpenAPI/Swagger file populates `asset.spec`; re-uploading a +// non-spec file over it clears the summary. + +const openapi = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Petstore', version: '1.0' }, + paths: { + '/pets': { get: { responses: { '200': {} } }, post: { responses: { '201': {} } } }, + }, +}); + +const specFile = (): File => new File([openapi], 'petstore.json', { type: 'application/json' }); +const pngFile = (): File => + new File([new Uint8Array([1, 2, 3])], 'logo.png', { type: 'image/png' }); + +describe('workspaceStore parse-on-upload', () => { + beforeEach(async () => { + await act(async () => { + await useWorkspaceStore.getState().hydrate(); + }); + }); + + it('populates asset.spec when an OpenAPI file is uploaded', async () => { + let id = ''; + await act(async () => { + id = await useWorkspaceStore.getState().addGlobalFileAsset(specFile()); + }); + const asset = useWorkspaceStore.getState().synced?.globalAssets.files?.[id]; + expect(asset?.spec?.dialect).toBe('openapi-3'); + expect(asset?.spec?.operationCount).toBe(2); + expect(asset?.spec?.title).toBe('Petstore'); + expect(asset?.spec?.parsedAt).toBeTruthy(); + }); + + it('leaves spec undefined for a binary upload', async () => { + let id = ''; + await act(async () => { + id = await useWorkspaceStore.getState().addGlobalFileAsset(pngFile()); + }); + expect(useWorkspaceStore.getState().synced?.globalAssets.files?.[id]?.spec).toBeUndefined(); + }); + + it('re-parses and clears spec when non-spec bytes replace a spec', async () => { + let id = ''; + await act(async () => { + id = await useWorkspaceStore.getState().addGlobalFileAsset(specFile()); + }); + expect(useWorkspaceStore.getState().synced?.globalAssets.files?.[id]?.spec).toBeDefined(); + + await act(async () => { + await useWorkspaceStore.getState().fillGlobalFileAssetBytes(id, pngFile()); + }); + expect(useWorkspaceStore.getState().synced?.globalAssets.files?.[id]?.spec).toBeUndefined(); + }); +}); diff --git a/packages/ui-components/src/store/specUpload.test.ts b/packages/ui-components/src/store/specUpload.test.ts new file mode 100644 index 00000000..156f6cb3 --- /dev/null +++ b/packages/ui-components/src/store/specUpload.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { looksTextual, summarizeUploadedSpec } from './specUpload'; + +const NOW = '2026-07-11T00:00:00.000Z'; +const enc = (s: string): Uint8Array => new TextEncoder().encode(s); + +const openapi = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Petstore', version: '1.0' }, + paths: { '/pets': { get: { responses: { '200': {} } } } }, +}); + +describe('looksTextual', () => { + it('accepts .json / .yaml / .yml filenames', () => { + expect(looksTextual('spec.json', 'application/octet-stream')).toBe(true); + expect(looksTextual('spec.yaml', '')).toBe(true); + expect(looksTextual('spec.yml', '')).toBe(true); + }); + + it('accepts json/yaml/text MIME types', () => { + expect(looksTextual('blob', 'application/json')).toBe(true); + expect(looksTextual('blob', 'application/yaml')).toBe(true); + expect(looksTextual('blob', 'text/plain')).toBe(true); + }); + + it('rejects binary uploads', () => { + expect(looksTextual('logo.png', 'image/png')).toBe(false); + }); +}); + +describe('summarizeUploadedSpec', () => { + it('returns undefined for a non-textual upload without decoding', async () => { + const spec = await summarizeUploadedSpec(enc(openapi), 'logo.png', 'image/png', NOW); + expect(spec).toBeUndefined(); + }); + + it('derives SpecAssetMeta from an OpenAPI upload and stamps the injected parsedAt', async () => { + const spec = await summarizeUploadedSpec( + enc(openapi), + 'petstore.json', + 'application/json', + NOW, + ); + expect(spec).toMatchObject({ + dialect: 'openapi-3', + format: 'json', + title: 'Petstore', + operationCount: 1, + parsedAt: NOW, + }); + }); + + it('returns undefined for a textual file that is not a spec', async () => { + const notSpec = enc(JSON.stringify({ hello: 'world' })); + const spec = await summarizeUploadedSpec(notSpec, 'data.json', 'application/json', NOW); + expect(spec).toBeUndefined(); + }); +}); diff --git a/packages/ui-components/src/store/specUpload.ts b/packages/ui-components/src/store/specUpload.ts new file mode 100644 index 00000000..df6ebb96 --- /dev/null +++ b/packages/ui-components/src/store/specUpload.ts @@ -0,0 +1,39 @@ +// Parse-on-upload: when a Global File Asset's bytes are an OpenAPI/Swagger +// document, derive its `SpecAssetMeta` so the asset carries a spec summary the +// Assets panel and the mock "run/import from spec" flows can read. Browser-safe +// (uses the `/parsing` subpath); guarded by extension/MIME so a large binary +// upload is never decoded as text. + +import type { SpecAssetMeta } from '@apicircle/shared'; + +/** True when the filename/MIME suggests a textual (JSON/YAML) payload worth + * sniffing as a spec — so we never UTF-8-decode a large binary blob. */ +export function looksTextual(filename: string, mimeType: string): boolean { + const name = filename.toLowerCase(); + return ( + name.endsWith('.json') || + name.endsWith('.yaml') || + name.endsWith('.yml') || + /json|yaml|text/i.test(mimeType) + ); +} + +/** + * Derive a {@link SpecAssetMeta} from freshly-uploaded bytes when they are an + * OpenAPI/Swagger document, else `undefined`. `nowIso` is injected so the + * caller stamps `parsedAt` with the store's clock (and tests stay + * deterministic). + */ +export async function summarizeUploadedSpec( + bytes: Uint8Array, + filename: string, + mimeType: string, + nowIso: string, +): Promise { + if (!looksTextual(filename, mimeType)) return undefined; + const { summarizeSpec } = await import('@apicircle/mock-server-core/parsing'); + const text = new TextDecoder().decode(bytes); + const summary = summarizeSpec(text, filename); + if (!summary) return undefined; + return { ...summary, parsedAt: nowIso }; +} diff --git a/packages/ui-components/src/store/workspaceStore.ts b/packages/ui-components/src/store/workspaceStore.ts index eca63962..b34c54e9 100644 --- a/packages/ui-components/src/store/workspaceStore.ts +++ b/packages/ui-components/src/store/workspaceStore.ts @@ -51,6 +51,7 @@ import { } from './githubPrCapability'; import { decideRetirement, probeBranchRetirement } from './branchRetirement'; import { getDesktopMockBridge } from '../desktop/bridge'; +import { summarizeUploadedSpec } from './specUpload'; import { applyFont } from '../theme/applyFont'; import { applyFontSize, clampFontSizePercent } from '../theme/applyFontSize'; import { @@ -3497,6 +3498,15 @@ export const useWorkspaceStore = create((set, get) => ({ const slotId = generateId(); const record = await createAttachmentFromFile(file, slotId); await putAttachment(record); + // Parse-on-upload: if the bytes are an OpenAPI/Swagger doc, derive its + // spec summary now (bytes in hand) so the asset carries it; `undefined` + // for ordinary files. + const spec = await summarizeUploadedSpec( + record.bytes, + record.filename, + record.mimeType, + new Date().toISOString(), + ); // Race-safe: run the reducer against LIVE state inside commitSynced // so a mid-await mutation (another upload, a rename, a request edit) // is preserved. The previous shape — `commitSynced(() => result.synced)` @@ -3513,6 +3523,7 @@ export const useWorkspaceStore = create((set, get) => ({ size: record.size, mimeType: record.mimeType, sha256: record.sha256, + spec, }); createdFileId = out.file.id; return out.synced; @@ -3562,6 +3573,14 @@ export const useWorkspaceStore = create((set, get) => ({ // user might be filling a slot the MCP claim guessed at. const record = await createAttachmentFromFile(file, slotId); await putAttachment(record); + // Re-parse on re-upload: keep the spec summary in sync with the new bytes + // (and clear it when a non-spec file replaces a spec). + const spec = await summarizeUploadedSpec( + record.bytes, + record.filename, + record.mimeType, + new Date().toISOString(), + ); // Race-safe: reducer runs against LIVE state. If the user deleted // the asset between our await and now, the reducer no-ops by // returning `s` unchanged. If the user renamed the asset, the @@ -3582,6 +3601,7 @@ export const useWorkspaceStore = create((set, get) => ({ size: record.size, mimeType: record.mimeType, sha256: record.sha256, + spec, updatedAt: new Date().toISOString(), }, }, From a2af40c78b96233aa23c03314d858dc52a7d4476 Mon Sep 17 00:00:00 2001 From: apicircle-dev Date: Sat, 11 Jul 2026 01:13:20 +0530 Subject: [PATCH 02/16] feat(mocks): run or import a spec asset as a mock server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build a mock server from an uploaded spec asset (GlobalFileAsset) in two modes: "run live" (linked — endpoints derive from the asset, stay in sync, read-only) or "import & edit" (materialized — parsed into editable endpoints). - shared: MockServerSource `openapi-asset` variant + `isLinkedMockSource` - ui-components: `resolveMockEndpoints` (asset bytes -> parse); createMockServer handles both modes; `refreshMockServer`; linked read-only guards + auto-refresh of linked mocks when the asset changes; "From spec asset" modal tab; sidebar Refresh / Re-import actions + read-only indicator - mcp-server: `mock.refresh` tool + linked read-only guards on the 7 endpoint tools (catalog 94 -> 95) - vscode: mockYaml serializes the new source variant - docs: CHANGELOG, mcp-tools-reference, mock-server.md, Help; tool count reconciled across README / CLAUDE.md / AGENTS.md / docs Additive; existing mocks and sources are unaffected. --- AGENTS.md | 4 +- CHANGELOG.md | 13 ++ CLAUDE.md | 4 +- README.md | 6 +- apps/vscode/README.md | 2 +- apps/vscode/src/fs/mockYaml.ts | 11 ++ docs/connect-your-ai-client.md | 2 +- docs/context/api-circle.md | 10 +- docs/mcp-tools-reference.md | 23 +-- docs/mock-server.md | 16 ++ packages/mcp-server/README.md | 6 +- .../mcp-server/src/tools/mockRefresh.test.ts | 165 ++++++++++++++++++ packages/mcp-server/src/tools/mocks.ts | 55 ++++++ packages/mcp-server/src/tools/registry.ts | 2 + packages/mock-server-core/src/parsing.test.ts | 11 ++ packages/mock-server-core/src/parsing.ts | 11 ++ packages/shared/src/index.ts | 1 + packages/shared/src/mcp.ts | 2 + packages/shared/src/mock.test.ts | 29 +++ packages/shared/src/mock.ts | 26 +++ .../src/panels/help/helpContent.ts | 1 + .../mocks/CreateMockServerModal.test.tsx | 32 ++++ .../panels/mocks/CreateMockServerModal.tsx | 108 +++++++++++- .../src/panels/mocks/MocksSidebar.test.tsx | 58 ++++++ .../src/panels/mocks/MocksSidebar.tsx | 94 ++++++---- .../src/store/mockResolve.test.ts | 103 +++++++++++ .../ui-components/src/store/mockResolve.ts | 50 ++++++ .../src/store/mockTwoModes.test.ts | 113 ++++++++++++ .../ui-components/src/store/workspaceStore.ts | 88 +++++++--- 29 files changed, 956 insertions(+), 90 deletions(-) create mode 100644 packages/mcp-server/src/tools/mockRefresh.test.ts create mode 100644 packages/ui-components/src/panels/mocks/MocksSidebar.test.tsx create mode 100644 packages/ui-components/src/store/mockResolve.test.ts create mode 100644 packages/ui-components/src/store/mockResolve.ts create mode 100644 packages/ui-components/src/store/mockTwoModes.test.ts diff --git a/AGENTS.md b/AGENTS.md index 927c8b57..bb0200da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ but with: read the same source of truth the UI uses, no IPC required. - **Local mock servers**: describe an API in OpenAPI / Postman / Insomnia and run a Hono-backed mock on `localhost`. -- An **MCP server**: exposes the workspace as a 94-tool catalog any +- An **MCP server**: exposes the workspace as a 95-tool catalog any Model Context Protocol client (Codex Desktop, ChatGPT, Cursor, Copilot, Codex, Continue, Cline, Zed, Windsurf) can drive. - A **CLI** for headless use @@ -262,7 +262,7 @@ studio/ │ ├── git/ GitHub REST client + typed error taxonomy │ ├── ui-components/ ALL React UI + the Zustand store + IndexedDB persistence │ ├── mock-server-core/ Hono mock-server engine + OpenAPI/Postman/Insomnia parsers -│ ├── mcp-server/ stdio MCP host + 94-tool catalog + workspace providers +│ ├── mcp-server/ stdio MCP host + 95-tool catalog + workspace providers │ └── cli/ `apicircle` binary — mock / mcp / import / export / run / workspaces ├── examples/ Demo workspaces + a standalone mock-server example ├── docs/ Product + architecture + QA docs (see §9) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e0fc310..1865b0d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,19 @@ for spec-driven mock servers and code-vs-spec drift. (`@apicircle/shared`, `@apicircle/mock-server-core`, `@apicircle/ui-components`, `@apicircle/mcp-server`) +- **Mock servers from a spec asset — "run live" vs "import & edit" (additive).** + A mock server can now be built from an uploaded spec asset (`GlobalFileAsset`) + in two modes: **linked** ("run live" — endpoints derive from the asset and stay + in sync; read-only) or **materialized** ("import & edit" — parsed into editable + endpoints). New `MockServerSource` variant + `{ kind: 'openapi-asset', assetId, format, mode }` + an `isLinkedMockSource` + helper; the store resolves the asset's bytes → parses → materializes, auto- + refreshes linked mocks when the asset changes, and keeps linked mocks read-only + on every surface. New `refreshMockServer` store action + `mock.refresh` MCP tool + (catalog **94 → 95**). The "Create mock server" modal gains a **From spec + asset** tab (Run live / Import & edit). + (`@apicircle/shared`, `@apicircle/mock-server-core`, `@apicircle/ui-components`, + `@apicircle/mcp-server`, VS Code) - **UI sections/mode seam (open-core, additive).** Building on the `extraPanels` seam, the React shell (`@apicircle/ui-components`) now accepts edition-contributed top-level **sections** ("modes") via an optional `App` `sections` prop diff --git a/CLAUDE.md b/CLAUDE.md index 5d2fe04a..7726f1ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ but with: read the same source of truth the UI uses, no IPC required. - **Local mock servers**: describe an API in OpenAPI / Postman / Insomnia and run a Hono-backed mock on `localhost`. -- An **MCP server**: exposes the workspace as a 94-tool catalog any +- An **MCP server**: exposes the workspace as a 95-tool catalog any Model Context Protocol client (Claude Desktop, ChatGPT, Cursor, Copilot, Codex, Continue, Cline, Zed, Windsurf) can drive. - A **CLI** for headless use @@ -310,7 +310,7 @@ studio/ │ │ (root = Node + swagger-parser; `/parsing` subpath = │ │ browser-safe, in-document `$ref` only — used by the │ │ web/desktop renderer to materialize endpoints at import) -│ ├── mcp-server/ stdio MCP host + 94-tool catalog + workspace providers +│ ├── mcp-server/ stdio MCP host + 95-tool catalog + workspace providers │ ├── desktop-shell/ Reusable Electron main-process building blocks — │ │ OS-keychain secrets, mock / MCP / workspace-file IPC │ │ bridges, OAuth2 callback server, window-state. Composed diff --git a/README.md b/README.md index ff382f5c..cdf0c360 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ rebuilt around two ideas the others miss: branch. Teams collaborate the way they collaborate on code — branches, diffs, pull requests, review. 2. **Your workspace is an AI tool catalog.** A built-in Model Context Protocol - server exposes **94 tools**, so Claude, ChatGPT, Cursor, Copilot, and any + server exposes **95 tools**, so Claude, ChatGPT, Cursor, Copilot, and any other MCP client can read, author, and run requests on your behalf. No cloud account. No vendor lock-in. Your data stays on your machine and in @@ -80,7 +80,7 @@ The bundled `@apicircle/mcp-server` speaks the open [Model Context Protocol](https://modelcontextprotocol.io) over stdio, so it works with **Claude Desktop, Claude Code, ChatGPT, GitHub Copilot, Cursor, Continue, Cline, Zed, and Windsurf** — or anything else that talks MCP. The -94-tool catalog covers request and folder CRUD, environment authoring, +95-tool catalog covers request and folder CRUD, environment authoring, assertions, execution plans, history, mock-server lifecycle, the release ledger (publish / deprecate / withdraw), linked-workspace config (list / pin / scope / unlink), codebase scanning, imports, code generation, and @@ -437,7 +437,7 @@ packages/ shared/ Types, generateId, validators, encryption helpers git/ GitHub API client + sync logic mock-server-core/ Hono mock-server engine + OpenAPI/Postman/Insomnia parsers - mcp-server/ stdio MCP host with the 94-tool catalog + mcp-server/ stdio MCP host with the 95-tool catalog cli/ `apicircle` binary — mock / mocks / mcp / import / export / run / workspaces ``` diff --git a/apps/vscode/README.md b/apps/vscode/README.md index 37f4f9a5..be8a862a 100644 --- a/apps/vscode/README.md +++ b/apps/vscode/README.md @@ -47,7 +47,7 @@ VS Code is where the same engineers who use API Circle already live with Git. Ed ### MCP integration -- **94-tool MCP catalog** for AI clients — Claude Desktop, Claude Code, Codex, Cursor, Copilot, Windsurf, Zed, Continue, Cline. +- **95-tool MCP catalog** for AI clients — Claude Desktop, Claude Code, Codex, Cursor, Copilot, Windsurf, Zed, Continue, Cline. - **One-click Copilot Chat install** — writes `.vscode/mcp.json` idempotently. - **Per-client config snippets** — copy or direct-install MCP configuration. - **Curated prompts** — 19 starter prompts across 7 categories in the MCP sidebar. diff --git a/apps/vscode/src/fs/mockYaml.ts b/apps/vscode/src/fs/mockYaml.ts index 0bfd83d3..318b014f 100644 --- a/apps/vscode/src/fs/mockYaml.ts +++ b/apps/vscode/src/fs/mockYaml.ts @@ -42,6 +42,9 @@ interface MockYamlOutput { * the human-edited YAML. */ bytes?: number; + /** For openapi-asset: the Global File Asset id + linked/materialized mode. */ + assetId?: string; + mode?: 'linked' | 'materialized'; }; endpoints: Array<{ id: string; @@ -100,6 +103,14 @@ function serializeSource(source: MockServerSource): MockYamlOutput['source'] { if (source.kind === 'postman') { return { kind: 'postman', bytes: source.collection.length }; } + if (source.kind === 'openapi-asset') { + return { + kind: 'openapi-asset', + format: source.format, + assetId: source.assetId, + mode: source.mode, + }; + } return { kind: 'insomnia', bytes: source.export.length }; } diff --git a/docs/connect-your-ai-client.md b/docs/connect-your-ai-client.md index db5a97cf..e32972bd 100644 --- a/docs/connect-your-ai-client.md +++ b/docs/connect-your-ai-client.md @@ -1,6 +1,6 @@ # Connect your AI client -API Circle Studio's MCP server exposes 94 tools (request CRUD, environment authoring, plan creation, assertions, history, mock servers — including **`mock.set_default_port`** to pin a 1024-65535 port that survives across runs — code generation from collections, codebase scanning, imports, **folder export / import as JSON**, **Global File Asset library with provenance state**, **release ledger — publish / deprecate / withdraw the versions linked consumers pin to**, **linked-workspace config — list / pin / scope / unlink the workspaces you consume**, **GitHub network ops — link / refresh / tag-release / set-topics with a `token` or `GITHUB_TOKEN`**, **marketplace discovery — `marketplace.search` with sort by stars/updated/relevance**, prompt-driven authoring) over stdio. Any AI client that speaks the [Model Context Protocol](https://modelcontextprotocol.io) can drive the workspace. +API Circle Studio's MCP server exposes 95 tools (request CRUD, environment authoring, plan creation, assertions, history, mock servers — including **`mock.set_default_port`** to pin a 1024-65535 port that survives across runs — code generation from collections, codebase scanning, imports, **folder export / import as JSON**, **Global File Asset library with provenance state**, **release ledger — publish / deprecate / withdraw the versions linked consumers pin to**, **linked-workspace config — list / pin / scope / unlink the workspaces you consume**, **GitHub network ops — link / refresh / tag-release / set-topics with a `token` or `GITHUB_TOKEN`**, **marketplace discovery — `marketplace.search` with sort by stars/updated/relevance**, prompt-driven authoring) over stdio. Any AI client that speaks the [Model Context Protocol](https://modelcontextprotocol.io) can drive the workspace. > **Open standard.** MCP is not Anthropic-locked. Claude Desktop, ChatGPT, GitHub Copilot, Cursor, Continue, Cline, Zed, and Windsurf all support it. Snippets below cover the major clients; if yours isn't listed, the _Generic stdio_ section is the fallback. diff --git a/docs/context/api-circle.md b/docs/context/api-circle.md index aaad3858..79626bdf 100644 --- a/docs/context/api-circle.md +++ b/docs/context/api-circle.md @@ -25,7 +25,7 @@ Insomnia — with a handful of things that set it apart: external tools can read or edit the same source of truth the UI uses. - **Local mock servers.** Describe an API in OpenAPI / Postman / Insomnia and run a Hono-backed mock on `localhost`. -- **An MCP server.** The workspace is exposed as a 94-tool catalog any +- **An MCP server.** The workspace is exposed as a 95-tool catalog any Model Context Protocol client (Claude Desktop, ChatGPT, Cursor, GitHub Copilot, Codex, Continue, Cline, Zed, Windsurf) can drive. - **A CLI** (`apicircle mock | mcp | import | run | workspaces`) for @@ -64,7 +64,7 @@ studio/ │ ├── git/ GitHub REST client + typed error taxonomy │ ├── ui-components/ ALL React UI + the Zustand store + IndexedDB persistence │ ├── mock-server-core/ Hono mock engine + OpenAPI/Postman/Insomnia parsers -│ ├── mcp-server/ stdio MCP host + 94-tool catalog + workspace providers +│ ├── mcp-server/ stdio MCP host + 95-tool catalog + workspace providers │ └── cli/ `apicircle` binary — mock / mcp / import / run / workspaces ├── examples/ Demo workspaces + a standalone example mock server ├── docs/ Product + architecture + QA docs (see §16) @@ -214,7 +214,7 @@ Full matrix: [`docs/auth.md`](../auth.md). ## 7. MCP server -`@apicircle/mcp-server` exposes **94 tools** over stdio, namespaced by +`@apicircle/mcp-server` exposes **95 tools** over stdio, namespaced by capability: imports, code generation, multi-workspace discovery (`workspace.list`), workspace read/write, request / folder / environment / plan / assertion CRUD, **folder export / import as JSON** @@ -313,7 +313,7 @@ promises`, `consistent-type-imports`, `prefer-const`, `eqeqeq` are all The product surfaces are built and functional: the web + desktop apps, the 17 auth types, the mock-server engine across all three runtimes, -the 94-tool MCP server, the CLI with multi-workspace addressing, the +the 95-tool MCP server, the CLI with multi-workspace addressing, the disk-mirror persistence layer, the MCP **Connection / Prompts** panel sections, the Settings → Community surface, and the GitHub Pages web deploy. Unit tests are green. @@ -401,7 +401,7 @@ Because the product is pre-launch with zero users: | [`docs/architecture/platform.md`](../architecture/platform.md) | MCP / mock engine / CLI / desktop design record | | [`docs/auth.md`](../auth.md) | The 17-auth-type matrix | | [`docs/mock-server.md`](../mock-server.md) | Mock server feature guide | -| [`docs/mcp-tools-reference.md`](../mcp-tools-reference.md) | MCP tool catalog reference (94 tools) | +| [`docs/mcp-tools-reference.md`](../mcp-tools-reference.md) | MCP tool catalog reference (95 tools) | | [`docs/connect-your-ai-client.md`](../connect-your-ai-client.md) | Wiring an MCP client | | [`docs/installing.md`](../installing.md) | Install instructions | | [`docs/qa/README.md`](../qa/README.md) | QA status, E2E CI reference, coverage tooling | diff --git a/docs/mcp-tools-reference.md b/docs/mcp-tools-reference.md index e78eddd4..efbe7823 100644 --- a/docs/mcp-tools-reference.md +++ b/docs/mcp-tools-reference.md @@ -1,6 +1,6 @@ # MCP tool catalog reference -The `@apicircle/mcp-server` host exposes 94 tools, namespaced by capability area. The full list is canonical in [`packages/shared/src/mcp.ts`](../packages/shared/src/mcp.ts) and registered in [`packages/mcp-server/src/tools/registry.ts`](../packages/mcp-server/src/tools/registry.ts). +The `@apicircle/mcp-server` host exposes 95 tools, namespaced by capability area. The full list is canonical in [`packages/shared/src/mcp.ts`](../packages/shared/src/mcp.ts) and registered in [`packages/mcp-server/src/tools/registry.ts`](../packages/mcp-server/src/tools/registry.ts). ## Imports @@ -179,16 +179,17 @@ These tools accept LLM-shaped JSON envelopes — flat, sensible defaults, ids au Endpoint-level editing for manual-mode mock servers. Validation- and response-rule shapes mirror the `prompt.set_endpoint_*` tools above. -| Tool | Input | -| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mock.list_endpoints` | `{ mockId }` → `[{ id, method, path, name }]` | -| `mock.add_endpoint` | `{ mockId, method, pathPattern, name?, description?, response? }` — defaults to a `200` JSON `{}` response | -| `mock.update_endpoint` | `{ mockId, endpointId, method?, pathPattern?, name?, description?, ... }` — patches only the supplied fields | -| `mock.delete_endpoint` | `{ mockId, endpointId }` | -| `mock.set_validation_rules` | `{ mockId, endpointId, rules: [{ kind, target, expected?, message?, enabled?, failResponse? }] }` — empty array clears | -| `mock.set_response_rules` | `{ mockId, endpointId, rules: [{ name, enabled?, when: [...], response }] }` — first match wins; empty array falls back to `defaultResponse` | -| `mock.set_multipliers` | `{ mockId, endpointId, multipliers: [{ source, targetJsonPath, defaultCount, min?, max? }] }` — capped at MAX_RESPONSE_MULTIPLIERS (1); empty array clears | -| `mock.set_request_schema` | `{ mockId, endpointId, pathParams?, queryParams?, headers?, cookies?: [{ name, typeHint?, required?, description?, example? }], body?: { description?, example? } }` — declares the endpoint's expected inputs (documentation + OpenAPI export); omitted lists clear | +| Tool | Input | +| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mock.list_endpoints` | `{ mockId }` → `[{ id, method, path, name }]` | +| `mock.refresh` | `{ id }` — re-derive endpoints from `source` (re-parse the spec) after the spec changed. Inline sources re-parse; asset-backed (`openapi-asset`) mocks refresh in the desktop/web app. Returns `{ ok, endpointCount, warnings }`. Endpoint edits on a linked mock are rejected as read-only. | +| `mock.add_endpoint` | `{ mockId, method, pathPattern, name?, description?, response? }` — defaults to a `200` JSON `{}` response | +| `mock.update_endpoint` | `{ mockId, endpointId, method?, pathPattern?, name?, description?, ... }` — patches only the supplied fields | +| `mock.delete_endpoint` | `{ mockId, endpointId }` | +| `mock.set_validation_rules` | `{ mockId, endpointId, rules: [{ kind, target, expected?, message?, enabled?, failResponse? }] }` — empty array clears | +| `mock.set_response_rules` | `{ mockId, endpointId, rules: [{ name, enabled?, when: [...], response }] }` — first match wins; empty array falls back to `defaultResponse` | +| `mock.set_multipliers` | `{ mockId, endpointId, multipliers: [{ source, targetJsonPath, defaultCount, min?, max? }] }` — capped at MAX_RESPONSE_MULTIPLIERS (1); empty array clears | +| `mock.set_request_schema` | `{ mockId, endpointId, pathParams?, queryParams?, headers?, cookies?: [{ name, typeHint?, required?, description?, example? }], body?: { description?, example? } }` — declares the endpoint's expected inputs (documentation + OpenAPI export); omitted lists clear | Prompt-shaped (LLM-friendly, fresh ids) authoring variants live alongside the `prompt.*` tools, including **`prompt.set_endpoint_request_schema`** (same fields, every param re-id'd). diff --git a/docs/mock-server.md b/docs/mock-server.md index 739edb59..82356291 100644 --- a/docs/mock-server.md +++ b/docs/mock-server.md @@ -114,6 +114,22 @@ parsed immediately and the resulting `MockEndpoint[]` is stored on `MockServer.endpoints`. The runtime router serves that array verbatim and never re-parses `source`, so a mock created with zero endpoints stays empty. +### Spec sources: paste, or a spec asset (run live / import) + +A spec-backed mock's `source` is either a **verbatim inline spec** +(`{ kind: 'openapi', spec, format }`, from the paste-spec flow) or a reference to +an uploaded **spec asset** (`{ kind: 'openapi-asset', assetId, format, mode }` — +upload the OpenAPI/Swagger file under Global Assets → Files). Asset-backed mocks +come in two modes: + +- **`linked` ("run live")** — endpoints derive from the asset and stay in sync: + re-uploading the spec asset auto-refreshes every linked mock, and the endpoints + are read-only (edits are rejected on every surface). Best for "just stand up my + spec." +- **`materialized` ("import & edit")** — the spec is parsed once into editable + endpoints you can modify; an explicit refresh (`refreshMockServer` / the + `mock.refresh` MCP tool) re-imports from the asset. + The parser ships as two entry points that differ only in how OpenAPI `$ref`s are dereferenced: diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 2765c4a5..43757969 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -6,12 +6,12 @@

Give your AI assistant a real API client.
- A Model Context Protocol server that exposes the API Circle Studio workspace as a 94-tool catalog — so Claude, ChatGPT, Cursor, Copilot, and every other MCP client can read, author, mock, and run requests for you. + A Model Context Protocol server that exposes the API Circle Studio workspace as a 95-tool catalog — so Claude, ChatGPT, Cursor, Copilot, and every other MCP client can read, author, mock, and run requests for you.

npm version - 94 MCP tools + 95 MCP tools stdio transport Multi-workspace Node ≥ 20 @@ -140,7 +140,7 @@ In multi-workspace mode the assistant gets two extra surfaces: Entity tools (`request.read`, `environment.create`, `mock.start`, etc.) all default to the active workspace — multi-workspace scoping is opt-in per call. -## The tool catalog (94 tools) +## The tool catalog (95 tools) The full list lives at [`docs/mcp-tools-reference.md`](https://github.com/apicircle/studio/blob/main/docs/mcp-tools-reference.md). diff --git a/packages/mcp-server/src/tools/mockRefresh.test.ts b/packages/mcp-server/src/tools/mockRefresh.test.ts new file mode 100644 index 00000000..fa8dcbfa --- /dev/null +++ b/packages/mcp-server/src/tools/mockRefresh.test.ts @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { MockServer, WorkspaceLocal, WorkspaceSynced } from '@apicircle/shared'; +import { InMemoryWorkspaceProvider } from '../providers/InMemoryWorkspaceProvider'; +import { SingleWorkspaceAdapter } from '../providers/Workspaces'; +import { InProcessMockController } from '../providers/InProcessMockController'; +import { + mockAddEndpointTool, + mockCreateFromOpenApiTool, + mockRefreshTool, + mockUpdateEndpointTool, +} from './mocks'; + +const T0 = '2026-04-27T00:00:00.000Z'; + +const openapi = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'P', version: '1' }, + paths: { '/pets': { get: { responses: { '200': { description: 'ok' } } } } }, +}); + +function linkedMock(id: string): MockServer { + return { + id, + name: 'Linked', + source: { kind: 'openapi-asset', assetId: 'a1', format: 'json', mode: 'linked' }, + endpoints: [ + { + id: 'e1', + name: 'GET /pets', + method: 'GET', + pathPattern: '/pets', + requestSchema: { pathParams: [], queryParams: [], headers: [], cookies: [] }, + requestValidation: [], + responseRules: [], + defaultResponse: { status: 200, headers: [], body: { type: 'json', content: '{}' } }, + }, + ], + defaultPort: null, + cors: { enabled: false, origins: [] }, + createdAt: T0, + updatedAt: T0, + }; +} + +function freshState(mocks: Record = {}): { + synced: WorkspaceSynced; + local: WorkspaceLocal; +} { + return { + synced: { + schemaVersion: 1, + workspaceId: 'ws-1', + collections: { tree: { id: 'r', type: 'root', children: [] }, requests: {}, folders: {} }, + environments: { items: {}, activeName: null, priorityOrder: [] }, + linkedWorkspaces: {}, + linkedOverrides: { requests: {}, environmentVars: {} }, + releases: { self: null, perLink: {} }, + globalAssets: { schemas: {}, graphql: {} }, + mockServers: mocks, + meta: { createdAt: T0, updatedAt: T0, appVersion: '0.1.0' }, + }, + local: { + schemaVersion: 1, + workspaceId: 'ws-1', + executionPlans: {}, + history: { requestRuns: [], planRuns: [] }, + secretIndex: { entries: {} }, + sessions: { github: { workspace: null, links: {} } }, + connectedRepo: null, + workingBranch: null, + seededWorkspaceSha: null, + retiredBranch: null, + sync: { lastPulledSnapshot: null, lastPulledSha: null, lastPulledAt: null, dirtyKeys: [] }, + linkedCollections: {}, + globalContext: {}, + mockRuntime: { active: {} }, + ui: { + activeRequestId: null, + sidebarExpandedSections: [], + themeId: 'studio-dark', + fontId: 'system-mono', + fontSizePercent: 100, + }, + settings: { validateOnSend: true, monacoConsumesWheel: false }, + snapshots: { entries: [], maxBytes: 50 * 1024 * 1024 }, + }, + }; +} + +let ctx: { + workspace: InMemoryWorkspaceProvider; + workspaces: SingleWorkspaceAdapter; + mock: InProcessMockController; +}; + +function setupCtx(state: { synced: WorkspaceSynced; local: WorkspaceLocal }) { + const workspace = new InMemoryWorkspaceProvider(state); + ctx = { + workspace, + workspaces: new SingleWorkspaceAdapter(workspace, 'ws-test'), + mock: new InProcessMockController(), + }; +} + +beforeEach(() => { + setupCtx(freshState()); +}); + +describe('mock.refresh', () => { + it('re-derives endpoints for an inline-spec mock', async () => { + const created = (await mockCreateFromOpenApiTool.handler( + { name: 'API', spec: openapi, format: 'json' }, + ctx, + )) as { id: string }; + const out = (await mockRefreshTool.handler({ id: created.id }, ctx)) as { + ok: boolean; + endpointCount: number; + }; + expect(out.ok).toBe(true); + expect(out.endpointCount).toBe(1); + }); + + it('refuses to refresh an asset-backed mock (no attachment bytes over MCP)', async () => { + setupCtx(freshState({ m1: linkedMock('m1') })); + const out = (await mockRefreshTool.handler({ id: 'm1' }, ctx)) as { + ok: boolean; + error: string; + }; + expect(out.ok).toBe(false); + expect(out.error).toMatch(/backed by a spec asset/); + }); + + it('returns not found for an unknown mock', async () => { + const out = (await mockRefreshTool.handler({ id: 'nope' }, ctx)) as { + ok: boolean; + error: string; + }; + expect(out.ok).toBe(false); + expect(out.error).toBe('mock not found'); + }); +}); + +describe('linked mocks are read-only over MCP', () => { + beforeEach(() => { + setupCtx(freshState({ m1: linkedMock('m1') })); + }); + + it('rejects mock.add_endpoint', async () => { + const out = (await mockAddEndpointTool.handler( + { mockId: 'm1', method: 'POST', pathPattern: '/x' }, + ctx, + )) as { ok: boolean; error: string }; + expect(out.ok).toBe(false); + expect(out.error).toMatch(/read-only/); + }); + + it('rejects mock.update_endpoint', async () => { + const out = (await mockUpdateEndpointTool.handler( + { mockId: 'm1', endpointId: 'e1', pathPattern: '/y' }, + ctx, + )) as { ok: boolean; error: string }; + expect(out.ok).toBe(false); + expect(out.error).toMatch(/read-only/); + }); +}); diff --git a/packages/mcp-server/src/tools/mocks.ts b/packages/mcp-server/src/tools/mocks.ts index c8938a36..6abceaad 100644 --- a/packages/mcp-server/src/tools/mocks.ts +++ b/packages/mcp-server/src/tools/mocks.ts @@ -7,6 +7,7 @@ import type { } from '@apicircle/shared'; import { generateId, + isLinkedMockSource, makeDefaultMockResponse, makeDefaultRequestSchema, MAX_RESPONSE_MULTIPLIERS, @@ -41,6 +42,18 @@ async function ingestSource( return { mock, warnings }; } +/** Reject endpoint edits on a "run live" (linked) mock — its endpoints are + * derived from a spec asset and would be clobbered on the next refresh. */ +function linkedReadOnly(mock: MockServer): { ok: false; error: string } | null { + return isLinkedMockSource(mock.source) + ? { + ok: false, + error: + 'This mock is linked to a spec asset and is read-only. Edit the spec asset, or recreate the mock in "Import & edit" (materialized) mode.', + } + : null; +} + export const mockCreateFromOpenApiTool: AnyToolDef = { name: 'mock.create_from_openapi', description: 'Create a mock server from an OpenAPI / Swagger spec (YAML or JSON).', @@ -305,6 +318,34 @@ export const mockListEndpointsTool: AnyToolDef = { }, }; +export const mockRefreshTool: AnyToolDef = { + name: 'mock.refresh', + description: + "Re-derive a mock server's endpoints from its source (re-parse the spec) — use after the underlying spec changed. Inline sources (openapi / postman / insomnia) re-parse directly; asset-backed (openapi-asset) mocks must be refreshed from the desktop/web app, which owns the attachment bytes. Returns the new endpoint count + parser warnings.", + inputSchema: z.object({ id: z.string() }), + async handler(input, ctx) { + const state = await ctx.workspace.read(); + const mock = state.synced.mockServers[input.id]; + if (!mock) return { ok: false as const, error: 'mock not found' as const }; + if (mock.source.kind === 'openapi-asset') { + return { + ok: false as const, + error: + 'This mock is backed by a spec asset — refresh it from the desktop/web app (the MCP host cannot read attachment bytes).', + }; + } + const { endpoints, warnings } = await parseSourceToEndpoints(mock.source); + const next: MockServer = { ...mock, endpoints, updatedAt: new Date().toISOString() }; + const out = await ctx.workspace.apply({ kind: 'mock.upsert', mock: next }); + return { + ok: true as const, + endpointCount: endpoints.length, + changedIds: out.changedIds, + warnings, + }; + }, +}; + const ENDPOINT_RESPONSE = z.object({ status: z.number().int().min(100).max(599).default(200), jsonBody: z.string().default('{}'), @@ -358,6 +399,8 @@ export const mockAddEndpointTool: AnyToolDef = { const state = await ctx.workspace.read(); const mock = state.synced.mockServers[input.mockId]; if (!mock) return { ok: false, error: 'mock not found' as const }; + const ro = linkedReadOnly(mock); + if (ro) return ro; const endpoint = buildDefaultEndpoint(input); const nextEndpoints = [...mock.endpoints, endpoint]; // Manual-mode mocks mirror endpoints back into source so the runtime @@ -394,6 +437,8 @@ export const mockUpdateEndpointTool: AnyToolDef = { const state = await ctx.workspace.read(); const mock = state.synced.mockServers[input.mockId]; if (!mock) return { ok: false, error: 'mock not found' as const }; + const ro = linkedReadOnly(mock); + if (ro) return ro; const idx = mock.endpoints.findIndex((e) => e.id === input.endpointId); if (idx === -1) return { ok: false, error: 'endpoint not found' as const }; const existing = mock.endpoints[idx]; @@ -446,6 +491,8 @@ export const mockDeleteEndpointTool: AnyToolDef = { const state = await ctx.workspace.read(); const mock = state.synced.mockServers[input.mockId]; if (!mock) return { ok: false, error: 'mock not found' as const }; + const ro = linkedReadOnly(mock); + if (ro) return ro; const nextEndpoints = mock.endpoints.filter((e) => e.id !== input.endpointId); if (nextEndpoints.length === mock.endpoints.length) { return { ok: false, error: 'endpoint not found' as const }; @@ -578,6 +625,8 @@ export const mockSetValidationRulesTool: AnyToolDef = { const state = await ctx.workspace.read(); const mock = state.synced.mockServers[input.mockId]; if (!mock) return { ok: false as const, error: 'mock not found' as const }; + const ro = linkedReadOnly(mock); + if (ro) return ro; const rules: Array> = input.rules; const next = patchEndpoint(mock, input.endpointId, (e) => ({ ...e, @@ -610,6 +659,8 @@ export const mockSetResponseRulesTool: AnyToolDef = { const state = await ctx.workspace.read(); const mock = state.synced.mockServers[input.mockId]; if (!mock) return { ok: false as const, error: 'mock not found' as const }; + const ro = linkedReadOnly(mock); + if (ro) return ro; const rules: Array> = input.rules; const next = patchEndpoint(mock, input.endpointId, (e) => ({ ...e, @@ -672,6 +723,8 @@ export const mockSetRequestSchemaTool: AnyToolDef = { const state = await ctx.workspace.read(); const mock = state.synced.mockServers[input.mockId]; if (!mock) return { ok: false as const, error: 'mock not found' as const }; + const ro = linkedReadOnly(mock); + if (ro) return ro; const toParams = (list: Array>) => list.map((p) => ({ id: p.id ?? generateId(), @@ -710,6 +763,8 @@ export const mockSetMultipliersTool: AnyToolDef = { const state = await ctx.workspace.read(); const mock = state.synced.mockServers[input.mockId]; if (!mock) return { ok: false as const, error: 'mock not found' as const }; + const ro = linkedReadOnly(mock); + if (ro) return ro; const multipliers: Array> = input.multipliers; if (multipliers.length > MAX_RESPONSE_MULTIPLIERS) { return { ok: false as const, error: 'too many multipliers' as const }; diff --git a/packages/mcp-server/src/tools/registry.ts b/packages/mcp-server/src/tools/registry.ts index 904fc144..6b9edf6b 100644 --- a/packages/mcp-server/src/tools/registry.ts +++ b/packages/mcp-server/src/tools/registry.ts @@ -79,6 +79,7 @@ import { mockCreateManualTool, mockListTool, mockListEndpointsTool, + mockRefreshTool, mockStartTool, mockStopTool, mockDeleteTool, @@ -184,6 +185,7 @@ export const TOOL_REGISTRY: AnyToolDef[] = [ mockCreateManualTool, mockListTool, mockListEndpointsTool, + mockRefreshTool, mockStartTool, mockStopTool, mockDeleteTool, diff --git a/packages/mock-server-core/src/parsing.test.ts b/packages/mock-server-core/src/parsing.test.ts index c4e59475..cb7cb69b 100644 --- a/packages/mock-server-core/src/parsing.test.ts +++ b/packages/mock-server-core/src/parsing.test.ts @@ -82,6 +82,17 @@ describe('parseSourceToEndpoints (browser dispatch)', () => { expect(endpoints).toHaveLength(1); }); + it('warns when an unresolved openapi-asset source reaches the parser', async () => { + const { endpoints, warnings } = await parseSourceToEndpoints({ + kind: 'openapi-asset', + assetId: 'a1', + format: 'json', + mode: 'linked', + }); + expect(endpoints).toEqual([]); + expect(warnings[0]).toMatch(/openapi-asset source reached the parser unresolved/); + }); + it('returns manual endpoints verbatim', async () => { const endpoint = { id: 'e1', diff --git a/packages/mock-server-core/src/parsing.ts b/packages/mock-server-core/src/parsing.ts index 750f1296..0e05efe8 100644 --- a/packages/mock-server-core/src/parsing.ts +++ b/packages/mock-server-core/src/parsing.ts @@ -56,6 +56,17 @@ export async function parseSourceToEndpointsWith( return parseInsomniaToEndpoints(source.export); case 'manual': return { endpoints: source.endpoints, warnings: [] }; + case 'openapi-asset': + // Asset-backed sources must be resolved to an inline OpenAPI source by + // the caller (the UI store reads the asset's bytes; the MCP host reads + // the attachment blob) before reaching the parser — this engine owns no + // asset store. Reaching here means the bytes weren't resolved. + return { + endpoints: [], + warnings: [ + 'An openapi-asset source reached the parser unresolved — resolve its bytes to an inline OpenAPI source first.', + ], + }; } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d6afc90d..4cc998fa 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -62,6 +62,7 @@ export { makeDefaultRequestSchema, MAX_RESPONSE_MULTIPLIERS, MAX_RESPONSE_RULE_CONDITIONS, + isLinkedMockSource, } from './mock'; export type { FontFamilyId, diff --git a/packages/shared/src/mcp.ts b/packages/shared/src/mcp.ts index 1b7f9dfd..f8cd48af 100644 --- a/packages/shared/src/mcp.ts +++ b/packages/shared/src/mcp.ts @@ -101,6 +101,7 @@ export type McpToolName = | 'mock.create_manual' | 'mock.list' | 'mock.list_endpoints' + | 'mock.refresh' | 'mock.start' | 'mock.stop' | 'mock.delete' @@ -212,6 +213,7 @@ export const MCP_TOOL_NAMES: ReadonlyArray = [ 'mock.create_manual', 'mock.list', 'mock.list_endpoints', + 'mock.refresh', 'mock.start', 'mock.stop', 'mock.delete', diff --git a/packages/shared/src/mock.test.ts b/packages/shared/src/mock.test.ts index 13112288..6d8510ab 100644 --- a/packages/shared/src/mock.test.ts +++ b/packages/shared/src/mock.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { coerceMockResponseBodyTypeForStatus, getAllowedMockResponseBodyTypes, + isLinkedMockSource, MAX_RESPONSE_MULTIPLIERS, MAX_RESPONSE_RULE_CONDITIONS, } from './mock'; @@ -12,6 +13,7 @@ describe('MockServerSource discriminator', () => { const sources: MockServerSource[] = [ { kind: 'openapi', spec: '{}', format: 'json' }, { kind: 'openapi', spec: 'paths: {}\n', format: 'yaml' }, + { kind: 'openapi-asset', assetId: 'a1', format: 'json', mode: 'linked' }, { kind: 'postman', collection: '{"info":{}}' }, { kind: 'insomnia', export: '{"resources":[]}' }, { @@ -40,6 +42,10 @@ describe('MockServerSource discriminator', () => { expect(s.spec).toBeDefined(); expect(['json', 'yaml']).toContain(s.format); break; + case 'openapi-asset': + expect(s.assetId).toBeDefined(); + expect(['linked', 'materialized']).toContain(s.mode); + break; case 'postman': expect(s.collection).toBeDefined(); break; @@ -168,3 +174,26 @@ describe('MockRuntime', () => { expect(rt.active['m-2']).toBeUndefined(); }); }); + +describe('isLinkedMockSource', () => { + it('is true only for an openapi-asset source in linked mode', () => { + expect( + isLinkedMockSource({ kind: 'openapi-asset', assetId: 'a', format: 'json', mode: 'linked' }), + ).toBe(true); + }); + + it('is false for a materialized asset source and every inline/manual source', () => { + expect( + isLinkedMockSource({ + kind: 'openapi-asset', + assetId: 'a', + format: 'json', + mode: 'materialized', + }), + ).toBe(false); + expect(isLinkedMockSource({ kind: 'openapi', spec: '{}', format: 'json' })).toBe(false); + expect(isLinkedMockSource({ kind: 'manual', endpoints: [] })).toBe(false); + expect(isLinkedMockSource({ kind: 'postman', collection: '{}' })).toBe(false); + expect(isLinkedMockSource({ kind: 'insomnia', export: '{}' })).toBe(false); + }); +}); diff --git a/packages/shared/src/mock.ts b/packages/shared/src/mock.ts index 80a0dfce..a35e6c73 100644 --- a/packages/shared/src/mock.ts +++ b/packages/shared/src/mock.ts @@ -262,10 +262,36 @@ export interface MockEndpoint { export type MockServerSource = | { kind: 'openapi'; spec: string; format: 'json' | 'yaml' } + | { + // A spec-typed Global File Asset (see `GlobalFileAsset.spec`) drives this + // mock. Unlike `kind: 'openapi'` (a verbatim inline copy), the bytes live + // once in the asset library and are resolved on create / refresh. + kind: 'openapi-asset'; + /** id of the Global File Asset holding the OpenAPI/Swagger document. */ + assetId: string; + format: 'json' | 'yaml'; + /** + * `linked` — endpoints are derived live from the asset and kept in sync + * when the asset changes; they are NOT hand-editable ("run the spec + * directly"). `materialized` — parsed once into editable endpoints the + * user can modify ("import & edit"); an explicit refresh re-imports. + */ + mode: 'linked' | 'materialized'; + } | { kind: 'postman'; collection: string } | { kind: 'insomnia'; export: string } | { kind: 'manual'; endpoints: MockEndpoint[] }; +/** + * True when a mock's endpoints are derived live from a spec asset and must not + * be hand-edited — the source is an asset in `linked` mode. Endpoint-mutating + * store actions and MCP tools consult this to stay read-only, so "run the spec + * directly" mocks always reflect the asset. + */ +export function isLinkedMockSource(source: MockServerSource): boolean { + return source.kind === 'openapi-asset' && source.mode === 'linked'; +} + export interface MockServer { id: string; name: string; diff --git a/packages/ui-components/src/panels/help/helpContent.ts b/packages/ui-components/src/panels/help/helpContent.ts index e4245e72..19857e10 100644 --- a/packages/ui-components/src/panels/help/helpContent.ts +++ b/packages/ui-components/src/panels/help/helpContent.ts @@ -967,6 +967,7 @@ Snapshots live only on this machine. They are kept within a size budget — pick - **Empty** — a blank server you add endpoints to by hand. - **From a spec** — paste an OpenAPI, Postman, or Insomnia source; it is parsed into endpoints the moment you create the mock, so the endpoint table is populated right away. On the Desktop app the parse runs in the native process and resolves external \`$ref\`s; the web app resolves in-document \`$ref\`s only and warns about any external references it can't follow. +- **From a spec asset** — build the mock from an OpenAPI/Swagger file you uploaded to Global Assets → Files, in one of two modes. **Run live** (linked) derives the endpoints from the asset and keeps them in sync — re-uploading the spec updates every linked mock, and its endpoints are read-only. **Import & edit** (materialized) parses the spec into editable endpoints you can modify, with a refresh-from-spec re-import when the asset changes. ## Endpoints and the response flow diff --git a/packages/ui-components/src/panels/mocks/CreateMockServerModal.test.tsx b/packages/ui-components/src/panels/mocks/CreateMockServerModal.test.tsx index e908e1b1..cb01b93f 100644 --- a/packages/ui-components/src/panels/mocks/CreateMockServerModal.test.tsx +++ b/packages/ui-components/src/panels/mocks/CreateMockServerModal.test.tsx @@ -96,4 +96,36 @@ describe('CreateMockServerModal', () => { await user.click(screen.getByRole('button', { name: /Done/i })); await waitFor(() => expect(useWorkspaceStore.getState().mocksCreateModalOpen).toBe(false)); }); + + it('shows an empty state on the spec-asset tab when no spec assets exist', async () => { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /From spec asset/i })); + expect(screen.getByText(/No spec assets yet/i)).toBeInTheDocument(); + }); + + it('creates a "run live" (linked) mock from a spec asset', async () => { + const user = userEvent.setup(); + // Seed a spec asset — parse-on-upload tags it as an OpenAPI doc. + await act(async () => { + await useWorkspaceStore + .getState() + .addGlobalFileAsset( + new File([OPENAPI_JSON], 'petstore.json', { type: 'application/json' }), + ); + }); + const assetId = Object.values(useWorkspaceStore.getState().synced!.globalAssets.files!)[0].id; + + await user.type(screen.getByLabelText('Mock server name'), 'Live petstore'); + await user.click(screen.getByRole('button', { name: /From spec asset/i })); + await user.selectOptions(await screen.findByLabelText('Spec asset'), assetId); + await user.click(screen.getByRole('button', { name: /Create mock server/i })); + + await waitFor(() => expect(useWorkspaceStore.getState().mocksCreateModalOpen).toBe(false)); + const created = Object.values(useWorkspaceStore.getState().synced!.mockServers).find( + (m) => m.name === 'Live petstore', + ); + expect(created?.source.kind).toBe('openapi-asset'); + if (created?.source.kind === 'openapi-asset') expect(created.source.mode).toBe('linked'); + expect(created?.endpoints.length).toBe(1); + }); }); diff --git a/packages/ui-components/src/panels/mocks/CreateMockServerModal.tsx b/packages/ui-components/src/panels/mocks/CreateMockServerModal.tsx index bb8d5e17..4933297c 100644 --- a/packages/ui-components/src/panels/mocks/CreateMockServerModal.tsx +++ b/packages/ui-components/src/panels/mocks/CreateMockServerModal.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { AlertTriangle, FileCode, Info, Plus } from 'lucide-react'; import type { MockServerSource } from '@apicircle/shared'; import { useWorkspaceStore } from '../../store/workspaceStore'; @@ -28,7 +28,7 @@ export function CreateMockServerModal() { // in the Node main process; otherwise we're browser-only (web app). const canResolveExternalRefs = getDesktopMockBridge()?.parseSpec != null; - const [tab, setTab] = useState<'manual' | 'spec'>('manual'); + const [tab, setTab] = useState<'manual' | 'spec' | 'asset'>('manual'); const [name, setName] = useState(''); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); @@ -40,11 +40,19 @@ export function CreateMockServerModal() { // closing on a partially-resolved import. const [result, setResult] = useState<{ endpointCount: number; warnings: string[] } | null>(null); + // Spec-typed Global File Assets available to build a mock from (Increment A). + const files = useWorkspaceStore((s) => s.synced?.globalAssets.files); + const specAssets = useMemo(() => Object.values(files ?? {}).filter((f) => f.spec), [files]); + const [assetId, setAssetId] = useState(''); + const [mockMode, setMockMode] = useState<'linked' | 'materialized'>('linked'); + const reset = () => { setName(''); setSpecKind('openapi'); setSpecFormat('json'); setSpecText(''); + setAssetId(''); + setMockMode('linked'); setError(null); setResult(null); setTab('manual'); @@ -57,6 +65,18 @@ export function CreateMockServerModal() { let source: MockServerSource; if (tab === 'manual') { source = { kind: 'manual', endpoints: [] }; + } else if (tab === 'asset') { + const asset = specAssets.find((f) => f.id === assetId); + if (!asset?.spec) { + setError('Select a spec asset to build the mock from.'); + return; + } + source = { + kind: 'openapi-asset', + assetId: asset.id, + format: asset.spec.format, + mode: mockMode, + }; } else { if (!specText.trim()) { setError('Paste the spec content.'); @@ -83,7 +103,7 @@ export function CreateMockServerModal() { const { id, warnings } = await createMockServer({ name, source }); // Activate the new server so the panel surfaces its endpoint list. setActiveMockEndpoint({ serverId: id, endpointId: null }); - if (tab === 'spec') { + if (tab !== 'manual') { const endpointCount = useWorkspaceStore.getState().synced?.mockServers[id]?.endpoints.length ?? 0; if (warnings.length > 0 || endpointCount === 0) { @@ -191,6 +211,14 @@ export function CreateMockServerModal() {

{tab === 'manual' ? ( @@ -200,6 +228,78 @@ export function CreateMockServerModal() { add endpoints — you can edit method, path, response body, headers, and rules per endpoint there.

+ ) : tab === 'asset' ? ( +
+

+ Build a mock from a spec you’ve uploaded to Global Assets. Upload the + OpenAPI/Swagger file in the Assets → Files tab first. +

+ {specAssets.length === 0 ? ( +
+
+ ) : ( + <> + + +
+ Mode + + +
+ + )} +
) : (

@@ -270,7 +370,7 @@ export function CreateMockServerModal() { @@ -207,26 +227,28 @@ export function MocksSidebar() { visibleEndpoints.map((endpoint) => { const isActive = activeServerId === server.id && activeEndpointId === endpoint.id; - const endpointItems: KebabMenuItem[] = [ - { - id: 'duplicate', - label: 'Duplicate', - icon:

  • - + {endpointItems.length > 0 && ( + + )}
  • ); diff --git a/packages/ui-components/src/store/mockResolve.test.ts b/packages/ui-components/src/store/mockResolve.test.ts new file mode 100644 index 00000000..089048b4 --- /dev/null +++ b/packages/ui-components/src/store/mockResolve.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import type { WorkspaceSynced } from '@apicircle/shared'; +import { putAttachment } from '../persistence/attachments'; +import { resolveMockEndpoints } from './mockResolve'; + +const openapi = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Petstore', version: '1.0' }, + paths: { + '/pets': { get: { responses: { '200': { description: 'ok' } } } }, + '/pets/{id}': { get: { responses: { '200': { description: 'ok' } } } }, + }, +}); + +const baseSynced = (files: WorkspaceSynced['globalAssets']['files'] = {}): WorkspaceSynced => ({ + schemaVersion: 1, + workspaceId: 'ws-1', + collections: { tree: { id: 'r', type: 'root', children: [] }, requests: {}, folders: {} }, + environments: { items: {}, activeName: null, priorityOrder: [] }, + linkedWorkspaces: {}, + linkedOverrides: { requests: {}, environmentVars: {} }, + releases: { self: null, perLink: {} }, + globalAssets: { schemas: {}, graphql: {}, files }, + mockServers: {}, + meta: { createdAt: 't', updatedAt: 't', appVersion: '0.1.0' }, +}); + +async function seedAttachment(slotId: string): Promise { + await putAttachment({ + slotId, + filename: 'petstore.json', + mimeType: 'application/json', + size: openapi.length, + sha256: 'sha', + savedAt: 't', + bytes: new TextEncoder().encode(openapi), + }); +} + +describe('resolveMockEndpoints', () => { + it('returns the inline endpoints for a manual source', async () => { + const out = await resolveMockEndpoints({ kind: 'manual', endpoints: [] }, baseSynced()); + expect(out).toEqual({ endpoints: [], warnings: [] }); + }); + + it('resolves an openapi-asset source from IDB bytes and parses it', async () => { + await seedAttachment('slot-1'); + const synced = baseSynced({ + a1: { + id: 'a1', + name: 'Petstore', + slotId: 'slot-1', + filename: 'petstore.json', + size: openapi.length, + mimeType: 'application/json', + createdAt: 't', + updatedAt: 't', + spec: { + dialect: 'openapi-3', + format: 'json', + operationCount: 2, + parsedAt: 't', + warnings: [], + }, + }, + }); + const out = await resolveMockEndpoints( + { kind: 'openapi-asset', assetId: 'a1', format: 'json', mode: 'linked' }, + synced, + ); + expect(out.endpoints.length).toBe(2); + }); + + it('warns when the asset is not in the workspace', async () => { + const out = await resolveMockEndpoints( + { kind: 'openapi-asset', assetId: 'missing', format: 'json', mode: 'linked' }, + baseSynced(), + ); + expect(out.endpoints).toEqual([]); + expect(out.warnings[0]).toMatch(/was not found/); + }); + + it('warns when the asset bytes are not in IDB', async () => { + const synced = baseSynced({ + a2: { + id: 'a2', + name: 'Gone', + slotId: 'slot-absent', + filename: 'gone.json', + size: 1, + mimeType: 'application/json', + createdAt: 't', + updatedAt: 't', + }, + }); + const out = await resolveMockEndpoints( + { kind: 'openapi-asset', assetId: 'a2', format: 'json', mode: 'materialized' }, + synced, + ); + expect(out.endpoints).toEqual([]); + expect(out.warnings[0]).toMatch(/not available locally/); + }); +}); diff --git a/packages/ui-components/src/store/mockResolve.ts b/packages/ui-components/src/store/mockResolve.ts new file mode 100644 index 00000000..20969c37 --- /dev/null +++ b/packages/ui-components/src/store/mockResolve.ts @@ -0,0 +1,50 @@ +// Resolve a MockServerSource to its endpoint table. +// +// `openapi-asset` sources (a spec-typed Global File Asset drives the mock) +// resolve the asset's bytes from IDB into an inline OpenAPI source first, so +// the SAME parser path serves paste-spec mocks, asset-backed "run live" +// (linked) mocks, and "import & edit" (materialized) mocks. On Desktop the +// parse runs in the Node main process (full external-`$ref` resolution); in the +// browser it uses `@apicircle/mock-server-core/parsing` (in-document refs only). + +import type { MockEndpoint, MockServerSource, WorkspaceSynced } from '@apicircle/shared'; +import { getAttachment } from '../persistence/attachments'; +import { getDesktopMockBridge } from '../desktop/bridge'; + +export interface ResolvedMockEndpoints { + endpoints: MockEndpoint[]; + warnings: string[]; +} + +export async function resolveMockEndpoints( + source: MockServerSource, + synced: WorkspaceSynced, +): Promise { + if (source.kind === 'manual') return { endpoints: source.endpoints, warnings: [] }; + + // Fold an asset-backed source into an inline OpenAPI source for parsing. + let parseSource: MockServerSource = source; + if (source.kind === 'openapi-asset') { + const asset = synced.globalAssets.files?.[source.assetId]; + if (!asset) { + return { endpoints: [], warnings: [`Spec asset "${source.assetId}" was not found.`] }; + } + const record = await getAttachment(asset.slotId); + if (!record) { + return { + endpoints: [], + warnings: ['The spec asset bytes are not available locally — re-upload the file.'], + }; + } + parseSource = { + kind: 'openapi', + spec: new TextDecoder().decode(record.bytes), + format: source.format, + }; + } + + const bridge = getDesktopMockBridge(); + if (bridge?.parseSpec) return bridge.parseSpec(parseSource); + const { parseSourceToEndpoints } = await import('@apicircle/mock-server-core/parsing'); + return parseSourceToEndpoints(parseSource); +} diff --git a/packages/ui-components/src/store/mockTwoModes.test.ts b/packages/ui-components/src/store/mockTwoModes.test.ts new file mode 100644 index 00000000..3ed1f00d --- /dev/null +++ b/packages/ui-components/src/store/mockTwoModes.test.ts @@ -0,0 +1,113 @@ +import { act } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { useWorkspaceStore } from './workspaceStore'; + +// End-to-end (fake-indexeddb) coverage of "mock two modes off a spec asset": +// create linked vs materialized, read-only guards on linked mocks, +// refreshMockServer, and auto-refresh of linked mocks when the asset changes. + +const specJson = (paths: string[]): string => + JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Petstore', version: '1.0' }, + paths: Object.fromEntries( + paths.map((p) => [p, { get: { responses: { '200': { description: 'ok' } } } }]), + ), + }); + +const specFile = (paths: string[]): File => + new File([specJson(paths)], 'petstore.json', { type: 'application/json' }); + +const store = () => useWorkspaceStore.getState(); + +async function uploadSpec(paths: string[]): Promise { + let id = ''; + await act(async () => { + id = await store().addGlobalFileAsset(specFile(paths)); + }); + return id; +} + +async function createAssetMock(assetId: string, mode: 'linked' | 'materialized'): Promise { + let id = ''; + await act(async () => { + const r = await store().createMockServer({ + name: `mock-${mode}-${assetId.slice(0, 4)}`, + source: { kind: 'openapi-asset', assetId, format: 'json', mode }, + }); + id = r.id; + }); + return id; +} + +describe('mock two modes off a spec asset', () => { + beforeEach(async () => { + await act(async () => { + await store().hydrate(); + }); + }); + + it('materializes endpoints and records the source for a linked mock', async () => { + const assetId = await uploadSpec(['/pets', '/pets/{id}']); + const mockId = await createAssetMock(assetId, 'linked'); + const mock = store().synced?.mockServers[mockId]; + expect(mock?.endpoints.length).toBe(2); + expect(mock?.source).toMatchObject({ kind: 'openapi-asset', assetId, mode: 'linked' }); + }); + + it('makes linked mocks read-only (endpoint mutations no-op)', async () => { + const assetId = await uploadSpec(['/pets']); + const mockId = await createAssetMock(assetId, 'linked'); + const before = store().synced?.mockServers[mockId]?.endpoints.length ?? 0; + + act(() => { + store().addMockEndpoint(mockId); + }); + const epId = store().synced?.mockServers[mockId]?.endpoints[0]?.id ?? ''; + act(() => { + store().updateMockEndpoint(mockId, epId, { name: 'hacked' }); + store().removeMockEndpoint(mockId, epId); + store().duplicateMockEndpoint(mockId, epId); + store().setMockServerEndpoints(mockId, []); + }); + + const after = store().synced?.mockServers[mockId]; + expect(after?.endpoints.length).toBe(before); + expect(after?.endpoints[0]?.name).not.toBe('hacked'); + }); + + it('allows editing a materialized mock', async () => { + const assetId = await uploadSpec(['/pets']); + const mockId = await createAssetMock(assetId, 'materialized'); + expect(store().synced?.mockServers[mockId]?.endpoints.length).toBe(1); + act(() => { + store().addMockEndpoint(mockId); + }); + expect(store().synced?.mockServers[mockId]?.endpoints.length).toBe(2); + }); + + it('auto-refreshes a linked mock when the spec asset changes', async () => { + const assetId = await uploadSpec(['/pets']); + const mockId = await createAssetMock(assetId, 'linked'); + expect(store().synced?.mockServers[mockId]?.endpoints.length).toBe(1); + + // Re-upload a richer spec into the same asset. + await act(async () => { + await store().fillGlobalFileAssetBytes(assetId, specFile(['/pets', '/pets/{id}', '/owners'])); + }); + expect(store().synced?.mockServers[mockId]?.endpoints.length).toBe(3); + }); + + it('refreshMockServer re-derives endpoints and returns warnings', async () => { + const assetId = await uploadSpec(['/pets', '/pets/{id}']); + const mockId = await createAssetMock(assetId, 'materialized'); + const res = await store().refreshMockServer(mockId); + expect(Array.isArray(res.warnings)).toBe(true); + expect(store().synced?.mockServers[mockId]?.endpoints.length).toBe(2); + }); + + it('refreshMockServer is a no-op for an unknown id', async () => { + const res = await store().refreshMockServer('no-such'); + expect(res).toEqual({ warnings: [] }); + }); +}); diff --git a/packages/ui-components/src/store/workspaceStore.ts b/packages/ui-components/src/store/workspaceStore.ts index b34c54e9..c2ad712f 100644 --- a/packages/ui-components/src/store/workspaceStore.ts +++ b/packages/ui-components/src/store/workspaceStore.ts @@ -50,8 +50,8 @@ import { resolvePrCapability, } from './githubPrCapability'; import { decideRetirement, probeBranchRetirement } from './branchRetirement'; -import { getDesktopMockBridge } from '../desktop/bridge'; import { summarizeUploadedSpec } from './specUpload'; +import { resolveMockEndpoints } from './mockResolve'; import { applyFont } from '../theme/applyFont'; import { applyFontSize, clampFontSizePercent } from '../theme/applyFontSize'; import { @@ -59,6 +59,7 @@ import { RUN_BODY_PREVIEW_LIMIT, envPriorityKey, generateId, + isLinkedMockSource, } from '@apicircle/shared'; import { type AttachmentResolver, @@ -1024,6 +1025,14 @@ type WorkspaceStore = { updateMockEndpoint: (serverId: string, endpointId: string, patch: Partial) => void; /** Remove an endpoint from a server. */ removeMockEndpoint: (serverId: string, endpointId: string) => void; + /** + * Re-derive a mock's endpoints from its `source` (re-resolves an + * `openapi-asset`'s bytes, re-parses a spec). Used by "run live" (linked) + * mocks to stay in sync with the asset, and by "import & edit" mocks as an + * explicit "re-import from spec" that overwrites local endpoint edits. + * No-op for `manual` sources. Returns any parser warnings. + */ + refreshMockServer: (id: string) => Promise<{ warnings: string[] }>; /** * Clone a mock server with all of its endpoints + nested rules. Every * cloned entity gets a fresh id; the legacy `overrides` map is reset @@ -2670,28 +2679,14 @@ export const useWorkspaceStore = create((set, get) => ({ // Materialize the endpoint table at create time so the router (which // serves `MockServer.endpoints` and never re-parses `source`) has - // something to route. Manual-mode mocks carry their endpoints inline; - // spec blobs are parsed now. On Desktop the parse runs in the Node main - // process (full external-`$ref` resolution via swagger-parser); in the - // browser it uses the in-document-only parser and warns about external - // refs it can't resolve. - let endpoints: MockEndpoint[]; - let warnings: string[] = []; - if (source.kind === 'manual') { - endpoints = source.endpoints; - } else { - const bridge = getDesktopMockBridge(); - if (bridge?.parseSpec) { - const parsed = await bridge.parseSpec(source); - endpoints = parsed.endpoints; - warnings = parsed.warnings; - } else { - const { parseSourceToEndpoints } = await import('@apicircle/mock-server-core/parsing'); - const parsed = await parseSourceToEndpoints(source); - endpoints = parsed.endpoints; - warnings = parsed.warnings; - } - } + // something to route. `resolveMockEndpoints` handles every spec source + // kind, including `openapi-asset` (resolves the asset's bytes → parses). + // Manual sources are kept SYNCHRONOUS (no `await`) so the meta bump lands + // before the returned promise settles — a behavior callers rely on. + const { endpoints, warnings } = + source.kind === 'manual' + ? { endpoints: source.endpoints, warnings: [] as string[] } + : await resolveMockEndpoints(source, synced); const id = generateId(); const now = new Date().toISOString(); @@ -2798,6 +2793,7 @@ export const useWorkspaceStore = create((set, get) => ({ if (!synced) return; const existing = synced.mockServers[id]; if (!existing) return; + if (isLinkedMockSource(existing.source)) return; const now = new Date().toISOString(); // For manual-mode mocks, mirror the new endpoints into the source // union so the runtime sees the same array regardless of which @@ -2819,6 +2815,8 @@ export const useWorkspaceStore = create((set, get) => ({ if (!synced) return ''; const existing = synced.mockServers[serverId]; if (!existing) return ''; + // Linked ("run live") mocks derive endpoints from the spec asset — read-only. + if (isLinkedMockSource(existing.source)) return ''; const id = generateId(); const newEndpoint: MockEndpoint = { id, @@ -2858,6 +2856,7 @@ export const useWorkspaceStore = create((set, get) => ({ if (!synced) return; const existing = synced.mockServers[serverId]; if (!existing) return; + if (isLinkedMockSource(existing.source)) return; const idx = existing.endpoints.findIndex((e) => e.id === endpointId); if (idx === -1) return; const nextEndpoint = { ...existing.endpoints[idx], ...patch }; @@ -2885,6 +2884,7 @@ export const useWorkspaceStore = create((set, get) => ({ if (!synced) return; const existing = synced.mockServers[serverId]; if (!existing) return; + if (isLinkedMockSource(existing.source)) return; const nextEndpoints = existing.endpoints.filter((e) => e.id !== endpointId); const source = existing.source.kind === 'manual' @@ -2905,6 +2905,33 @@ export const useWorkspaceStore = create((set, get) => ({ queueSaveSynced(nextSynced); }, + refreshMockServer: async (id) => { + const synced = get().synced; + if (!synced) return { warnings: [] }; + const server = synced.mockServers[id]; + if (!server) return { warnings: [] }; + const { endpoints, warnings } = await resolveMockEndpoints(server.source, synced); + // Race-safe: write against LIVE state (the resolve awaited). + set((state) => { + const live = state.synced?.mockServers[id]; + if (!state.synced || !live) return {}; + const now = new Date().toISOString(); + return { + synced: { + ...state.synced, + mockServers: { + ...state.synced.mockServers, + [id]: { ...live, endpoints, updatedAt: now }, + }, + meta: { ...state.synced.meta, updatedAt: now }, + }, + }; + }); + const after = get().synced; + if (after) queueSaveSynced(after); + return { warnings }; + }, + duplicateMockServer: (id) => { const synced = get().synced; if (!synced) return null; @@ -2918,6 +2945,8 @@ export const useWorkspaceStore = create((set, get) => ({ duplicateMockEndpoint: (serverId, endpointId) => { const synced = get().synced; if (!synced) return null; + const server = synced.mockServers[serverId]; + if (server && isLinkedMockSource(server.source)) return null; const { synced: nextSynced, endpoint } = duplicateMockEndpointAction( synced, serverId, @@ -3613,6 +3642,19 @@ export const useWorkspaceStore = create((set, get) => ({ if (get().synced?.globalAssets.files?.[id]) { recordPendingFileUpload(set, get, id); } + // Auto-refresh any "run live" (linked) mocks that read this spec asset so + // they reflect the new bytes immediately. + const linkedMockIds = Object.values(get().synced?.mockServers ?? {}) + .filter( + (m) => + m.source.kind === 'openapi-asset' && + m.source.mode === 'linked' && + m.source.assetId === id, + ) + .map((m) => m.id); + for (const mockId of linkedMockIds) { + await get().refreshMockServer(mockId); + } }, setFormRowGlobalFileAsset: async (requestId, rowIndex, fileAssetId) => { const synced = get().synced; From 7a5bf967c939a1d82473bbc7956cd7a4e63c3480 Mon Sep 17 00:00:00 2001 From: apicircle-dev Date: Sat, 11 Jul 2026 13:45:02 +0530 Subject: [PATCH 03/16] feat(import): import an OpenAPI/Swagger spec into a collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor's unified Import modal now recognises OpenAPI 3.x / Swagger 2.0 (auto-detect, or an explicit "OpenAPI / Swagger" source) — paste or upload a spec and it becomes a new folder with one request per operation (method, path, query/header/path params). The Global Assets spec editor gains an "Import to collection" button that imports straight from the stored asset. - shared: additive ApiRequest.specAssetId + operationId back-refs - ui-components: importOpenApiToCollection store action (reuses the mock resolver's parser dispatch); ImportModal OpenAPI source (sync detect via summarizeSpec, async import); Assets-dock "Import to collection" button - docs: CHANGELOG, Help import section Additive; existing requests and import formats are unaffected. --- CHANGELOG.md | 9 ++ packages/shared/src/types.ts | 9 ++ .../dock/GlobalAssetsDockPanel.test.tsx | 25 ++++++ .../src/layout/dock/GlobalAssetsDockPanel.tsx | 45 +++++++++- .../src/panels/editor/ImportModal.test.tsx | 18 +++- .../src/panels/editor/ImportModal.tsx | 66 ++++++++++++++- .../src/panels/help/helpContent.ts | 2 +- .../src/store/importOpenApi.test.ts | 82 +++++++++++++++++++ .../ui-components/src/store/workspaceStore.ts | 82 +++++++++++++++++++ 9 files changed, 334 insertions(+), 4 deletions(-) create mode 100644 packages/ui-components/src/store/importOpenApi.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1865b0d7..f63c04d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,15 @@ asset** tab (Run live / Import & edit). (`@apicircle/shared`, `@apicircle/mock-server-core`, `@apicircle/ui-components`, `@apicircle/mcp-server`, VS Code) +- **Import an OpenAPI/Swagger spec into a collection (editor, additive).** The + editor's unified Import modal now recognises OpenAPI 3.x / Swagger 2.0 + (`Auto-detect`, or the explicit "OpenAPI / Swagger" source) — paste or upload a + spec and it becomes a new folder with one request per operation (method, path, + query/header/path params). The Global Assets spec editor gains an **Import to + collection** button that imports straight from the stored asset. `ApiRequest` + gains additive `specAssetId` + `operationId` back-refs so an imported + collection knows which spec asset + operation each request came from. + (`@apicircle/shared`, `@apicircle/ui-components`) - **UI sections/mode seam (open-core, additive).** Building on the `extraPanels` seam, the React shell (`@apicircle/ui-components`) now accepts edition-contributed top-level **sections** ("modes") via an optional `App` `sections` prop diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index d8bdd092..db30ce43 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -331,6 +331,15 @@ export interface Request { // Optional reference to a workspace-wide GraphQL schema definition. Used // for GraphQL request body autocomplete (P19). graphqlSchemaId?: string | null; + /** + * Provenance for requests imported from an OpenAPI/Swagger spec: the Global + * File Asset the collection was imported from (`specAssetId`) and the + * operation it maps to (`operationId` = `" "`, the stable + * operation key). Enables re-syncing a collection when its source spec asset + * changes. Additive; absent on hand-authored or non-spec-imported requests. + */ + specAssetId?: string; + operationId?: string; assertions: Assertion[]; createdAt: string; updatedAt: string; diff --git a/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.test.tsx b/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.test.tsx index 15e5a649..43d46d62 100644 --- a/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.test.tsx +++ b/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.test.tsx @@ -80,6 +80,31 @@ describe('GlobalAssetsDockPanel', () => { expect(screen.getAllByLabelText(/API spec:/).length).toBeGreaterThan(0); }); + it('imports a spec asset into a collection with the source back-ref', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: /^Files/ })); + const input = screen.getByLabelText('Global file asset'); + const openapi = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Petstore', version: '1.0' }, + paths: { '/pets': { get: { responses: { '200': {} } } } }, + }); + await userEvent.upload( + input, + new File([openapi], 'petstore.json', { type: 'application/json' }), + ); + + await userEvent.click(await screen.findByRole('button', { name: /Import to collection/i })); + await waitFor(() => { + const req = Object.values(useWorkspaceStore.getState().synced!.collections.requests).find( + (r) => r.url === '/pets', + ); + expect(req?.operationId).toBe('GET /pets'); + // The imported request carries the source spec asset back-ref. + expect(req?.specAssetId).toBeTruthy(); + }); + }); + it('uploads and edits a reusable file asset', async () => { render(); await userEvent.click(screen.getByRole('button', { name: /^Files/ })); diff --git a/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.tsx b/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.tsx index 31c7fa57..68645d2c 100644 --- a/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.tsx +++ b/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.tsx @@ -12,7 +12,15 @@ // request referencing the deleted id has its mapping cleared. import { useEffect, useMemo, useRef, useState } from 'react'; -import { AlertTriangle, ArrowLeft, FileArchive, Plus, Trash2, Upload } from 'lucide-react'; +import { + AlertTriangle, + ArrowLeft, + FileArchive, + FolderInput, + Plus, + Trash2, + Upload, +} from 'lucide-react'; import { formatBytes, type AssetUsage, @@ -26,6 +34,7 @@ import { MonacoEditorBase } from '../../editors/MonacoEditorBase'; import { cn } from '../../primitives/cn'; import { deriveFileAssetState, FileAssetStatusPill } from '../../primitives/FileAssetStatusPill'; import { SpecAssetBadge } from '../../primitives/SpecAssetBadge'; +import { getAttachment } from '../../persistence/attachments'; interface FileAssetConsumer { kind: 'request' | 'mock'; @@ -727,6 +736,32 @@ function FileAssetEditor({ id }: { id: string | null }) { return out; }); const update = useWorkspaceStore((s) => s.updateGlobalFileAsset); + const importOpenApiToCollection = useWorkspaceStore((s) => s.importOpenApiToCollection); + const pushToast = useWorkspaceStore((s) => s.pushToast); + const importSpecToCollection = async (): Promise => { + if (!file?.spec) return; + const record = await getAttachment(file.slotId); + if (!record) { + pushToast({ + tone: 'error', + title: 'Spec bytes are not available locally — re-upload the file.', + ttlMs: 8000, + }); + return; + } + const res = await importOpenApiToCollection({ + spec: new TextDecoder().decode(record.bytes), + format: file.spec.format, + specAssetId: file.id, + title: file.spec.title, + }); + pushToast({ + tone: res.warnings.length > 0 ? 'info' : 'success', + title: `Imported ${res.requests} request${res.requests === 1 ? '' : 's'} to a new collection`, + detail: res.warnings.length > 0 ? res.warnings.join(' · ') : undefined, + ttlMs: res.warnings.length > 0 ? 12000 : 6000, + }); + }; const remove = useWorkspaceStore((s) => s.removeGlobalFileAsset); const fillBytes = useWorkspaceStore((s) => s.fillGlobalFileAssetBytes); const hasPending = useWorkspaceStore((s) => @@ -847,6 +882,14 @@ function FileAssetEditor({ id }: { id: string | null }) { )} {file.spec.version && v{file.spec.version}}
    + {file.spec.warnings.length > 0 && (
      {file.spec.warnings.map((w) => ( diff --git a/packages/ui-components/src/panels/editor/ImportModal.test.tsx b/packages/ui-components/src/panels/editor/ImportModal.test.tsx index 0a0eb93d..38e15308 100644 --- a/packages/ui-components/src/panels/editor/ImportModal.test.tsx +++ b/packages/ui-components/src/panels/editor/ImportModal.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useWorkspaceStore } from '../../store/workspaceStore'; @@ -85,6 +85,22 @@ describe('ImportModal — auto-detect', () => { expect(screen.getByText('POST')).toBeInTheDocument(); }); + it('detects an OpenAPI spec and imports it into a collection', async () => { + render( {}} />); + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Petstore', version: '1.0' }, + paths: { '/pets': { get: { responses: { '200': { description: 'ok' } } } } }, + }); + pasteInto(screen.getByLabelText('Import source'), spec); + expect(await screen.findByText(/OpenAPI 3\)/)).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Import' })); + await waitFor(() => { + const reqs = Object.values(useWorkspaceStore.getState().synced!.collections.requests); + expect(reqs.some((r) => r.url === '/pets' && r.operationId === 'GET /pets')).toBe(true); + }); + }); + it('surfaces a parse error for non-JSON, non-cURL input', async () => { render( {}} />); pasteInto(screen.getByLabelText('Import source'), 'just garbage'); diff --git a/packages/ui-components/src/panels/editor/ImportModal.tsx b/packages/ui-components/src/panels/editor/ImportModal.tsx index ff22d237..9b3e35c2 100644 --- a/packages/ui-components/src/panels/editor/ImportModal.tsx +++ b/packages/ui-components/src/panels/editor/ImportModal.tsx @@ -43,6 +43,7 @@ import { type ParsedPostmanCollection, type ParsedPostmanEnvironment, } from '@apicircle/core'; +import { summarizeSpec, type SpecSummary } from '@apicircle/mock-server-core/parsing'; import { useWorkspaceStore, type ApicircleEnvironmentPendingBinding, @@ -51,8 +52,16 @@ import { Modal } from '../../primitives/Modal'; import { Select } from '../../primitives/Select'; import { cn } from '../../primitives/cn'; -type SourceFormat = 'auto' | 'postman' | 'postman-env' | 'insomnia' | 'curl' | 'apicircle'; +type SourceFormat = + | 'auto' + | 'openapi' + | 'postman' + | 'postman-env' + | 'insomnia' + | 'curl' + | 'apicircle'; type DetectedKind = + | { kind: 'openapi'; summary: SpecSummary } | { kind: 'postman-collection'; parsed: ParsedPostmanCollection } | { kind: 'postman-environment'; parsed: ParsedPostmanEnvironment } | { kind: 'insomnia-collection'; parsed: ParsedPostmanCollection } @@ -72,6 +81,7 @@ interface ImportModalProps { const FORMAT_LABELS: Record = { auto: 'Auto-detect', + openapi: 'OpenAPI / Swagger', postman: 'Postman v2.1 collection', 'postman-env': 'Postman environment', insomnia: 'Insomnia v4 export', @@ -111,6 +121,7 @@ export function ImportModal({ const [binding, setBinding] = useState(false); const importPostmanCollection = useWorkspaceStore((s) => s.importPostmanCollection); + const importOpenApiToCollection = useWorkspaceStore((s) => s.importOpenApiToCollection); const importPostmanEnvironment = useWorkspaceStore((s) => s.importPostmanEnvironment); const importApicircleFolder = useWorkspaceStore((s) => s.importApicircleFolder); const importApicircleEnvironment = useWorkspaceStore((s) => s.importApicircleEnvironment); @@ -143,6 +154,22 @@ export function ImportModal({ importPostmanEnvironment(d.parsed); } else if (d.kind === 'curl') { addRequestFromCurl(text, parentFolderId); + } else if (d.kind === 'openapi') { + // Async parse + create; the modal closes immediately (fire-and-forget), + // surfacing the outcome via a toast when it lands. + void importOpenApiToCollection({ + spec: text, + format: d.summary.format, + parentFolderId, + title: d.summary.title, + }).then((res) => { + pushToast({ + tone: res.warnings.length > 0 ? 'info' : 'success', + title: `Imported ${res.requests} request${res.requests === 1 ? '' : 's'} from ${d.summary.title ?? 'the spec'}`, + detail: res.warnings.length > 0 ? res.warnings.join(' · ') : undefined, + ttlMs: res.warnings.length > 0 ? 12000 : 6000, + }); + }); } else if (d.kind === 'apicircle-folder') { const result = importApicircleFolder(d.parsed, parentFolderId); if (result && result.filesRequiringReattachment.length > 0) { @@ -449,6 +476,16 @@ function detect(text: string, format: SourceFormat): DetectedKind { throw new Error('Selected source is "cURL" but the input doesn\'t start with "curl ".'); } + // OpenAPI / Swagger — recognised by a top-level `openapi:`/`swagger:` key. + // Handled before JSON.parse so YAML specs work too; summarizeSpec is sync. + if (format === 'openapi' || format === 'auto') { + const summary = summarizeSpec(trimmed); + if (summary) return { kind: 'openapi', summary }; + if (format === 'openapi') { + throw new Error('Selected source is "OpenAPI / Swagger" but this is not a recognised spec.'); + } + } + // The remaining formats are JSON. let json: unknown; try { @@ -494,6 +531,10 @@ function detect(text: string, format: SourceFormat): DetectedKind { } function labelForDetection(d: DetectedKind): string { + if (d.kind === 'openapi') { + const dialect = d.summary.dialect === 'swagger-2' ? 'Swagger 2' : 'OpenAPI 3'; + return `${d.summary.title ?? 'API'} · ${d.summary.operationCount} operation${d.summary.operationCount === 1 ? '' : 's'} (${dialect})`; + } if (d.kind === 'postman-collection') { return `${d.parsed.collectionName} · ${d.parsed.requests.length} request${d.parsed.requests.length === 1 ? '' : 's'} (Postman)`; } @@ -514,6 +555,29 @@ function labelForDetection(d: DetectedKind): string { } function DetectionPreview({ detection }: { detection: DetectedKind }) { + if (detection.kind === 'openapi') { + const s = detection.summary; + const dialect = s.dialect === 'swagger-2' ? 'Swagger 2.0' : 'OpenAPI 3.x'; + return ( +
      +

      + {s.title ?? 'API'} + {s.version ? v{s.version} : null} · {dialect} +

      +

      + {s.operationCount} operation{s.operationCount === 1 ? '' : 's'} → one request each in + a new folder. +

      + {s.warnings.length > 0 ? ( +
        + {s.warnings.map((w) => ( +
      • • {w}
      • + ))} +
      + ) : null} +
      + ); + } if (detection.kind === 'postman-collection' || detection.kind === 'insomnia-collection') { return ; } diff --git a/packages/ui-components/src/panels/help/helpContent.ts b/packages/ui-components/src/panels/help/helpContent.ts index 19857e10..29afb356 100644 --- a/packages/ui-components/src/panels/help/helpContent.ts +++ b/packages/ui-components/src/panels/help/helpContent.ts @@ -571,7 +571,7 @@ Extracted values are local-only — they live in this machine's context, never i ## Import in the app -The Editor sidebar's Import action opens a modal that takes pasted text or an uploaded file and auto-detects the format — Postman v2.1 collections, Postman environments (keys import as context variables), Insomnia v4 exports, **API Circle folder exports** (the \`.apicircle.json\` files produced by the new "Export as JSON" folder action — see below), **API Circle environment exports** (the JSON the Environments sidebar's "Export as JSON" action produces; encrypted variables travel with the slot's user-recognizable label and trigger a **"Provide secret values"** second step in the modal where you can fill the value to bind on the spot, or **Skip & finish** and re-bind later under Environments), and pasted cURL commands: +The Editor sidebar's Import action opens a modal that takes pasted text or an uploaded file and auto-detects the format — Postman v2.1 collections, Postman environments (keys import as context variables), Insomnia v4 exports, **OpenAPI 3.x / Swagger 2.0 specs** (each operation becomes a request in a new folder — or use **Import to collection** on a spec you've uploaded to Global Assets → Files), **API Circle folder exports** (the \`.apicircle.json\` files produced by the new "Export as JSON" folder action — see below), **API Circle environment exports** (the JSON the Environments sidebar's "Export as JSON" action produces; encrypted variables travel with the slot's user-recognizable label and trigger a **"Provide secret values"** second step in the modal where you can fill the value to bind on the spot, or **Skip & finish** and re-bind later under Environments), and pasted cURL commands: curl -X POST https://api.example.com/v1/users \\ -H "Content-Type: application/json" \\ diff --git a/packages/ui-components/src/store/importOpenApi.test.ts b/packages/ui-components/src/store/importOpenApi.test.ts new file mode 100644 index 00000000..adbe7fa4 --- /dev/null +++ b/packages/ui-components/src/store/importOpenApi.test.ts @@ -0,0 +1,82 @@ +import { act } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { useWorkspaceStore } from './workspaceStore'; + +const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Petstore', version: '1.0' }, + paths: { + '/pets': { + get: { + parameters: [{ name: 'limit', in: 'query' }], + responses: { '200': { description: 'ok' } }, + }, + }, + '/pets/{id}': { + delete: { + parameters: [{ name: 'id', in: 'path', required: true }], + responses: { '204': { description: 'gone' } }, + }, + }, + }, +}); + +const store = () => useWorkspaceStore.getState(); + +describe('importOpenApiToCollection', () => { + beforeEach(async () => { + await act(async () => { + await store().hydrate(); + }); + }); + + it('creates a folder + one request per operation, with source back-refs', async () => { + let res: { folderId: string | null; requests: number; warnings: string[] } = { + folderId: null, + requests: 0, + warnings: [], + }; + await act(async () => { + res = await store().importOpenApiToCollection({ + spec, + format: 'json', + specAssetId: 'asset-1', + title: 'Petstore', + }); + }); + + expect(res.requests).toBe(2); + expect(res.folderId).toBeTruthy(); + + const imported = Object.values(store().synced!.collections.requests).filter( + (r) => r.folderId === res.folderId, + ); + expect(imported).toHaveLength(2); + // Every imported request carries the source back-refs. + expect(imported.every((r) => r.specAssetId === 'asset-1')).toBe(true); + + const get = imported.find((r) => r.url === '/pets'); + expect(get?.method).toBe('GET'); + expect(get?.operationId).toBe('GET /pets'); + expect(get?.query.some((q) => q.key === 'limit' && !q.enabled)).toBe(true); + + const del = imported.find((r) => r.url === '/pets/{id}'); + expect(del?.method).toBe('DELETE'); + expect(del?.operationId).toBe('DELETE /pets/{id}'); + expect(del?.pathParams).toHaveProperty('id'); + }); + + it('creates an empty folder (0 requests) for a spec with no operations', async () => { + const empty = JSON.stringify({ openapi: '3.0.0', info: { title: 'Empty' }, paths: {} }); + let res: { folderId: string | null; requests: number; warnings: string[] } = { + folderId: null, + requests: 0, + warnings: [], + }; + await act(async () => { + res = await store().importOpenApiToCollection({ spec: empty, format: 'json' }); + }); + expect(res.requests).toBe(0); + expect(res.folderId).toBeTruthy(); + }); +}); diff --git a/packages/ui-components/src/store/workspaceStore.ts b/packages/ui-components/src/store/workspaceStore.ts index c2ad712f..4d94e000 100644 --- a/packages/ui-components/src/store/workspaceStore.ts +++ b/packages/ui-components/src/store/workspaceStore.ts @@ -1167,6 +1167,21 @@ type WorkspaceStore = { parsed: ParsedPostmanCollection, parentFolderId?: string | null, ) => { folders: number; requests: number }; + /** + * Import an OpenAPI / Swagger spec into a new collection folder — one request + * per operation (method + path + query/header/path params). `specAssetId` + * stamps the source spec asset onto every created request for later re-sync; + * `operationId` (= `" "`) is the stable operation key. Async — + * it parses the spec (browser: in-document `$ref`s only). Returns the new + * folder id, the request count, and parser warnings. + */ + importOpenApiToCollection: (args: { + spec: string; + format: 'json' | 'yaml'; + parentFolderId?: string | null; + specAssetId?: string; + title?: string; + }) => Promise<{ folderId: string | null; requests: number; warnings: string[] }>; /** * Import a parsed Postman environment. Returns the final env name * (uniquified if it collided), or null if no synced doc was loaded. @@ -3303,6 +3318,73 @@ export const useWorkspaceStore = create((set, get) => ({ return { folders: parsed.folders.length + 1, requests: parsed.requests.length }; }, + importOpenApiToCollection: async ({ + spec, + format, + parentFolderId = null, + specAssetId, + title, + }) => { + const synced = get().synced; + if (!synced) return { folderId: null, requests: 0, warnings: [] }; + // Reuse the mock resolver's parser dispatch (desktop bridge / browser). + const { endpoints, warnings } = await resolveMockEndpoints( + { kind: 'openapi', spec, format }, + synced, + ); + + let rootFolderId = ''; + let created = 0; + // Build the collection against LIVE state (the parse awaited). + set((state) => { + if (!state.synced) return {}; + let cur = state.synced; + const { synced: afterRoot, folder } = addFolderAction( + cur, + parentFolderId, + title?.trim() || 'Imported API', + ); + cur = afterRoot; + rootFolderId = folder.id; + for (const ep of endpoints) { + const name = ep.name || `${ep.method} ${ep.pathPattern}`; + const { synced: next, request } = addRequestAction(cur, folder.id, name); + const patched: ApiRequest = { + ...request, + method: ep.method, + url: ep.pathPattern, + query: ep.requestSchema.queryParams.map((p) => ({ + key: p.name, + value: p.example != null ? String(p.example) : '', + enabled: false, + })), + headers: ep.requestSchema.headers.map((p) => ({ + key: p.name, + value: '', + enabled: false, + })), + pathParams: Object.fromEntries(ep.requestSchema.pathParams.map((p) => [p.name, ''])), + specAssetId, + operationId: `${ep.method} ${ep.pathPattern}`, + }; + cur = { + ...next, + collections: { + ...next.collections, + requests: { ...next.collections.requests, [request.id]: patched }, + }, + }; + created += 1; + } + return { + synced: { ...cur, meta: { ...cur.meta, updatedAt: new Date().toISOString() } }, + }; + }); + const after = get().synced; + if (after) queueSaveSynced(after); + return { folderId: rootFolderId, requests: created, warnings }; + }, + importPostmanEnvironment: (parsed) => { const state = get(); if (!state.synced) return null; From cf8a7e01203c7c80fb23971e4ef583cd38df6cb8 Mon Sep 17 00:00:00 2001 From: apicircle-dev Date: Sat, 11 Jul 2026 14:07:17 +0530 Subject: [PATCH 04/16] feat(mocks): promote a mock endpoint into a collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every mock endpoint gains an "Add to collection" action (the mock sidebar's endpoint kebab) that creates a saved request from its method + path pattern + request-schema params — available even on read-only "run live" mocks. - ui-components: promoteMockEndpointToRequest store action; a shared requestShapeFromMockEndpoint mapper (also used by the OpenAPI importer so promoted + imported requests are identical); MocksSidebar "Add to collection" - mcp-server: mock.promote_endpoint tool (catalog 95 -> 96) - docs: CHANGELOG, mcp-tools-reference, Help; count reconciled across README / CLAUDE.md / AGENTS.md / docs Additive; existing mocks and requests are unaffected. --- AGENTS.md | 4 +- CHANGELOG.md | 8 +++ CLAUDE.md | 4 +- README.md | 6 +- docs/connect-your-ai-client.md | 2 +- docs/context/api-circle.md | 10 +-- docs/mcp-tools-reference.md | 3 +- packages/mcp-server/README.md | 6 +- .../mcp-server/src/tools/mockRefresh.test.ts | 28 ++++++++ packages/mcp-server/src/tools/mocks.ts | 43 ++++++++++++ packages/mcp-server/src/tools/registry.ts | 2 + packages/shared/src/mcp.ts | 2 + .../src/panels/help/helpContent.ts | 2 + .../src/panels/mocks/MocksSidebar.test.tsx | 23 ++++++- .../src/panels/mocks/MocksSidebar.tsx | 66 ++++++++++++------- .../ui-components/src/store/mockResolve.ts | 29 +++++++- .../src/store/promoteMockEndpoint.test.ts | 58 ++++++++++++++++ .../ui-components/src/store/workspaceStore.ts | 57 ++++++++++++---- 18 files changed, 298 insertions(+), 55 deletions(-) create mode 100644 packages/ui-components/src/store/promoteMockEndpoint.test.ts diff --git a/AGENTS.md b/AGENTS.md index bb0200da..a70247ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ but with: read the same source of truth the UI uses, no IPC required. - **Local mock servers**: describe an API in OpenAPI / Postman / Insomnia and run a Hono-backed mock on `localhost`. -- An **MCP server**: exposes the workspace as a 95-tool catalog any +- An **MCP server**: exposes the workspace as a 96-tool catalog any Model Context Protocol client (Codex Desktop, ChatGPT, Cursor, Copilot, Codex, Continue, Cline, Zed, Windsurf) can drive. - A **CLI** for headless use @@ -262,7 +262,7 @@ studio/ │ ├── git/ GitHub REST client + typed error taxonomy │ ├── ui-components/ ALL React UI + the Zustand store + IndexedDB persistence │ ├── mock-server-core/ Hono mock-server engine + OpenAPI/Postman/Insomnia parsers -│ ├── mcp-server/ stdio MCP host + 95-tool catalog + workspace providers +│ ├── mcp-server/ stdio MCP host + 96-tool catalog + workspace providers │ └── cli/ `apicircle` binary — mock / mcp / import / export / run / workspaces ├── examples/ Demo workspaces + a standalone mock-server example ├── docs/ Product + architecture + QA docs (see §9) diff --git a/CHANGELOG.md b/CHANGELOG.md index f63c04d4..fff42e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,14 @@ gains additive `specAssetId` + `operationId` back-refs so an imported collection knows which spec asset + operation each request came from. (`@apicircle/shared`, `@apicircle/ui-components`) +- **Promote a mock endpoint into a collection (additive).** Every mock endpoint + gains an **Add to collection** action (the mock sidebar's endpoint kebab) that + creates a saved request from its method + path + request-schema params — + available even on read-only "run live" mocks. New + `promoteMockEndpointToRequest` store action + `mock.promote_endpoint` MCP tool + (catalog 95 → **96**). A shared `requestShapeFromMockEndpoint` mapper keeps + promoted requests and OpenAPI-imported requests identical. + (`@apicircle/ui-components`, `@apicircle/mcp-server`) - **UI sections/mode seam (open-core, additive).** Building on the `extraPanels` seam, the React shell (`@apicircle/ui-components`) now accepts edition-contributed top-level **sections** ("modes") via an optional `App` `sections` prop diff --git a/CLAUDE.md b/CLAUDE.md index 7726f1ec..3d9243e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ but with: read the same source of truth the UI uses, no IPC required. - **Local mock servers**: describe an API in OpenAPI / Postman / Insomnia and run a Hono-backed mock on `localhost`. -- An **MCP server**: exposes the workspace as a 95-tool catalog any +- An **MCP server**: exposes the workspace as a 96-tool catalog any Model Context Protocol client (Claude Desktop, ChatGPT, Cursor, Copilot, Codex, Continue, Cline, Zed, Windsurf) can drive. - A **CLI** for headless use @@ -310,7 +310,7 @@ studio/ │ │ (root = Node + swagger-parser; `/parsing` subpath = │ │ browser-safe, in-document `$ref` only — used by the │ │ web/desktop renderer to materialize endpoints at import) -│ ├── mcp-server/ stdio MCP host + 95-tool catalog + workspace providers +│ ├── mcp-server/ stdio MCP host + 96-tool catalog + workspace providers │ ├── desktop-shell/ Reusable Electron main-process building blocks — │ │ OS-keychain secrets, mock / MCP / workspace-file IPC │ │ bridges, OAuth2 callback server, window-state. Composed diff --git a/README.md b/README.md index cdf0c360..8b637aa9 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ rebuilt around two ideas the others miss: branch. Teams collaborate the way they collaborate on code — branches, diffs, pull requests, review. 2. **Your workspace is an AI tool catalog.** A built-in Model Context Protocol - server exposes **95 tools**, so Claude, ChatGPT, Cursor, Copilot, and any + server exposes **96 tools**, so Claude, ChatGPT, Cursor, Copilot, and any other MCP client can read, author, and run requests on your behalf. No cloud account. No vendor lock-in. Your data stays on your machine and in @@ -80,7 +80,7 @@ The bundled `@apicircle/mcp-server` speaks the open [Model Context Protocol](https://modelcontextprotocol.io) over stdio, so it works with **Claude Desktop, Claude Code, ChatGPT, GitHub Copilot, Cursor, Continue, Cline, Zed, and Windsurf** — or anything else that talks MCP. The -95-tool catalog covers request and folder CRUD, environment authoring, +96-tool catalog covers request and folder CRUD, environment authoring, assertions, execution plans, history, mock-server lifecycle, the release ledger (publish / deprecate / withdraw), linked-workspace config (list / pin / scope / unlink), codebase scanning, imports, code generation, and @@ -437,7 +437,7 @@ packages/ shared/ Types, generateId, validators, encryption helpers git/ GitHub API client + sync logic mock-server-core/ Hono mock-server engine + OpenAPI/Postman/Insomnia parsers - mcp-server/ stdio MCP host with the 95-tool catalog + mcp-server/ stdio MCP host with the 96-tool catalog cli/ `apicircle` binary — mock / mocks / mcp / import / export / run / workspaces ``` diff --git a/docs/connect-your-ai-client.md b/docs/connect-your-ai-client.md index e32972bd..1bdc42aa 100644 --- a/docs/connect-your-ai-client.md +++ b/docs/connect-your-ai-client.md @@ -1,6 +1,6 @@ # Connect your AI client -API Circle Studio's MCP server exposes 95 tools (request CRUD, environment authoring, plan creation, assertions, history, mock servers — including **`mock.set_default_port`** to pin a 1024-65535 port that survives across runs — code generation from collections, codebase scanning, imports, **folder export / import as JSON**, **Global File Asset library with provenance state**, **release ledger — publish / deprecate / withdraw the versions linked consumers pin to**, **linked-workspace config — list / pin / scope / unlink the workspaces you consume**, **GitHub network ops — link / refresh / tag-release / set-topics with a `token` or `GITHUB_TOKEN`**, **marketplace discovery — `marketplace.search` with sort by stars/updated/relevance**, prompt-driven authoring) over stdio. Any AI client that speaks the [Model Context Protocol](https://modelcontextprotocol.io) can drive the workspace. +API Circle Studio's MCP server exposes 96 tools (request CRUD, environment authoring, plan creation, assertions, history, mock servers — including **`mock.set_default_port`** to pin a 1024-65535 port that survives across runs — code generation from collections, codebase scanning, imports, **folder export / import as JSON**, **Global File Asset library with provenance state**, **release ledger — publish / deprecate / withdraw the versions linked consumers pin to**, **linked-workspace config — list / pin / scope / unlink the workspaces you consume**, **GitHub network ops — link / refresh / tag-release / set-topics with a `token` or `GITHUB_TOKEN`**, **marketplace discovery — `marketplace.search` with sort by stars/updated/relevance**, prompt-driven authoring) over stdio. Any AI client that speaks the [Model Context Protocol](https://modelcontextprotocol.io) can drive the workspace. > **Open standard.** MCP is not Anthropic-locked. Claude Desktop, ChatGPT, GitHub Copilot, Cursor, Continue, Cline, Zed, and Windsurf all support it. Snippets below cover the major clients; if yours isn't listed, the _Generic stdio_ section is the fallback. diff --git a/docs/context/api-circle.md b/docs/context/api-circle.md index 79626bdf..7ac6d132 100644 --- a/docs/context/api-circle.md +++ b/docs/context/api-circle.md @@ -25,7 +25,7 @@ Insomnia — with a handful of things that set it apart: external tools can read or edit the same source of truth the UI uses. - **Local mock servers.** Describe an API in OpenAPI / Postman / Insomnia and run a Hono-backed mock on `localhost`. -- **An MCP server.** The workspace is exposed as a 95-tool catalog any +- **An MCP server.** The workspace is exposed as a 96-tool catalog any Model Context Protocol client (Claude Desktop, ChatGPT, Cursor, GitHub Copilot, Codex, Continue, Cline, Zed, Windsurf) can drive. - **A CLI** (`apicircle mock | mcp | import | run | workspaces`) for @@ -64,7 +64,7 @@ studio/ │ ├── git/ GitHub REST client + typed error taxonomy │ ├── ui-components/ ALL React UI + the Zustand store + IndexedDB persistence │ ├── mock-server-core/ Hono mock engine + OpenAPI/Postman/Insomnia parsers -│ ├── mcp-server/ stdio MCP host + 95-tool catalog + workspace providers +│ ├── mcp-server/ stdio MCP host + 96-tool catalog + workspace providers │ └── cli/ `apicircle` binary — mock / mcp / import / run / workspaces ├── examples/ Demo workspaces + a standalone example mock server ├── docs/ Product + architecture + QA docs (see §16) @@ -214,7 +214,7 @@ Full matrix: [`docs/auth.md`](../auth.md). ## 7. MCP server -`@apicircle/mcp-server` exposes **95 tools** over stdio, namespaced by +`@apicircle/mcp-server` exposes **96 tools** over stdio, namespaced by capability: imports, code generation, multi-workspace discovery (`workspace.list`), workspace read/write, request / folder / environment / plan / assertion CRUD, **folder export / import as JSON** @@ -313,7 +313,7 @@ promises`, `consistent-type-imports`, `prefer-const`, `eqeqeq` are all The product surfaces are built and functional: the web + desktop apps, the 17 auth types, the mock-server engine across all three runtimes, -the 95-tool MCP server, the CLI with multi-workspace addressing, the +the 96-tool MCP server, the CLI with multi-workspace addressing, the disk-mirror persistence layer, the MCP **Connection / Prompts** panel sections, the Settings → Community surface, and the GitHub Pages web deploy. Unit tests are green. @@ -401,7 +401,7 @@ Because the product is pre-launch with zero users: | [`docs/architecture/platform.md`](../architecture/platform.md) | MCP / mock engine / CLI / desktop design record | | [`docs/auth.md`](../auth.md) | The 17-auth-type matrix | | [`docs/mock-server.md`](../mock-server.md) | Mock server feature guide | -| [`docs/mcp-tools-reference.md`](../mcp-tools-reference.md) | MCP tool catalog reference (95 tools) | +| [`docs/mcp-tools-reference.md`](../mcp-tools-reference.md) | MCP tool catalog reference (96 tools) | | [`docs/connect-your-ai-client.md`](../connect-your-ai-client.md) | Wiring an MCP client | | [`docs/installing.md`](../installing.md) | Install instructions | | [`docs/qa/README.md`](../qa/README.md) | QA status, E2E CI reference, coverage tooling | diff --git a/docs/mcp-tools-reference.md b/docs/mcp-tools-reference.md index efbe7823..f3c95206 100644 --- a/docs/mcp-tools-reference.md +++ b/docs/mcp-tools-reference.md @@ -1,6 +1,6 @@ # MCP tool catalog reference -The `@apicircle/mcp-server` host exposes 95 tools, namespaced by capability area. The full list is canonical in [`packages/shared/src/mcp.ts`](../packages/shared/src/mcp.ts) and registered in [`packages/mcp-server/src/tools/registry.ts`](../packages/mcp-server/src/tools/registry.ts). +The `@apicircle/mcp-server` host exposes 96 tools, namespaced by capability area. The full list is canonical in [`packages/shared/src/mcp.ts`](../packages/shared/src/mcp.ts) and registered in [`packages/mcp-server/src/tools/registry.ts`](../packages/mcp-server/src/tools/registry.ts). ## Imports @@ -183,6 +183,7 @@ response-rule shapes mirror the `prompt.set_endpoint_*` tools above. | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `mock.list_endpoints` | `{ mockId }` → `[{ id, method, path, name }]` | | `mock.refresh` | `{ id }` — re-derive endpoints from `source` (re-parse the spec) after the spec changed. Inline sources re-parse; asset-backed (`openapi-asset`) mocks refresh in the desktop/web app. Returns `{ ok, endpointCount, warnings }`. Endpoint edits on a linked mock are rejected as read-only. | +| `mock.promote_endpoint` | `{ mockId, endpointId, folderId? }` — promote a mock endpoint into a saved request (method + path + request-schema params) under a folder (root when omitted). Returns `{ ok, requestId, changedIds }`. Allowed on read-only linked mocks (promoting is a read). | | `mock.add_endpoint` | `{ mockId, method, pathPattern, name?, description?, response? }` — defaults to a `200` JSON `{}` response | | `mock.update_endpoint` | `{ mockId, endpointId, method?, pathPattern?, name?, description?, ... }` — patches only the supplied fields | | `mock.delete_endpoint` | `{ mockId, endpointId }` | diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 43757969..e1570cc0 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -6,12 +6,12 @@

      Give your AI assistant a real API client.
      - A Model Context Protocol server that exposes the API Circle Studio workspace as a 95-tool catalog — so Claude, ChatGPT, Cursor, Copilot, and every other MCP client can read, author, mock, and run requests for you. + A Model Context Protocol server that exposes the API Circle Studio workspace as a 96-tool catalog — so Claude, ChatGPT, Cursor, Copilot, and every other MCP client can read, author, mock, and run requests for you.

      npm version - 95 MCP tools + 96 MCP tools stdio transport Multi-workspace Node ≥ 20 @@ -140,7 +140,7 @@ In multi-workspace mode the assistant gets two extra surfaces: Entity tools (`request.read`, `environment.create`, `mock.start`, etc.) all default to the active workspace — multi-workspace scoping is opt-in per call. -## The tool catalog (95 tools) +## The tool catalog (96 tools) The full list lives at [`docs/mcp-tools-reference.md`](https://github.com/apicircle/studio/blob/main/docs/mcp-tools-reference.md). diff --git a/packages/mcp-server/src/tools/mockRefresh.test.ts b/packages/mcp-server/src/tools/mockRefresh.test.ts index fa8dcbfa..cb70fb19 100644 --- a/packages/mcp-server/src/tools/mockRefresh.test.ts +++ b/packages/mcp-server/src/tools/mockRefresh.test.ts @@ -6,6 +6,7 @@ import { InProcessMockController } from '../providers/InProcessMockController'; import { mockAddEndpointTool, mockCreateFromOpenApiTool, + mockPromoteEndpointTool, mockRefreshTool, mockUpdateEndpointTool, } from './mocks'; @@ -163,3 +164,30 @@ describe('linked mocks are read-only over MCP', () => { expect(out.error).toMatch(/read-only/); }); }); + +describe('mock.promote_endpoint', () => { + beforeEach(() => { + setupCtx(freshState({ m1: linkedMock('m1') })); + }); + + it('promotes a mock endpoint into a collection request (allowed on linked mocks)', async () => { + const out = (await mockPromoteEndpointTool.handler( + { mockId: 'm1', endpointId: 'e1' }, + ctx, + )) as { ok: boolean; requestId: string }; + expect(out.ok).toBe(true); + const state = await ctx.workspace.read(); + const req = state.synced.collections.requests[out.requestId]; + expect(req.method).toBe('GET'); + expect(req.url).toBe('/pets'); + }); + + it('returns not found for a missing endpoint', async () => { + const out = (await mockPromoteEndpointTool.handler( + { mockId: 'm1', endpointId: 'nope' }, + ctx, + )) as { ok: boolean; error: string }; + expect(out.ok).toBe(false); + expect(out.error).toMatch(/not found/); + }); +}); diff --git a/packages/mcp-server/src/tools/mocks.ts b/packages/mcp-server/src/tools/mocks.ts index 6abceaad..7d5fd727 100644 --- a/packages/mcp-server/src/tools/mocks.ts +++ b/packages/mcp-server/src/tools/mocks.ts @@ -4,6 +4,7 @@ import type { MockResponseConfig, MockServer, MockServerSource, + Request as ApiRequest, } from '@apicircle/shared'; import { generateId, @@ -346,6 +347,48 @@ export const mockRefreshTool: AnyToolDef = { }, }; +export const mockPromoteEndpointTool: AnyToolDef = { + name: 'mock.promote_endpoint', + description: + "Promote a mock endpoint into a saved request in the collection tree — maps the endpoint's method, path pattern, and request-schema params (query / header / path) to a new request. `folderId` places it under a folder (root when omitted). Returns the new request id.", + inputSchema: z.object({ + mockId: z.string(), + endpointId: z.string(), + folderId: z.string().nullish(), + }), + async handler(input, ctx) { + const state = await ctx.workspace.read(); + const mock = state.synced.mockServers[input.mockId]; + const ep = mock?.endpoints.find((e) => e.id === input.endpointId); + if (!mock || !ep) return { ok: false as const, error: 'mock or endpoint not found' as const }; + const now = new Date().toISOString(); + const request: ApiRequest = { + id: generateId(), + name: ep.name || `${ep.method} ${ep.pathPattern}`, + folderId: input.folderId ?? null, + method: ep.method, + url: ep.pathPattern, + headers: ep.requestSchema.headers.map((p) => ({ key: p.name, value: '', enabled: false })), + query: ep.requestSchema.queryParams.map((p) => ({ + key: p.name, + value: p.example != null ? String(p.example) : '', + enabled: false, + })), + pathParams: Object.fromEntries(ep.requestSchema.pathParams.map((p) => [p.name, ''])), + cookies: [], + body: { type: 'none', content: '' }, + auth: { type: 'none' }, + contextVars: [], + extractions: [], + assertions: [], + createdAt: now, + updatedAt: now, + }; + const out = await ctx.workspace.apply({ kind: 'request.create', request }); + return { ok: true as const, requestId: request.id, changedIds: out.changedIds }; + }, +}; + const ENDPOINT_RESPONSE = z.object({ status: z.number().int().min(100).max(599).default(200), jsonBody: z.string().default('{}'), diff --git a/packages/mcp-server/src/tools/registry.ts b/packages/mcp-server/src/tools/registry.ts index 6b9edf6b..67f68f2e 100644 --- a/packages/mcp-server/src/tools/registry.ts +++ b/packages/mcp-server/src/tools/registry.ts @@ -80,6 +80,7 @@ import { mockListTool, mockListEndpointsTool, mockRefreshTool, + mockPromoteEndpointTool, mockStartTool, mockStopTool, mockDeleteTool, @@ -186,6 +187,7 @@ export const TOOL_REGISTRY: AnyToolDef[] = [ mockListTool, mockListEndpointsTool, mockRefreshTool, + mockPromoteEndpointTool, mockStartTool, mockStopTool, mockDeleteTool, diff --git a/packages/shared/src/mcp.ts b/packages/shared/src/mcp.ts index f8cd48af..19718788 100644 --- a/packages/shared/src/mcp.ts +++ b/packages/shared/src/mcp.ts @@ -102,6 +102,7 @@ export type McpToolName = | 'mock.list' | 'mock.list_endpoints' | 'mock.refresh' + | 'mock.promote_endpoint' | 'mock.start' | 'mock.stop' | 'mock.delete' @@ -214,6 +215,7 @@ export const MCP_TOOL_NAMES: ReadonlyArray = [ 'mock.list', 'mock.list_endpoints', 'mock.refresh', + 'mock.promote_endpoint', 'mock.start', 'mock.stop', 'mock.delete', diff --git a/packages/ui-components/src/panels/help/helpContent.ts b/packages/ui-components/src/panels/help/helpContent.ts index 29afb356..02e1a893 100644 --- a/packages/ui-components/src/panels/help/helpContent.ts +++ b/packages/ui-components/src/panels/help/helpContent.ts @@ -969,6 +969,8 @@ Snapshots live only on this machine. They are kept within a size budget — pick - **From a spec** — paste an OpenAPI, Postman, or Insomnia source; it is parsed into endpoints the moment you create the mock, so the endpoint table is populated right away. On the Desktop app the parse runs in the native process and resolves external \`$ref\`s; the web app resolves in-document \`$ref\`s only and warns about any external references it can't follow. - **From a spec asset** — build the mock from an OpenAPI/Swagger file you uploaded to Global Assets → Files, in one of two modes. **Run live** (linked) derives the endpoints from the asset and keeps them in sync — re-uploading the spec updates every linked mock, and its endpoints are read-only. **Import & edit** (materialized) parses the spec into editable endpoints you can modify, with a refresh-from-spec re-import when the asset changes. +Every mock endpoint has an **Add to collection** action (the ⋯ menu on the endpoint row) that saves it as a real request in your collection tree — carrying the method, path pattern, and declared query/header/path params. It works even on read-only "run live" mocks, so you can turn a mocked endpoint into a request you send against the real API. + ## Endpoints and the response flow Each endpoint is a method + path pattern, edited as a flow: Endpoint → Validation → Rules → Default response. Example — a \`GET /users/:id\` endpoint: diff --git a/packages/ui-components/src/panels/mocks/MocksSidebar.test.tsx b/packages/ui-components/src/panels/mocks/MocksSidebar.test.tsx index f4794f11..9f521127 100644 --- a/packages/ui-components/src/panels/mocks/MocksSidebar.test.tsx +++ b/packages/ui-components/src/panels/mocks/MocksSidebar.test.tsx @@ -1,4 +1,4 @@ -import { act, screen } from '@testing-library/react'; +import { act, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it } from 'vitest'; import { MocksSidebar } from './MocksSidebar'; @@ -55,4 +55,25 @@ describe('MocksSidebar — asset-backed mocks', () => { expect(screen.queryByText('Refresh from spec')).not.toBeInTheDocument(); expect(screen.queryByText('Re-import from spec')).not.toBeInTheDocument(); }); + + it('promotes an endpoint into the collection via "Add to collection"', async () => { + await renderWithStore(); + let mockId = ''; + await act(async () => { + const r = await useWorkspaceStore + .getState() + .createMockServer({ name: 'API', source: { kind: 'manual', endpoints: [] } }); + mockId = r.id; + useWorkspaceStore.getState().addMockEndpoint(mockId); + }); + + // The active server auto-expands, so the endpoint row + its kebab are visible. + await userEvent.click(await screen.findByRole('button', { name: /GET \/path actions/i })); + await userEvent.click(screen.getByText('Add to collection')); + + await waitFor(() => { + const reqs = Object.values(useWorkspaceStore.getState().synced!.collections.requests); + expect(reqs.some((r) => r.url === '/path')).toBe(true); + }); + }); }); diff --git a/packages/ui-components/src/panels/mocks/MocksSidebar.tsx b/packages/ui-components/src/panels/mocks/MocksSidebar.tsx index d7a37734..1d5b1f90 100644 --- a/packages/ui-components/src/panels/mocks/MocksSidebar.tsx +++ b/packages/ui-components/src/panels/mocks/MocksSidebar.tsx @@ -4,6 +4,7 @@ import { ChevronRight, Copy, FileCode, + FolderPlus, Plus, RefreshCw, Search, @@ -36,6 +37,8 @@ export function MocksSidebar() { const duplicateMockServer = useWorkspaceStore((s) => s.duplicateMockServer); const duplicateMockEndpoint = useWorkspaceStore((s) => s.duplicateMockEndpoint); const refreshMockServer = useWorkspaceStore((s) => s.refreshMockServer); + const promoteMockEndpointToRequest = useWorkspaceStore((s) => s.promoteMockEndpointToRequest); + const pushToast = useWorkspaceStore((s) => s.pushToast); const allServers = Object.values(mockServers); const [expanded, setExpanded] = useState>({}); @@ -227,28 +230,47 @@ export function MocksSidebar() { visibleEndpoints.map((endpoint) => { const isActive = activeServerId === server.id && activeEndpointId === endpoint.id; - const endpointItems: KebabMenuItem[] = isLinked - ? [] - : [ - { - id: 'duplicate', - label: 'Duplicate', - icon:

    • { + return { + method: ep.method, + url: ep.pathPattern, + query: ep.requestSchema.queryParams.map((p) => ({ + key: p.name, + value: p.example != null ? String(p.example) : '', + enabled: false, + })), + headers: ep.requestSchema.headers.map((p) => ({ key: p.name, value: '', enabled: false })), + pathParams: Object.fromEntries(ep.requestSchema.pathParams.map((p) => [p.name, ''])), + }; +} + export interface ResolvedMockEndpoints { endpoints: MockEndpoint[]; warnings: string[]; diff --git a/packages/ui-components/src/store/promoteMockEndpoint.test.ts b/packages/ui-components/src/store/promoteMockEndpoint.test.ts new file mode 100644 index 00000000..79098de5 --- /dev/null +++ b/packages/ui-components/src/store/promoteMockEndpoint.test.ts @@ -0,0 +1,58 @@ +import { act } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { useWorkspaceStore } from './workspaceStore'; + +const store = () => useWorkspaceStore.getState(); + +async function seedManualMockWithEndpoint(): Promise<{ mockId: string; endpointId: string }> { + let mockId = ''; + await act(async () => { + const r = await store().createMockServer({ + name: 'API', + source: { kind: 'manual', endpoints: [] }, + }); + mockId = r.id; + }); + let endpointId = ''; + act(() => { + endpointId = store().addMockEndpoint(mockId); + store().updateMockEndpoint(mockId, endpointId, { + method: 'POST', + pathPattern: '/pets/{id}', + requestSchema: { + pathParams: [{ id: 'p1', name: 'id' }], + queryParams: [{ id: 'q1', name: 'limit' }], + headers: [{ id: 'h1', name: 'X-Key' }], + cookies: [], + }, + }); + }); + return { mockId, endpointId }; +} + +describe('promoteMockEndpointToRequest', () => { + beforeEach(async () => { + await act(async () => { + await store().hydrate(); + }); + }); + + it('creates a request from a mock endpoint (method + path + params)', async () => { + const { mockId, endpointId } = await seedManualMockWithEndpoint(); + let reqId: string | null = null; + act(() => { + reqId = store().promoteMockEndpointToRequest(mockId, endpointId); + }); + expect(reqId).toBeTruthy(); + const req = store().synced!.collections.requests[reqId!]; + expect(req.method).toBe('POST'); + expect(req.url).toBe('/pets/{id}'); + expect(req.query.some((q) => q.key === 'limit' && !q.enabled)).toBe(true); + expect(req.headers.some((h) => h.key === 'X-Key')).toBe(true); + expect(req.pathParams).toHaveProperty('id'); + }); + + it('returns null for a missing mock or endpoint', () => { + expect(store().promoteMockEndpointToRequest('no-mock', 'no-ep')).toBeNull(); + }); +}); diff --git a/packages/ui-components/src/store/workspaceStore.ts b/packages/ui-components/src/store/workspaceStore.ts index 4d94e000..6085624f 100644 --- a/packages/ui-components/src/store/workspaceStore.ts +++ b/packages/ui-components/src/store/workspaceStore.ts @@ -51,7 +51,7 @@ import { } from './githubPrCapability'; import { decideRetirement, probeBranchRetirement } from './branchRetirement'; import { summarizeUploadedSpec } from './specUpload'; -import { resolveMockEndpoints } from './mockResolve'; +import { resolveMockEndpoints, requestShapeFromMockEndpoint } from './mockResolve'; import { applyFont } from '../theme/applyFont'; import { applyFontSize, clampFontSizePercent } from '../theme/applyFontSize'; import { @@ -1182,6 +1182,17 @@ type WorkspaceStore = { specAssetId?: string; title?: string; }) => Promise<{ folderId: string | null; requests: number; warnings: string[] }>; + /** + * Promote a mock endpoint into a saved request in the collection tree — maps + * method + path pattern + request-schema params to a new request under + * `parentFolderId` (root when null). Returns the new request id, or null when + * the mock / endpoint is missing. + */ + promoteMockEndpointToRequest: ( + mockId: string, + endpointId: string, + parentFolderId?: string | null, + ) => string | null; /** * Import a parsed Postman environment. Returns the final env name * (uniquified if it collided), or null if no synced doc was loaded. @@ -3351,19 +3362,7 @@ export const useWorkspaceStore = create((set, get) => ({ const { synced: next, request } = addRequestAction(cur, folder.id, name); const patched: ApiRequest = { ...request, - method: ep.method, - url: ep.pathPattern, - query: ep.requestSchema.queryParams.map((p) => ({ - key: p.name, - value: p.example != null ? String(p.example) : '', - enabled: false, - })), - headers: ep.requestSchema.headers.map((p) => ({ - key: p.name, - value: '', - enabled: false, - })), - pathParams: Object.fromEntries(ep.requestSchema.pathParams.map((p) => [p.name, ''])), + ...requestShapeFromMockEndpoint(ep), specAssetId, operationId: `${ep.method} ${ep.pathPattern}`, }; @@ -3385,6 +3384,36 @@ export const useWorkspaceStore = create((set, get) => ({ return { folderId: rootFolderId, requests: created, warnings }; }, + promoteMockEndpointToRequest: (mockId, endpointId, parentFolderId = null) => { + const synced = get().synced; + if (!synced) return null; + const server = synced.mockServers[mockId]; + const ep = server?.endpoints.find((e) => e.id === endpointId); + if (!server || !ep) return null; + let requestId = ''; + set((state) => { + if (!state.synced) return {}; + const { synced: next, request } = addRequestAction( + state.synced, + parentFolderId, + ep.name || `${ep.method} ${ep.pathPattern}`, + ); + const patched: ApiRequest = { ...request, ...requestShapeFromMockEndpoint(ep) }; + requestId = request.id; + return { + synced: { + ...next, + collections: { + ...next.collections, + requests: { ...next.collections.requests, [request.id]: patched }, + }, + meta: { ...next.meta, updatedAt: new Date().toISOString() }, + }, + }; + }); + return requestId || null; + }, + importPostmanEnvironment: (parsed) => { const state = get(); if (!state.synced) return null; From a18d646c71656fd266d631418b9da39037e9bc1a Mon Sep 17 00:00:00 2001 From: apicircle-dev Date: Sat, 11 Jul 2026 23:18:52 +0530 Subject: [PATCH 05/16] feat(assets): track spec-asset usage (mocks + imported collections) --- CHANGELOG.md | 9 ++++ apps/vscode/README.md | 2 +- packages/shared/src/types.ts | 9 ++++ .../src/layout/dock/GlobalAssetsDockPanel.tsx | 15 ++++++- .../src/panels/help/helpContent.ts | 2 +- .../src/store/assetUsageAggregator.test.ts | 30 +++++++++++++ .../src/store/assetUsageAggregator.ts | 45 +++++++++++++++++++ 7 files changed, 109 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fff42e7c..c4deb0d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,15 @@ (catalog 95 → **96**). A shared `requestShapeFromMockEndpoint` mapper keeps promoted requests and OpenAPI-imported requests identical. (`@apicircle/ui-components`, `@apicircle/mcp-server`) +- **Spec-asset usage tracking (additive).** The Global Assets usage index now + counts, per spec asset, the mock servers whose source it is (`openapi-asset`) + and the requests imported from it (`Request.specAssetId`) — so the Assets + panel's "Used in N" and the delete confirmation surface the mocks and + collections a spec backs. This completes the spec-asset hub: upload a spec + once, then run/import it as a mock, import it as a collection, promote mock + endpoints into requests, and (in the Lens edition) drift-check code against it, + all referencing the same asset. + (`@apicircle/shared`, `@apicircle/ui-components`) - **UI sections/mode seam (open-core, additive).** Building on the `extraPanels` seam, the React shell (`@apicircle/ui-components`) now accepts edition-contributed top-level **sections** ("modes") via an optional `App` `sections` prop diff --git a/apps/vscode/README.md b/apps/vscode/README.md index be8a862a..c8a9a7cc 100644 --- a/apps/vscode/README.md +++ b/apps/vscode/README.md @@ -47,7 +47,7 @@ VS Code is where the same engineers who use API Circle already live with Git. Ed ### MCP integration -- **95-tool MCP catalog** for AI clients — Claude Desktop, Claude Code, Codex, Cursor, Copilot, Windsurf, Zed, Continue, Cline. +- **96-tool MCP catalog** for AI clients — Claude Desktop, Claude Code, Codex, Cursor, Copilot, Windsurf, Zed, Continue, Cline. - **One-click Copilot Chat install** — writes `.vscode/mcp.json` idempotently. - **Per-client config snippets** — copy or direct-install MCP configuration. - **Curated prompts** — 19 starter prompts across 7 categories in the MCP sidebar. diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index db30ce43..2b381508 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -512,6 +512,15 @@ export interface AssetUsage { requests: string[]; /** Mock endpoints whose responses bind this asset. */ mockEndpoints: Array<{ mockId: string; endpointId: string }>; + /** + * Mock servers whose SOURCE is this spec asset (`openapi-asset`). Present only + * for spec assets (Increment E). Deleting the asset breaks linked mocks and + * removes the re-import source for materialized ones, so the delete cascade + * surfaces these. + */ + mockServers?: string[]; + /** Request ids imported from this spec asset (`Request.specAssetId`) — spec assets only (Increment E). */ + importedRequests?: string[]; /** Total reference count — denormalised for cheap badge rendering. */ total: number; } diff --git a/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.tsx b/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.tsx index 68645d2c..1b1f90cb 100644 --- a/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.tsx +++ b/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.tsx @@ -37,7 +37,7 @@ import { SpecAssetBadge } from '../../primitives/SpecAssetBadge'; import { getAttachment } from '../../persistence/attachments'; interface FileAssetConsumer { - kind: 'request' | 'mock'; + kind: 'request' | 'mock' | 'spec-mock' | 'spec-request'; /** Friendly label for the row ("My request", "Petstore · GET /pets"). */ label: string; /** Stable id for the list `key`. */ @@ -59,6 +59,19 @@ function consumersFromIndex( const label = meta ? `${meta.server} · ${meta.endpoint}` : `${ref.mockId} · ${ref.endpointId}`; out.push({ kind: 'mock', id: `mock:${ref.mockId}:${ref.endpointId}`, label }); } + // Spec-source consumers (Increment E): mocks driven by this spec asset + + // requests imported from it. + for (const mockId of usage.mockServers ?? []) { + const named = Object.entries(mockNames).find(([key]) => key.startsWith(`${mockId}:`)); + out.push({ + kind: 'spec-mock', + id: `spec-mock:${mockId}`, + label: named ? named[1].server : mockId, + }); + } + for (const id of usage.importedRequests ?? []) { + out.push({ kind: 'spec-request', id: `spec-req:${id}`, label: requestNames[id] ?? id }); + } return out; } diff --git a/packages/ui-components/src/panels/help/helpContent.ts b/packages/ui-components/src/panels/help/helpContent.ts index 02e1a893..4efb8e39 100644 --- a/packages/ui-components/src/panels/help/helpContent.ts +++ b/packages/ui-components/src/panels/help/helpContent.ts @@ -1351,7 +1351,7 @@ Each asset shows a small status pill next to its name. The pill tells you where - **Missing** — both refs dropped and no local copy. Re-upload from the same row to restore. - **Diverged** — both refs hold different blob shas. Usually means someone force-pushed the base branch with a different file at the same slot — review before pushing. -Each row also shows "Used in N" — clicking through the Global Assets panel shows every request and mock endpoint that binds to the file. Zero-use assets get an "Unused" badge so you can identify and prune orphans deliberately. +Each row also shows "Used in N" — clicking through the Global Assets panel shows every request and mock endpoint that binds to the file. For a spec asset, that count also includes the mock servers built from it and the requests imported from it, so you can see what a spec backs before deleting it. Zero-use assets get an "Unused" badge so you can identify and prune orphans deliberately. When a workspace is pushed to GitHub, file bytes are stored as attachment blobs next to the synced doc under \`.apicircle/workspace-/attachments/\`, separate from the workspace document. That keeps the JSON small and makes diffs readable. On another machine, linked or synced file assets show as missing until you download them. Sending a request or running a plan that needs missing files opens a download prompt; after the download verifies the checksum, execution continues. The \`apicircle run\` CLI follows the same rule for headless plans. diff --git a/packages/ui-components/src/store/assetUsageAggregator.test.ts b/packages/ui-components/src/store/assetUsageAggregator.test.ts index e9baa8d8..cb5022f7 100644 --- a/packages/ui-components/src/store/assetUsageAggregator.test.ts +++ b/packages/ui-components/src/store/assetUsageAggregator.test.ts @@ -110,6 +110,36 @@ describe('aggregateAssetUsage', () => { expect(aggregateAssetUsage(makeSynced())).toEqual({}); }); + it('tracks spec-source usage — mocks driven by a spec asset + imported requests', () => { + const synced = makeSynced({ + mockServers: { + m1: { + id: 'm1', + name: 'Live', + source: { kind: 'openapi-asset', assetId: 'spec1', format: 'json', mode: 'linked' }, + endpoints: [], + defaultPort: null, + cors: { enabled: false, origins: [] }, + createdAt: T, + updatedAt: T, + }, + }, + collections: { + tree: { id: 'r', type: 'root', children: [] }, + requests: { + r1: makeRequest('r1', { specAssetId: 'spec1' }), + r2: makeRequest('r2', { specAssetId: 'spec1' }), + r3: makeRequest('r3'), // not imported from a spec — must be ignored + }, + folders: {}, + }, + }); + const usage = aggregateAssetUsage(synced); + expect(usage['spec1']?.mockServers).toEqual(['m1']); + expect([...(usage['spec1']?.importedRequests ?? [])].sort()).toEqual(['r1', 'r2']); + expect(usage['spec1']?.total).toBe(3); // 1 mock source + 2 imported requests + }); + it('counts binary request bodies bound to an asset', () => { const synced = makeSynced({ collections: { diff --git a/packages/ui-components/src/store/assetUsageAggregator.ts b/packages/ui-components/src/store/assetUsageAggregator.ts index d35e6ea4..02345452 100644 --- a/packages/ui-components/src/store/assetUsageAggregator.ts +++ b/packages/ui-components/src/store/assetUsageAggregator.ts @@ -82,6 +82,39 @@ export function aggregateAssetUsage(synced: WorkspaceSynced): Record { + let entry = out[assetId]; + if (!entry) { + entry = { requests: [], mockEndpoints: [], total: 0 }; + out[assetId] = entry; + } + if (kind === 'mockServer') { + if (!entry.mockServers) entry.mockServers = []; + if (!entry.mockServers.includes(id)) { + entry.mockServers.push(id); + entry.total += 1; + } + } else { + if (!entry.importedRequests) entry.importedRequests = []; + if (!entry.importedRequests.includes(id)) { + entry.importedRequests.push(id); + entry.total += 1; + } + } + }; + for (const server of Object.values(synced.mockServers ?? {})) { + if (server.source.kind === 'openapi-asset') { + pushSpec(server.source.assetId, 'mockServer', server.id); + } + } + for (const req of Object.values(synced.collections.requests)) { + if (req.specAssetId) pushSpec(req.specAssetId, 'importedRequest', req.id); + } + return out; } @@ -192,6 +225,18 @@ function sameUsageIndex( return false; } } + const xMocks = x.mockServers ?? []; + const yMocks = y.mockServers ?? []; + if (xMocks.length !== yMocks.length) return false; + for (let i = 0; i < xMocks.length; i++) { + if (xMocks[i] !== yMocks[i]) return false; + } + const xImp = x.importedRequests ?? []; + const yImp = y.importedRequests ?? []; + if (xImp.length !== yImp.length) return false; + for (let i = 0; i < xImp.length; i++) { + if (xImp[i] !== yImp[i]) return false; + } } return true; } From 91aecdbc005f4aa744fe7c1e7b76ce1cd2f78930 Mon Sep 17 00:00:00 2001 From: apicircle-dev Date: Sun, 12 Jul 2026 00:40:15 +0530 Subject: [PATCH 06/16] feat(mocks): add "Serve OpenAPI contract" run-live entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote the run-live (linked) mock path to a first-class, discoverable flow. Previously "run live" was a radio buried in the New Mock Server modal, so a contract server came out indistinguishable from a plain import. - New "Serve OpenAPI contract" action in the Mocks header opens a dedicated ServeContractModal: pick a spec asset, see a contract preview (title, version, dialect, op count), name it, choose a port; creates a linked (read-only, in-sync) mock and activates it so Start/Stop is right there. - The unified "New Mock Server > From spec asset" path now always IMPORTS (materialized/editable) and points to the new flow for run-live — removes the confusing dual toggle that made this ambiguous. - The mock panel shows a "Served directly from contract" callout + a friendly source label ("OpenAPI contract (live)") for a linked mock. - Store: mocksServeContractModalOpen + open/close actions (mirrors the create modal). No new persisted shape or MCP tool — reuses createMockServer + setMockServerDefaultPort. Additive; existing mocks and sources unaffected. Tests: ServeContractModal (preview/create/port/validation/cancel/error), sidebar 2-action menu, linked-mock panel callout, unified-modal import + toggle-removal. Docs: CHANGELOG, mock-server.md, Help. --- CHANGELOG.md | 8 +- docs/mock-server.md | 22 +- .../src/panels/help/helpContent.ts | 3 +- .../mocks/CreateMockServerModal.test.tsx | 24 +- .../panels/mocks/CreateMockServerModal.tsx | 48 +--- .../panels/mocks/MockServersPanel.test.tsx | 35 ++- .../src/panels/mocks/MockServersPanel.tsx | 48 +++- .../src/panels/mocks/MocksSidebar.test.tsx | 14 +- .../src/panels/mocks/MocksSidebar.tsx | 8 + .../panels/mocks/ServeContractModal.test.tsx | 176 +++++++++++++ .../src/panels/mocks/ServeContractModal.tsx | 232 ++++++++++++++++++ .../ui-components/src/store/workspaceStore.ts | 13 + 12 files changed, 574 insertions(+), 57 deletions(-) create mode 100644 packages/ui-components/src/panels/mocks/ServeContractModal.test.tsx create mode 100644 packages/ui-components/src/panels/mocks/ServeContractModal.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index c4deb0d2..c30a600d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,8 +47,12 @@ helper; the store resolves the asset's bytes → parses → materializes, auto- refreshes linked mocks when the asset changes, and keeps linked mocks read-only on every surface. New `refreshMockServer` store action + `mock.refresh` MCP tool - (catalog **94 → 95**). The "Create mock server" modal gains a **From spec - asset** tab (Run live / Import & edit). + (catalog **94 → 95**). Two distinct entry points in the Mocks header: **New + Mock Server → From spec asset** imports a contract as editable endpoints + (materialized), and **Serve OpenAPI contract** stands up a live, read-only + server straight from a contract (linked) with a spec preview, name, and port. + The mock panel shows a "Served directly from contract" callout and a friendly + source label for a live contract server. (`@apicircle/shared`, `@apicircle/mock-server-core`, `@apicircle/ui-components`, `@apicircle/mcp-server`, VS Code) - **Import an OpenAPI/Swagger spec into a collection (editor, additive).** The diff --git a/docs/mock-server.md b/docs/mock-server.md index 82356291..b45ed21c 100644 --- a/docs/mock-server.md +++ b/docs/mock-server.md @@ -114,21 +114,23 @@ parsed immediately and the resulting `MockEndpoint[]` is stored on `MockServer.endpoints`. The runtime router serves that array verbatim and never re-parses `source`, so a mock created with zero endpoints stays empty. -### Spec sources: paste, or a spec asset (run live / import) +### Spec sources: paste, or a spec asset (serve live / import) A spec-backed mock's `source` is either a **verbatim inline spec** (`{ kind: 'openapi', spec, format }`, from the paste-spec flow) or a reference to an uploaded **spec asset** (`{ kind: 'openapi-asset', assetId, format, mode }` — upload the OpenAPI/Swagger file under Global Assets → Files). Asset-backed mocks -come in two modes: - -- **`linked` ("run live")** — endpoints derive from the asset and stay in sync: - re-uploading the spec asset auto-refreshes every linked mock, and the endpoints - are read-only (edits are rejected on every surface). Best for "just stand up my - spec." -- **`materialized` ("import & edit")** — the spec is parsed once into editable - endpoints you can modify; an explicit refresh (`refreshMockServer` / the - `mock.refresh` MCP tool) re-imports from the asset. +come in two modes, each with its own entry point in the Mocks header: + +- **`linked` (Serve OpenAPI contract)** — endpoints derive from the asset and + stay in sync: re-uploading the spec asset auto-refreshes every linked mock, and + the endpoints are read-only (edits are rejected on every surface). The dedicated + **Serve OpenAPI contract** flow picks a contract, name, and port and stands up a + live, read-only server; the mock panel shows a "Served directly from contract" + callout. Best for "just run my contract." +- **`materialized` (New Mock Server → From spec asset)** — the spec is parsed + once into editable endpoints you can modify; an explicit refresh + (`refreshMockServer` / the `mock.refresh` MCP tool) re-imports from the asset. The parser ships as two entry points that differ only in how OpenAPI `$ref`s are dereferenced: diff --git a/packages/ui-components/src/panels/help/helpContent.ts b/packages/ui-components/src/panels/help/helpContent.ts index 4efb8e39..1ac89ff7 100644 --- a/packages/ui-components/src/panels/help/helpContent.ts +++ b/packages/ui-components/src/panels/help/helpContent.ts @@ -967,7 +967,8 @@ Snapshots live only on this machine. They are kept within a size budget — pick - **Empty** — a blank server you add endpoints to by hand. - **From a spec** — paste an OpenAPI, Postman, or Insomnia source; it is parsed into endpoints the moment you create the mock, so the endpoint table is populated right away. On the Desktop app the parse runs in the native process and resolves external \`$ref\`s; the web app resolves in-document \`$ref\`s only and warns about any external references it can't follow. -- **From a spec asset** — build the mock from an OpenAPI/Swagger file you uploaded to Global Assets → Files, in one of two modes. **Run live** (linked) derives the endpoints from the asset and keeps them in sync — re-uploading the spec updates every linked mock, and its endpoints are read-only. **Import & edit** (materialized) parses the spec into editable endpoints you can modify, with a refresh-from-spec re-import when the asset changes. +- **From a spec asset** — import an OpenAPI/Swagger file you uploaded to Global Assets → Files as **editable** endpoints (materialized). Modify them freely; a refresh-from-spec re-import pulls changes when the asset updates. +- **Serve OpenAPI contract** (a separate action in the Mocks header) — stand up a **live, read-only** server straight from an uploaded contract (linked): pick the contract, name it, choose a port, and start/stop it from the mock's panel. Endpoints derive from the contract and stay in sync — re-uploading the spec updates the server, and the panel shows a "Served directly from contract" callout. Use this when you just want to run a contract; use From a spec asset when you want to tweak the endpoints. Every mock endpoint has an **Add to collection** action (the ⋯ menu on the endpoint row) that saves it as a real request in your collection tree — carrying the method, path pattern, and declared query/header/path params. It works even on read-only "run live" mocks, so you can turn a mocked endpoint into a request you send against the real API. diff --git a/packages/ui-components/src/panels/mocks/CreateMockServerModal.test.tsx b/packages/ui-components/src/panels/mocks/CreateMockServerModal.test.tsx index cb01b93f..4af561cb 100644 --- a/packages/ui-components/src/panels/mocks/CreateMockServerModal.test.tsx +++ b/packages/ui-components/src/panels/mocks/CreateMockServerModal.test.tsx @@ -103,7 +103,7 @@ describe('CreateMockServerModal', () => { expect(screen.getByText(/No spec assets yet/i)).toBeInTheDocument(); }); - it('creates a "run live" (linked) mock from a spec asset', async () => { + it('imports a spec asset as editable (materialized) endpoints', async () => { const user = userEvent.setup(); // Seed a spec asset — parse-on-upload tags it as an OpenAPI doc. await act(async () => { @@ -115,17 +115,33 @@ describe('CreateMockServerModal', () => { }); const assetId = Object.values(useWorkspaceStore.getState().synced!.globalAssets.files!)[0].id; - await user.type(screen.getByLabelText('Mock server name'), 'Live petstore'); + await user.type(screen.getByLabelText('Mock server name'), 'Imported petstore'); await user.click(screen.getByRole('button', { name: /From spec asset/i })); await user.selectOptions(await screen.findByLabelText('Spec asset'), assetId); await user.click(screen.getByRole('button', { name: /Create mock server/i })); await waitFor(() => expect(useWorkspaceStore.getState().mocksCreateModalOpen).toBe(false)); const created = Object.values(useWorkspaceStore.getState().synced!.mockServers).find( - (m) => m.name === 'Live petstore', + (m) => m.name === 'Imported petstore', ); expect(created?.source.kind).toBe('openapi-asset'); - if (created?.source.kind === 'openapi-asset') expect(created.source.mode).toBe('linked'); + // The unified modal now always IMPORTS (materialized); "run live" moved to + // the dedicated Serve OpenAPI contract flow. + if (created?.source.kind === 'openapi-asset') expect(created.source.mode).toBe('materialized'); expect(created?.endpoints.length).toBe(1); }); + + it('no longer offers a "run live" toggle on the spec-asset tab, and points to Serve OpenAPI contract', async () => { + const user = userEvent.setup(); + await act(async () => { + await useWorkspaceStore + .getState() + .addGlobalFileAsset( + new File([OPENAPI_JSON], 'petstore.json', { type: 'application/json' }), + ); + }); + await user.click(screen.getByRole('button', { name: /From spec asset/i })); + expect(screen.queryByLabelText('Run live')).not.toBeInTheDocument(); + expect(screen.getByText(/Serve OpenAPI contract/i)).toBeInTheDocument(); + }); }); diff --git a/packages/ui-components/src/panels/mocks/CreateMockServerModal.tsx b/packages/ui-components/src/panels/mocks/CreateMockServerModal.tsx index 4933297c..17e65f97 100644 --- a/packages/ui-components/src/panels/mocks/CreateMockServerModal.tsx +++ b/packages/ui-components/src/panels/mocks/CreateMockServerModal.tsx @@ -44,7 +44,6 @@ export function CreateMockServerModal() { const files = useWorkspaceStore((s) => s.synced?.globalAssets.files); const specAssets = useMemo(() => Object.values(files ?? {}).filter((f) => f.spec), [files]); const [assetId, setAssetId] = useState(''); - const [mockMode, setMockMode] = useState<'linked' | 'materialized'>('linked'); const reset = () => { setName(''); @@ -52,7 +51,6 @@ export function CreateMockServerModal() { setSpecFormat('json'); setSpecText(''); setAssetId(''); - setMockMode('linked'); setError(null); setResult(null); setTab('manual'); @@ -71,11 +69,14 @@ export function CreateMockServerModal() { setError('Select a spec asset to build the mock from.'); return; } + // This unified modal always IMPORTS a spec asset as editable endpoints + // (materialized). Running a contract live (read-only) is its own + // first-class flow — the "Serve OpenAPI contract" entry point. source = { kind: 'openapi-asset', assetId: asset.id, format: asset.spec.format, - mode: mockMode, + mode: 'materialized', }; } else { if (!specText.trim()) { @@ -231,8 +232,11 @@ export function CreateMockServerModal() { ) : tab === 'asset' ? (

      - Build a mock from a spec you’ve uploaded to Global Assets. Upload the - OpenAPI/Swagger file in the Assets → Files tab first. + Import a spec you’ve uploaded to Global Assets as editable endpoints you can + modify — re-import from the spec anytime. To run a contract{' '} + live (read-only, always in sync), use{' '} + Serve OpenAPI contract in the Mocks + menu instead. Upload OpenAPI/Swagger files under Assets → Files first.

      {specAssets.length === 0 ? (
      @@ -266,37 +270,9 @@ export function CreateMockServerModal() { ))} -
      - Mode - - -
      +

      + Endpoints are parsed into an editable table you can modify after creating. +

      )}
      diff --git a/packages/ui-components/src/panels/mocks/MockServersPanel.test.tsx b/packages/ui-components/src/panels/mocks/MockServersPanel.test.tsx index 3556c666..df7fd116 100644 --- a/packages/ui-components/src/panels/mocks/MockServersPanel.test.tsx +++ b/packages/ui-components/src/panels/mocks/MockServersPanel.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { screen } from '@testing-library/react'; +import { act, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { MockRuntimeEntry, MockServer } from '@apicircle/shared'; import { MockServersPanel } from './MockServersPanel'; @@ -8,6 +8,16 @@ import { useWorkspaceStore } from '../../store/workspaceStore'; const T0 = '2026-04-27T00:00:00.000Z'; +const OPENAPI_JSON = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Petstore', version: '1.0.0' }, + paths: { + '/pets': { + get: { responses: { '200': { content: { 'application/json': { example: [{ id: 1 }] } } } } }, + }, + }, +}); + function fixtureMock(id: string, name: string): MockServer { const endpoint = { id: 'ep1', @@ -94,6 +104,29 @@ describe('MockServersPanel (post-rich-editor redesign)', () => { expect(await screen.findByLabelText('Mock server name')).toHaveValue('Petstore'); }); + it('shows a "Served directly from contract" callout + friendly label for a linked mock', async () => { + await renderWithStore(); + await act(async () => { + await useWorkspaceStore + .getState() + .addGlobalFileAsset( + new File([OPENAPI_JSON], 'petstore.json', { type: 'application/json' }), + ); + }); + const assetId = Object.values(useWorkspaceStore.getState().synced!.globalAssets.files!)[0].id; + const { id } = await useWorkspaceStore.getState().createMockServer({ + name: 'Petstore live', + source: { kind: 'openapi-asset', assetId, format: 'json', mode: 'linked' }, + }); + act(() => { + useWorkspaceStore.getState().setActiveMockEndpoint({ serverId: id, endpointId: null }); + }); + + expect(await screen.findByText(/Served directly from contract/i)).toBeInTheDocument(); + // Friendly source label instead of the raw `openapi-asset` union tag. + expect(screen.getByText('OpenAPI contract (live)')).toBeInTheDocument(); + }); + it('renders the MockEndpointEditor flow + node editor when both a server and endpoint are active', async () => { await renderWithStore(); useWorkspaceStore.setState((s) => ({ diff --git a/packages/ui-components/src/panels/mocks/MockServersPanel.tsx b/packages/ui-components/src/panels/mocks/MockServersPanel.tsx index e82fd5c8..56245f81 100644 --- a/packages/ui-components/src/panels/mocks/MockServersPanel.tsx +++ b/packages/ui-components/src/panels/mocks/MockServersPanel.tsx @@ -1,11 +1,29 @@ import { useEffect, useState } from 'react'; -import { AlertTriangle, Loader2, Play, Plus, Server, Square, Trash2 } from 'lucide-react'; +import { AlertTriangle, Loader2, Play, Plus, Radio, Server, Square, Trash2 } from 'lucide-react'; import type { MockRuntimeEntry, MockServer } from '@apicircle/shared'; import { useWorkspaceStore } from '../../store/workspaceStore'; import { MockEndpointEditor } from './MockEndpointEditor'; import { CreateMockServerModal } from './CreateMockServerModal'; +import { ServeContractModal } from './ServeContractModal'; import { DesktopAppLink } from '../../primitives/desktopDownload'; +// Friendly label for a mock's source kind — the raw union tags (`openapi-asset`) +// read poorly in the panel; a linked contract in particular should say so. +function friendlySourceKind(source: MockServer['source']): string { + switch (source.kind) { + case 'manual': + return 'Manual'; + case 'openapi': + return 'OpenAPI (pasted)'; + case 'postman': + return 'Postman'; + case 'insomnia': + return 'Insomnia'; + case 'openapi-asset': + return source.mode === 'linked' ? 'OpenAPI contract (live)' : 'OpenAPI (imported from asset)'; + } +} + // ============================================================================= // MockServersPanel — split pane: the sidebar (handled by Sidebar.tsx for the // 'mocks' panel) selects a server + endpoint; this pane renders the @@ -218,6 +236,7 @@ export function MockServersPanel() { )} +
      ); } @@ -272,6 +291,11 @@ function ServerSummary({ onStop: () => void; }) { const setMockServerName = useWorkspaceStore((s) => s.setMockServerName); + const files = useWorkspaceStore((s) => s.synced?.globalAssets.files); + // A "run live" (linked) contract mock gets a clear provenance callout below. + const src = server.source; + const linked = src.kind === 'openapi-asset' && src.mode === 'linked' ? src : null; + const linkedAsset = linked ? files?.[linked.assetId] : undefined; return (
      @@ -286,9 +310,29 @@ function ServerSummary({ className="mt-1 h-8 w-full max-w-md rounded-sm border border-border bg-card px-2 text-sm text-text-primary focus:border-accent focus:outline-none" />
      + {linked && ( +
      +
      + )}
      Source kind
      -
      {server.source.kind}
      +
      {friendlySourceKind(server.source)}
      Endpoints
      {server.endpoints.length}
      diff --git a/packages/ui-components/src/panels/mocks/MocksSidebar.test.tsx b/packages/ui-components/src/panels/mocks/MocksSidebar.test.tsx index 9f521127..669bdac4 100644 --- a/packages/ui-components/src/panels/mocks/MocksSidebar.test.tsx +++ b/packages/ui-components/src/panels/mocks/MocksSidebar.test.tsx @@ -1,7 +1,7 @@ import { act, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it } from 'vitest'; -import { MocksSidebar } from './MocksSidebar'; +import { MocksSidebar, MocksSidebarActions } from './MocksSidebar'; import { renderWithStore } from '../../../test/renderWithStore'; import { useWorkspaceStore } from '../../store/workspaceStore'; @@ -77,3 +77,15 @@ describe('MocksSidebar — asset-backed mocks', () => { }); }); }); + +describe('MocksSidebarActions', () => { + it('offers "New Mock Server" and "Serve OpenAPI contract"; the latter opens the serve modal', async () => { + await renderWithStore(); + await userEvent.click(screen.getByRole('button', { name: /Mocks actions/i })); + expect(screen.getByText('New Mock Server')).toBeInTheDocument(); + const serve = screen.getByText('Serve OpenAPI contract'); + expect(serve).toBeInTheDocument(); + await userEvent.click(serve); + expect(useWorkspaceStore.getState().mocksServeContractModalOpen).toBe(true); + }); +}); diff --git a/packages/ui-components/src/panels/mocks/MocksSidebar.tsx b/packages/ui-components/src/panels/mocks/MocksSidebar.tsx index 1d5b1f90..c99ecaf7 100644 --- a/packages/ui-components/src/panels/mocks/MocksSidebar.tsx +++ b/packages/ui-components/src/panels/mocks/MocksSidebar.tsx @@ -6,6 +6,7 @@ import { FileCode, FolderPlus, Plus, + Radio, RefreshCw, Search, Server, @@ -367,6 +368,7 @@ export function MocksSidebar() { */ export function MocksSidebarActions() { const openCreateMockServer = useWorkspaceStore((s) => s.openMocksCreateModal); + const openServeContract = useWorkspaceStore((s) => s.openMocksServeContractModal); const items: KebabMenuItem[] = [ { @@ -375,6 +377,12 @@ export function MocksSidebarActions() { icon:
      +
      + +
      + + + +
      +
      + +
      +
      +
      + +
      + ); } diff --git a/packages/ui-components/src/panels/mocks/MockResponseEditor.tsx b/packages/ui-components/src/panels/mocks/MockResponseEditor.tsx index ffedf23f..f00fa6f9 100644 --- a/packages/ui-components/src/panels/mocks/MockResponseEditor.tsx +++ b/packages/ui-components/src/panels/mocks/MockResponseEditor.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { useContext, useRef, useState } from 'react'; import { Paperclip, Plus, Trash2, X } from 'lucide-react'; import { coerceMockResponseBodyTypeForStatus, @@ -19,6 +19,7 @@ import { applyContentTypeForBodyType } from '@apicircle/core'; import { useWorkspaceStore } from '../../store/workspaceStore'; import { FilePickerMenu } from '../../primitives/FilePickerMenu'; import { MonacoBodyEditor } from '../../editors/MonacoBodyEditor'; +import { MockReadOnlyContext } from './mockReadOnly'; import { HeaderKeyAutocomplete, HeaderValueRecommendations } from '../editor/HeaderAutocomplete'; import { cn } from '../../primitives/cn'; import { FileAssetStatusPill } from '../../primitives/FileAssetStatusPill'; @@ -356,6 +357,7 @@ function BodyContentEditor({ attachmentSlot: { serverId: string; endpointId: string } | null; compact?: boolean; }) { + const readOnly = useContext(MockReadOnlyContext); if (body.type === 'none') { return (

      @@ -392,6 +394,7 @@ function BodyContentEditor({ onChange={onContentChange} modelPath={`inmemory://apicircle/mock-response/${attachmentSlot?.endpointId ?? 'inline'}.body`} ariaLabel={`${label} body`} + readOnly={readOnly} />

    ); diff --git a/packages/ui-components/src/panels/mocks/MockServersPanel.test.tsx b/packages/ui-components/src/panels/mocks/MockServersPanel.test.tsx index df7fd116..65da791b 100644 --- a/packages/ui-components/src/panels/mocks/MockServersPanel.test.tsx +++ b/packages/ui-components/src/panels/mocks/MockServersPanel.test.tsx @@ -127,6 +127,54 @@ describe('MockServersPanel (post-rich-editor redesign)', () => { expect(screen.getByText('OpenAPI contract (live)')).toBeInTheDocument(); }); + it('renders the endpoint editor READ-ONLY for a linked contract mock', async () => { + await renderWithStore(); + await act(async () => { + await useWorkspaceStore + .getState() + .addGlobalFileAsset( + new File([OPENAPI_JSON], 'petstore.json', { type: 'application/json' }), + ); + }); + const assetId = Object.values(useWorkspaceStore.getState().synced!.globalAssets.files!)[0].id; + const { id } = await useWorkspaceStore.getState().createMockServer({ + name: 'Live', + source: { kind: 'openapi-asset', assetId, format: 'json', mode: 'linked' }, + }); + const ep = useWorkspaceStore.getState().synced!.mockServers[id].endpoints[0]; + act(() => { + useWorkspaceStore.getState().setActiveMockEndpoint({ serverId: id, endpointId: ep.id }); + }); + + // Banner + native controls disabled via the wrapping
    . + expect(await screen.findByText(/Read-only/i)).toBeInTheDocument(); + expect(screen.getByLabelText('Mock endpoint method')).toBeDisabled(); + expect(screen.getByLabelText('Mock endpoint name')).toBeDisabled(); + }); + + it('leaves the endpoint editor EDITABLE for a materialized (imported) mock', async () => { + await renderWithStore(); + await act(async () => { + await useWorkspaceStore + .getState() + .addGlobalFileAsset( + new File([OPENAPI_JSON], 'petstore.json', { type: 'application/json' }), + ); + }); + const assetId = Object.values(useWorkspaceStore.getState().synced!.globalAssets.files!)[0].id; + const { id } = await useWorkspaceStore.getState().createMockServer({ + name: 'Imported', + source: { kind: 'openapi-asset', assetId, format: 'json', mode: 'materialized' }, + }); + const ep = useWorkspaceStore.getState().synced!.mockServers[id].endpoints[0]; + act(() => { + useWorkspaceStore.getState().setActiveMockEndpoint({ serverId: id, endpointId: ep.id }); + }); + + expect(await screen.findByLabelText('Mock endpoint method')).not.toBeDisabled(); + expect(screen.queryByText(/Read-only/i)).not.toBeInTheDocument(); + }); + it('renders the MockEndpointEditor flow + node editor when both a server and endpoint are active', async () => { await renderWithStore(); useWorkspaceStore.setState((s) => ({ diff --git a/packages/ui-components/src/panels/mocks/MocksSidebar.tsx b/packages/ui-components/src/panels/mocks/MocksSidebar.tsx index c99ecaf7..83982fa0 100644 --- a/packages/ui-components/src/panels/mocks/MocksSidebar.tsx +++ b/packages/ui-components/src/panels/mocks/MocksSidebar.tsx @@ -201,16 +201,16 @@ export function MocksSidebar() { aria-label={`Open ${server.name}`} aria-current={isServerActive ? 'true' : undefined} className={cn( - 'flex h-full flex-1 items-center gap-1 rounded-sm px-1.5 text-left text-[0.6875rem]', + 'flex h-full min-w-0 flex-1 items-center gap-1 rounded-sm px-1.5 text-left text-[0.6875rem]', isServerActive ? 'text-accent' : 'text-text-primary', )} > -