{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 (
+
+
+ {!iconOnly && {`${dialect} · ${ops}`}}
+
+ );
+}
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.
-
+
@@ -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() {
Paste spec
+
{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 ? (
+
+
+
+ No spec assets yet. Upload an OpenAPI/Swagger file under Assets → Files,
+ then return here.
+
+ {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.
-
+
@@ -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: ,
- onSelect: () => duplicateMockEndpoint(server.id, endpoint.id),
- },
- {
- id: 'delete',
- label: 'Delete',
- icon: ,
- tone: 'danger',
- onSelect: () =>
- setPendingEndpointDelete({
- serverId: server.id,
- endpointId: endpoint.id,
- label: `${endpoint.method} ${endpoint.pathPattern}`,
- }),
- },
- ];
+ const endpointItems: KebabMenuItem[] = [
+ {
+ id: 'promote',
+ label: 'Add to collection',
+ icon: ,
+ onSelect: () => {
+ const newId = promoteMockEndpointToRequest(server.id, endpoint.id);
+ if (newId) {
+ pushToast({
+ tone: 'success',
+ title: `Added ${endpoint.method} ${endpoint.pathPattern} to the collection`,
+ ttlMs: 5000,
+ });
+ }
+ },
+ },
+ ];
+ // Linked ("run live") mocks are read-only, so only the
+ // non-mutating "Add to collection" action shows.
+ if (!isLinked) {
+ endpointItems.push(
+ {
+ id: 'duplicate',
+ label: 'Duplicate',
+ icon: ,
+ onSelect: () => duplicateMockEndpoint(server.id, endpoint.id),
+ },
+ {
+ id: 'delete',
+ label: 'Delete',
+ icon: ,
+ tone: 'danger',
+ onSelect: () =>
+ setPendingEndpointDelete({
+ serverId: server.id,
+ endpointId: endpoint.id,
+ label: `${endpoint.method} ${endpoint.pathPattern}`,
+ }),
+ },
+ );
+ }
return (
{
+ 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.
.
+ 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',
)}
>
-
+ {server.name}
{server.source.kind !== 'manual' && (
` — also
+// goes read-only. Standalone module so the two editors don't form an import
+// cycle.
+export const MockReadOnlyContext = createContext(false);
From f6e942192f59ab7ebd4c972c0181abd05396a5f1 Mon Sep 17 00:00:00 2001
From: apicircle-dev
Date: Sun, 12 Jul 2026 10:23:10 +0530
Subject: [PATCH 08/16] feat(mocks): convert a live contract mock into an
editable one
A "Serve OpenAPI contract" (linked, read-only) mock can now be unlocked for
editing in place, instead of re-creating it as a separate import.
- New convertMockToEditable(serverId) store action flips a linked openapi-asset
source to materialized: endpoints are preserved (already on the server, no
re-parse) and the spec link is kept, so "Re-import from spec" and asset-usage
tracking keep working. No-op for non-linked/manual/unknown mocks; success toast.
- Surfaced in all three read-only contexts: the Mocks sidebar kebab ("Convert
to editable mock"), the endpoint-editor read-only banner ("Convert to
editable", replacing the old import hint), and the panel's "Served directly
from contract" callout.
- Shorten the panel's source-kind label for a live contract from "OpenAPI
contract (live)" to "OpenAPI contract" (the callout already conveys liveness).
Tests: store flip preserves endpoints + unlocks editing + no-op cases; sidebar
action flips to materialized; banner button unlocks the editor. Docs: CHANGELOG,
mock-server.md, Help. Additive; other mocks unaffected.
---
CHANGELOG.md | 9 ++--
docs/mock-server.md | 5 +-
.../src/panels/help/helpContent.ts | 2 +-
.../src/panels/mocks/MockEndpointEditor.tsx | 16 ++++--
.../panels/mocks/MockServersPanel.test.tsx | 35 ++++++++++++-
.../src/panels/mocks/MockServersPanel.tsx | 23 ++++++++-
.../src/panels/mocks/MocksSidebar.test.tsx | 19 +++++++
.../src/panels/mocks/MocksSidebar.tsx | 12 +++++
.../src/store/mockTwoModes.test.ts | 50 +++++++++++++++++++
.../ui-components/src/store/workspaceStore.ts | 38 ++++++++++++++
10 files changed, 196 insertions(+), 13 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 70e472eb..fe89b300 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -55,9 +55,12 @@
source label for a live contract server, and the endpoint editor is fully
read-only for a linked mock — every native control disabled via
`
`, the Monaco response-body editor read-only, plus an
- explanatory banner — so a live contract mock can't be hand-edited. Long
- mock-server names now truncate in the sidebar so the per-server actions menu
- stays reachable.
+ explanatory banner — so a live contract mock can't be hand-edited. A live
+ contract mock can be **converted to an editable mock** in place (the Mocks
+ kebab, the read-only banner, or the panel callout) — this flips it to a
+ materialized copy while keeping the spec link, so "Re-import from spec" still
+ works. Long mock-server names now truncate in the sidebar so the per-server
+ actions menu stays reachable.
(`@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 b45ed21c..95ac49f6 100644
--- a/docs/mock-server.md
+++ b/docs/mock-server.md
@@ -127,7 +127,10 @@ come in two modes, each with its own entry point in the Mocks header:
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."
+ callout. Best for "just run my contract." A live contract mock can be
+ **converted to an editable mock** in place (`convertMockToEditable`) — this
+ flips the source to `materialized`, unlocking the endpoints while keeping the
+ spec link so "Re-import from spec" still works.
- **`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.
diff --git a/packages/ui-components/src/panels/help/helpContent.ts b/packages/ui-components/src/panels/help/helpContent.ts
index 1ac89ff7..2b9335fd 100644
--- a/packages/ui-components/src/panels/help/helpContent.ts
+++ b/packages/ui-components/src/panels/help/helpContent.ts
@@ -968,7 +968,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** — 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.
+- **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. Changed your mind after creating it? **Convert to editable mock** (the ⋯ menu, the read-only banner, or the panel callout) flips a live contract into an editable copy in place — the spec link stays, so you can still re-import.
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/MockEndpointEditor.tsx b/packages/ui-components/src/panels/mocks/MockEndpointEditor.tsx
index e6b501c6..bbb55a97 100644
--- a/packages/ui-components/src/panels/mocks/MockEndpointEditor.tsx
+++ b/packages/ui-components/src/panels/mocks/MockEndpointEditor.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
-import { Radio } from 'lucide-react';
+import { Radio, Unlock } from 'lucide-react';
import type { HttpMethod, MockEndpoint, MockServer } from '@apicircle/shared';
import { isLinkedMockSource, validateMockPath } from '@apicircle/shared';
import { useWorkspaceStore } from '../../store/workspaceStore';
@@ -49,6 +49,7 @@ export function MockEndpointEditor({
endpoint: MockEndpoint;
}) {
const updateMockEndpoint = useWorkspaceStore((s) => s.updateMockEndpoint);
+ const convertMockToEditable = useWorkspaceStore((s) => s.convertMockToEditable);
const [selection, setSelection] = useState({ kind: 'endpoint' });
// Reset selection whenever the active endpoint changes — the user
@@ -97,11 +98,18 @@ export function MockEndpointEditor({
{readOnly && (
)}
diff --git a/packages/ui-components/src/panels/mocks/MocksSidebar.test.tsx b/packages/ui-components/src/panels/mocks/MocksSidebar.test.tsx
index 669bdac4..8cf18d56 100644
--- a/packages/ui-components/src/panels/mocks/MocksSidebar.test.tsx
+++ b/packages/ui-components/src/panels/mocks/MocksSidebar.test.tsx
@@ -33,6 +33,25 @@ describe('MocksSidebar — asset-backed mocks', () => {
expect(screen.queryByText('Add endpoint')).not.toBeInTheDocument();
});
+ it('offers "Convert to editable mock" on a linked mock and flips it to materialized', async () => {
+ await renderWithStore();
+ await seedAssetMock('Live', 'linked');
+
+ await userEvent.click(await screen.findByRole('button', { name: /Live actions/i }));
+ await userEvent.click(screen.getByText('Convert to editable mock'));
+
+ await waitFor(() => {
+ const m = Object.values(useWorkspaceStore.getState().synced!.mockServers).find(
+ (s) => s.name === 'Live',
+ );
+ expect(m?.source).toMatchObject({ kind: 'openapi-asset', mode: 'materialized' });
+ });
+ // Now editable: Add endpoint returns, Convert is gone, read-only indicator lifts.
+ await userEvent.click(screen.getByRole('button', { name: /Live actions/i }));
+ expect(screen.getByText('Add endpoint')).toBeInTheDocument();
+ expect(screen.queryByText('Convert to editable mock')).not.toBeInTheDocument();
+ });
+
it('offers "Re-import from spec" alongside Add endpoint on a materialized mock', async () => {
await renderWithStore();
await seedAssetMock('Editable', 'materialized');
diff --git a/packages/ui-components/src/panels/mocks/MocksSidebar.tsx b/packages/ui-components/src/panels/mocks/MocksSidebar.tsx
index 83982fa0..a21dcf68 100644
--- a/packages/ui-components/src/panels/mocks/MocksSidebar.tsx
+++ b/packages/ui-components/src/panels/mocks/MocksSidebar.tsx
@@ -11,6 +11,7 @@ import {
Search,
Server,
Trash2,
+ Unlock,
} from 'lucide-react';
import { isLinkedMockSource } from '@apicircle/shared';
import { useWorkspaceStore } from '../../store/workspaceStore';
@@ -38,6 +39,7 @@ export function MocksSidebar() {
const duplicateMockServer = useWorkspaceStore((s) => s.duplicateMockServer);
const duplicateMockEndpoint = useWorkspaceStore((s) => s.duplicateMockEndpoint);
const refreshMockServer = useWorkspaceStore((s) => s.refreshMockServer);
+ const convertMockToEditable = useWorkspaceStore((s) => s.convertMockToEditable);
const promoteMockEndpointToRequest = useWorkspaceStore((s) => s.promoteMockEndpointToRequest);
const pushToast = useWorkspaceStore((s) => s.pushToast);
@@ -158,6 +160,16 @@ export function MocksSidebar() {
},
]
: []),
+ ...(isLinked
+ ? [
+ {
+ id: 'convert-editable',
+ label: 'Convert to editable mock',
+ icon: ,
+ onSelect: () => convertMockToEditable(server.id),
+ },
+ ]
+ : []),
{
id: 'duplicate',
label: 'Duplicate',
diff --git a/packages/ui-components/src/store/mockTwoModes.test.ts b/packages/ui-components/src/store/mockTwoModes.test.ts
index 3ed1f00d..30693d47 100644
--- a/packages/ui-components/src/store/mockTwoModes.test.ts
+++ b/packages/ui-components/src/store/mockTwoModes.test.ts
@@ -110,4 +110,54 @@ describe('mock two modes off a spec asset', () => {
const res = await store().refreshMockServer('no-such');
expect(res).toEqual({ warnings: [] });
});
+
+ it('converts a linked mock to editable (materialized), preserving endpoints + spec link', async () => {
+ const assetId = await uploadSpec(['/pets', '/pets/{id}']);
+ const mockId = await createAssetMock(assetId, 'linked');
+ expect(store().synced?.mockServers[mockId]?.endpoints.length).toBe(2);
+
+ act(() => {
+ store().convertMockToEditable(mockId);
+ });
+
+ const mock = store().synced?.mockServers[mockId];
+ // Same spec link, mode flipped to materialized, endpoints preserved.
+ expect(mock?.source).toMatchObject({ kind: 'openapi-asset', assetId, mode: 'materialized' });
+ expect(mock?.endpoints.length).toBe(2);
+ // Now editable — endpoint mutations take effect (were no-ops while linked).
+ act(() => {
+ store().addMockEndpoint(mockId);
+ });
+ expect(store().synced?.mockServers[mockId]?.endpoints.length).toBe(3);
+ });
+
+ it('convertMockToEditable is a no-op for unknown ids, manual, and already-materialized mocks', async () => {
+ // Unknown id — no throw, nothing created.
+ act(() => {
+ store().convertMockToEditable('no-such');
+ });
+
+ let manualId = '';
+ await act(async () => {
+ const r = await store().createMockServer({
+ name: 'manual',
+ source: { kind: 'manual', endpoints: [] },
+ });
+ manualId = r.id;
+ });
+ act(() => {
+ store().convertMockToEditable(manualId);
+ });
+ expect(store().synced?.mockServers[manualId]?.source.kind).toBe('manual');
+
+ const assetId = await uploadSpec(['/pets']);
+ const matId = await createAssetMock(assetId, 'materialized');
+ act(() => {
+ store().convertMockToEditable(matId);
+ });
+ expect(store().synced?.mockServers[matId]?.source).toMatchObject({
+ kind: 'openapi-asset',
+ mode: 'materialized',
+ });
+ });
});
diff --git a/packages/ui-components/src/store/workspaceStore.ts b/packages/ui-components/src/store/workspaceStore.ts
index f74eff72..ee7c20a6 100644
--- a/packages/ui-components/src/store/workspaceStore.ts
+++ b/packages/ui-components/src/store/workspaceStore.ts
@@ -1033,6 +1033,14 @@ type WorkspaceStore = {
* No-op for `manual` sources. Returns any parser warnings.
*/
refreshMockServer: (id: string) => Promise<{ warnings: string[] }>;
+ /**
+ * Convert a linked ("run live") contract mock into an editable one by flipping
+ * its `openapi-asset` source from `linked` to `materialized`. Endpoints are
+ * preserved (they already live on the server) and the spec link is kept, so
+ * "Re-import from spec" and asset-usage tracking keep working. No-op for any
+ * non-linked mock.
+ */
+ convertMockToEditable: (serverId: string) => void;
/**
* Clone a mock server with all of its endpoints + nested rules. Every
* cloned entity gets a fresh id; the legacy `overrides` map is reset
@@ -2967,6 +2975,36 @@ export const useWorkspaceStore = create((set, get) => ({
return { warnings };
},
+ convertMockToEditable: (serverId) => {
+ const synced = get().synced;
+ if (!synced) return;
+ const existing = synced.mockServers[serverId];
+ if (!existing) return;
+ // Only a linked ("run live") contract mock needs converting; every other
+ // source is already editable. The endpoints already live on the server, so
+ // flipping the mode simply unlocks them — nothing is re-parsed.
+ if (existing.source.kind !== 'openapi-asset' || existing.source.mode !== 'linked') return;
+ const now = new Date().toISOString();
+ const nextServer = {
+ ...existing,
+ source: { ...existing.source, mode: 'materialized' as const },
+ updatedAt: now,
+ };
+ const nextSynced: WorkspaceSynced = {
+ ...synced,
+ mockServers: { ...synced.mockServers, [serverId]: nextServer },
+ meta: { ...synced.meta, updatedAt: now },
+ };
+ set({ synced: nextSynced });
+ queueSaveSynced(nextSynced);
+ get().pushToast({
+ tone: 'success',
+ title: `"${existing.name}" is now editable`,
+ detail:
+ 'Endpoints no longer auto-sync with the spec — use "Re-import from spec" to pull updates.',
+ });
+ },
+
duplicateMockServer: (id) => {
const synced = get().synced;
if (!synced) return null;
From 39c8d77aa169faa843d88076041e4dd31e840bdb Mon Sep 17 00:00:00 2001
From: apicircle-dev
Date: Sat, 18 Jul 2026 14:08:59 +0530
Subject: [PATCH 09/16] feat(mocks): update a live contract mock by
re-uploading the revised spec
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When a mock is served directly from an OpenAPI/Swagger contract, the user can
now re-upload the modified spec from the mock itself and the endpoints update
live — no hunting through Assets -> Files.
- New reuploadMockSpec(serverId, file) store action: validates the file IS a
spec first (a non-spec upload is rejected with an error toast, leaving the
mock untouched), then replaces the backing asset's bytes via
fillGlobalFileAssetBytes (re-parses the summary + auto-refreshes every linked
mock reading it), and toasts the new endpoint count. No-op for non-openapi
-asset mocks.
- "Update spec..." file-picker surfaced in the panel's "Served directly from
contract" callout and the sidebar kebab (linked mocks only).
- Export the existing bytesFromFile helper (with its jsdom FileReader fallback)
so validation reads bytes the same robust way as the rest of the app.
Tests: replace+refresh, non-spec rejection, no-op manual, full sidebar upload
flow, callout button presence. Docs: CHANGELOG, mock-server.md, Help. Additive.
---
CHANGELOG.md | 7 ++-
docs/mock-server.md | 4 +-
.../src/panels/help/helpContent.ts | 2 +-
.../panels/mocks/MockServersPanel.test.tsx | 3 ++
.../src/panels/mocks/MockServersPanel.tsx | 43 +++++++++++++----
.../src/panels/mocks/MocksSidebar.test.tsx | 35 +++++++++++++-
.../src/panels/mocks/MocksSidebar.tsx | 31 +++++++++++-
.../src/persistence/attachments.ts | 2 +-
.../src/store/mockTwoModes.test.ts | 48 +++++++++++++++++++
.../ui-components/src/store/workspaceStore.ts | 41 ++++++++++++++++
10 files changed, 200 insertions(+), 16 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fe89b300..55a67e9b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -59,8 +59,11 @@
contract mock can be **converted to an editable mock** in place (the Mocks
kebab, the read-only banner, or the panel callout) — this flips it to a
materialized copy while keeping the spec link, so "Re-import from spec" still
- works. Long mock-server names now truncate in the sidebar so the per-server
- actions menu stays reachable.
+ works. When the contract itself changes, **Update spec…** (the Mocks kebab or
+ the panel callout) re-uploads the revised OpenAPI/Swagger file — replacing the
+ shared asset's bytes and live-refreshing the linked mock's endpoints. Long
+ mock-server names now truncate in the sidebar so the per-server actions menu
+ stays reachable.
(`@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 95ac49f6..fca0fd66 100644
--- a/docs/mock-server.md
+++ b/docs/mock-server.md
@@ -130,7 +130,9 @@ come in two modes, each with its own entry point in the Mocks header:
callout. Best for "just run my contract." A live contract mock can be
**converted to an editable mock** in place (`convertMockToEditable`) — this
flips the source to `materialized`, unlocking the endpoints while keeping the
- spec link so "Re-import from spec" still works.
+ spec link so "Re-import from spec" still works. When the contract itself
+ changes, **Update spec…** (`reuploadMockSpec`) re-uploads the revised file,
+ replacing the backing asset's bytes and live-refreshing the linked endpoints.
- **`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.
diff --git a/packages/ui-components/src/panels/help/helpContent.ts b/packages/ui-components/src/panels/help/helpContent.ts
index 2b9335fd..8e39f9f8 100644
--- a/packages/ui-components/src/panels/help/helpContent.ts
+++ b/packages/ui-components/src/panels/help/helpContent.ts
@@ -968,7 +968,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** — 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. Changed your mind after creating it? **Convert to editable mock** (the ⋯ menu, the read-only banner, or the panel callout) flips a live contract into an editable copy in place — the spec link stays, so you can still re-import.
+- **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. Changed your mind after creating it? **Convert to editable mock** (the ⋯ menu, the read-only banner, or the panel callout) flips a live contract into an editable copy in place — the spec link stays, so you can still re-import. And when the contract itself changes, **Update spec…** (the ⋯ menu or the panel callout) re-uploads the revised file — replacing the shared asset and live-refreshing the mock's 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/MockServersPanel.test.tsx b/packages/ui-components/src/panels/mocks/MockServersPanel.test.tsx
index 1c9a2ef0..13375d1d 100644
--- a/packages/ui-components/src/panels/mocks/MockServersPanel.test.tsx
+++ b/packages/ui-components/src/panels/mocks/MockServersPanel.test.tsx
@@ -125,6 +125,9 @@ describe('MockServersPanel (post-rich-editor redesign)', () => {
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')).toBeInTheDocument();
+ // The callout exposes both spec actions.
+ expect(screen.getByRole('button', { name: /Update spec/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Convert to editable/i })).toBeInTheDocument();
});
it('renders the endpoint editor READ-ONLY for a linked contract mock', async () => {
diff --git a/packages/ui-components/src/panels/mocks/MockServersPanel.tsx b/packages/ui-components/src/panels/mocks/MockServersPanel.tsx
index 6eed5b9a..94a57015 100644
--- a/packages/ui-components/src/panels/mocks/MockServersPanel.tsx
+++ b/packages/ui-components/src/panels/mocks/MockServersPanel.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import {
AlertTriangle,
Loader2,
@@ -9,6 +9,7 @@ import {
Square,
Trash2,
Unlock,
+ Upload,
} from 'lucide-react';
import type { MockRuntimeEntry, MockServer } from '@apicircle/shared';
import { useWorkspaceStore } from '../../store/workspaceStore';
@@ -302,6 +303,8 @@ function ServerSummary({
}) {
const setMockServerName = useWorkspaceStore((s) => s.setMockServerName);
const convertMockToEditable = useWorkspaceStore((s) => s.convertMockToEditable);
+ const reuploadMockSpec = useWorkspaceStore((s) => s.reuploadMockSpec);
+ const specInput = useRef(null);
const files = useWorkspaceStore((s) => s.synced?.globalAssets.files);
// A "run live" (linked) contract mock gets a clear provenance callout below.
const src = server.source;
@@ -338,14 +341,36 @@ function ServerSummary({
{linkedAsset?.spec?.dialect === 'swagger-2' ? 'Swagger 2.0' : 'OpenAPI 3.x'} ·{' '}
{linkedAsset?.spec?.operationCount ?? server.endpoints.length} ops
-
+
+
+
+
+ {
+ const f = e.target.files?.[0];
+ e.target.value = '';
+ if (f) void reuploadMockSpec(server.id, f);
+ }}
+ />
)}
diff --git a/packages/ui-components/src/panels/mocks/MocksSidebar.test.tsx b/packages/ui-components/src/panels/mocks/MocksSidebar.test.tsx
index 8cf18d56..8759203c 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, waitFor } from '@testing-library/react';
+import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { MocksSidebar, MocksSidebarActions } from './MocksSidebar';
@@ -52,6 +52,39 @@ describe('MocksSidebar — asset-backed mocks', () => {
expect(screen.queryByText('Convert to editable mock')).not.toBeInTheDocument();
});
+ it('offers "Update spec…" on a linked mock and re-derives endpoints from the new file', async () => {
+ await renderWithStore();
+ await seedAssetMock('Live', 'linked');
+ const mockId = Object.values(useWorkspaceStore.getState().synced!.mockServers)[0].id;
+ expect(useWorkspaceStore.getState().synced!.mockServers[mockId].endpoints.length).toBe(1);
+
+ // Click "Update spec…" (stashes the target server), then supply the new file.
+ await userEvent.click(await screen.findByRole('button', { name: /Live actions/i }));
+ await userEvent.click(screen.getByText('Update spec…'));
+
+ const revised = new File(
+ [
+ JSON.stringify({
+ openapi: '3.0.0',
+ info: { title: 'P', version: '1' },
+ paths: {
+ '/a': { get: { responses: { '200': { description: 'ok' } } } },
+ '/b': { get: { responses: { '200': { description: 'ok' } } } },
+ },
+ }),
+ ],
+ 'revised.json',
+ { type: 'application/json' },
+ );
+ fireEvent.change(screen.getByLabelText('Update OpenAPI/Swagger spec file'), {
+ target: { files: [revised] },
+ });
+
+ await waitFor(() => {
+ expect(useWorkspaceStore.getState().synced!.mockServers[mockId].endpoints.length).toBe(2);
+ });
+ });
+
it('offers "Re-import from spec" alongside Add endpoint on a materialized mock', async () => {
await renderWithStore();
await seedAssetMock('Editable', 'materialized');
diff --git a/packages/ui-components/src/panels/mocks/MocksSidebar.tsx b/packages/ui-components/src/panels/mocks/MocksSidebar.tsx
index a21dcf68..ae80f776 100644
--- a/packages/ui-components/src/panels/mocks/MocksSidebar.tsx
+++ b/packages/ui-components/src/panels/mocks/MocksSidebar.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from 'react';
+import { useMemo, useRef, useState } from 'react';
import {
ChevronDown,
ChevronRight,
@@ -12,6 +12,7 @@ import {
Server,
Trash2,
Unlock,
+ Upload,
} from 'lucide-react';
import { isLinkedMockSource } from '@apicircle/shared';
import { useWorkspaceStore } from '../../store/workspaceStore';
@@ -40,8 +41,13 @@ export function MocksSidebar() {
const duplicateMockEndpoint = useWorkspaceStore((s) => s.duplicateMockEndpoint);
const refreshMockServer = useWorkspaceStore((s) => s.refreshMockServer);
const convertMockToEditable = useWorkspaceStore((s) => s.convertMockToEditable);
+ const reuploadMockSpec = useWorkspaceStore((s) => s.reuploadMockSpec);
const promoteMockEndpointToRequest = useWorkspaceStore((s) => s.promoteMockEndpointToRequest);
const pushToast = useWorkspaceStore((s) => s.pushToast);
+ // "Update spec…" (linked mocks) triggers this one hidden file input; the
+ // target server id is stashed in a ref so a single input serves the list.
+ const specUploadInput = useRef(null);
+ const specUploadServerId = useRef(null);
const allServers = Object.values(mockServers);
const [expanded, setExpanded] = useState>({});
@@ -162,6 +168,15 @@ export function MocksSidebar() {
: []),
...(isLinked
? [
+ {
+ id: 'update-spec',
+ label: 'Update spec…',
+ icon: ,
+ onSelect: () => {
+ specUploadServerId.current = server.id;
+ specUploadInput.current?.click();
+ },
+ },
{
id: 'convert-editable',
label: 'Convert to editable mock',
@@ -370,6 +385,20 @@ export function MocksSidebar() {
setPendingEndpointDelete(null);
}}
/>
+
+ {
+ const f = e.target.files?.[0];
+ e.target.value = '';
+ const serverId = specUploadServerId.current;
+ if (f && serverId) void reuploadMockSpec(serverId, f);
+ }}
+ />
);
}
diff --git a/packages/ui-components/src/persistence/attachments.ts b/packages/ui-components/src/persistence/attachments.ts
index 6ade8c0b..83dc3ba4 100644
--- a/packages/ui-components/src/persistence/attachments.ts
+++ b/packages/ui-components/src/persistence/attachments.ts
@@ -91,7 +91,7 @@ export async function deleteManyAttachments(slotIds: string[]): Promise {
* back to FileReader in environments (notably jsdom) where the method is
* absent on the File constructor.
*/
-async function bytesFromFile(file: File): Promise {
+export async function bytesFromFile(file: File): Promise {
if (typeof file.arrayBuffer === 'function') {
return new Uint8Array(await file.arrayBuffer());
}
diff --git a/packages/ui-components/src/store/mockTwoModes.test.ts b/packages/ui-components/src/store/mockTwoModes.test.ts
index 30693d47..0bfd007e 100644
--- a/packages/ui-components/src/store/mockTwoModes.test.ts
+++ b/packages/ui-components/src/store/mockTwoModes.test.ts
@@ -160,4 +160,52 @@ describe('mock two modes off a spec asset', () => {
mode: 'materialized',
});
});
+
+ it('reuploadMockSpec replaces the spec bytes and re-derives a linked mock', async () => {
+ const assetId = await uploadSpec(['/pets']);
+ const mockId = await createAssetMock(assetId, 'linked');
+ expect(store().synced?.mockServers[mockId]?.endpoints.length).toBe(1);
+
+ await act(async () => {
+ await store().reuploadMockSpec(mockId, specFile(['/pets', '/pets/{id}', '/owners']));
+ });
+
+ // Endpoints re-derived from the revised spec + the asset summary re-parsed.
+ expect(store().synced?.mockServers[mockId]?.endpoints.length).toBe(3);
+ expect(store().synced?.globalAssets.files?.[assetId]?.spec?.operationCount).toBe(3);
+ });
+
+ it('reuploadMockSpec is a no-op for a non-spec-asset (manual) mock', async () => {
+ let manualId = '';
+ await act(async () => {
+ const r = await store().createMockServer({
+ name: 'manual',
+ source: { kind: 'manual', endpoints: [] },
+ });
+ manualId = r.id;
+ });
+ await act(async () => {
+ await store().reuploadMockSpec(manualId, specFile(['/pets']));
+ });
+ expect(store().synced?.mockServers[manualId]?.source.kind).toBe('manual');
+ expect(store().synced?.mockServers[manualId]?.endpoints.length).toBe(0);
+ });
+
+ it('reuploadMockSpec rejects a non-spec file and leaves the mock + asset unchanged', async () => {
+ const assetId = await uploadSpec(['/pets']);
+ const mockId = await createAssetMock(assetId, 'linked');
+ expect(store().synced?.mockServers[mockId]?.endpoints.length).toBe(1);
+
+ // A JSON file with no openapi/swagger key is not a spec.
+ const notASpec = new File([JSON.stringify({ hello: 'world' })], 'notes.json', {
+ type: 'application/json',
+ });
+ await act(async () => {
+ await store().reuploadMockSpec(mockId, notASpec);
+ });
+
+ // Untouched: endpoints preserved, the asset keeps its original spec summary.
+ expect(store().synced?.mockServers[mockId]?.endpoints.length).toBe(1);
+ expect(store().synced?.globalAssets.files?.[assetId]?.spec?.operationCount).toBe(1);
+ });
});
diff --git a/packages/ui-components/src/store/workspaceStore.ts b/packages/ui-components/src/store/workspaceStore.ts
index ee7c20a6..3ed93d36 100644
--- a/packages/ui-components/src/store/workspaceStore.ts
+++ b/packages/ui-components/src/store/workspaceStore.ts
@@ -116,6 +116,7 @@ import {
} from '@apicircle/core';
import { create } from 'zustand';
import {
+ bytesFromFile,
createAttachmentFromFile,
deleteAttachment,
deleteManyAttachments,
@@ -1041,6 +1042,14 @@ type WorkspaceStore = {
* non-linked mock.
*/
convertMockToEditable: (serverId: string) => void;
+ /**
+ * Re-upload the OpenAPI/Swagger file backing a spec-asset mock: replaces the
+ * asset's bytes (so every consumer sees the revised spec) which re-parses the
+ * spec summary and auto-refreshes every linked ("run live") mock's endpoints.
+ * Surfaces the new endpoint count as a toast. No-op for non-`openapi-asset`
+ * mocks. The spec asset is shared, so this reflects everywhere it's linked.
+ */
+ reuploadMockSpec: (serverId: string, file: File) => Promise;
/**
* Clone a mock server with all of its endpoints + nested rules. Every
* cloned entity gets a fresh id; the legacy `overrides` map is reset
@@ -3005,6 +3014,38 @@ export const useWorkspaceStore = create((set, get) => ({
});
},
+ reuploadMockSpec: async (serverId, file) => {
+ const server = get().synced?.mockServers[serverId];
+ if (!server || server.source.kind !== 'openapi-asset') return;
+ // Validate the new file is an OpenAPI/Swagger doc BEFORE replacing anything —
+ // a non-spec upload would otherwise silently clear the linked endpoints and
+ // de-type the asset. `summarizeUploadedSpec` returns null for non-specs.
+ const bytes = await bytesFromFile(file);
+ const summary = await summarizeUploadedSpec(
+ bytes,
+ file.name,
+ file.type,
+ new Date().toISOString(),
+ );
+ if (!summary) {
+ get().pushToast({
+ tone: 'error',
+ title: "That file isn't a valid OpenAPI/Swagger spec",
+ detail: 'The mock was left unchanged — upload the revised contract as JSON or YAML.',
+ });
+ return;
+ }
+ // Replacing the backing asset's bytes re-parses the spec summary and
+ // auto-refreshes every linked mock reading it (see fillGlobalFileAssetBytes).
+ await get().fillGlobalFileAssetBytes(server.source.assetId, file);
+ const count = get().synced?.mockServers[serverId]?.endpoints.length ?? 0;
+ get().pushToast({
+ tone: 'success',
+ title: `Updated "${server.name}" from the revised spec`,
+ detail: `${count} endpoint${count === 1 ? '' : 's'} now served.`,
+ });
+ },
+
duplicateMockServer: (id) => {
const synced = get().synced;
if (!synced) return null;
From a0c049c65d2e0c4b05e54af32eff3941cbbe962e Mon Sep 17 00:00:00 2001
From: apicircle-dev
Date: Sat, 18 Jul 2026 14:40:10 +0530
Subject: [PATCH 10/16] fix(mocks): hide (not just disable) mutation CTAs in
the read-only editor
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A live contract mock's editor was read-only via
, but the
Add/Import/Remove CTAs were still visible (disabled, non-working) — confusing.
Hide them in read-only mode and drop the empty-state "click Add ..." hints:
- Rule overviews: "Add rule" / "Import rule" hidden; "No validation/response
rules." without the click-to-add hint.
- Request schema: "Derive from path" / "Add param" / row "Remove" hidden;
"No ." without "Add one below."
- Default response: "+ Header" / header "Remove" / "Add multiplier" hidden; the
multipliers empty hint drops "Add one to repeat ...".
All driven off the existing MockReadOnlyContext; inputs stay disabled+visible so
the contract remains fully inspectable. (Rule-editor / form-data / attachment
CTAs are unreachable for a spec-locked linked mock, so untouched.)
Tests: read-only hides CTAs across the validation + default-response nodes and
drops the hints; an editable mock keeps them. Additive.
---
CHANGELOG.md | 9 +-
.../src/panels/mocks/MockNodeEditor.tsx | 101 +++++++++++-------
.../panels/mocks/MockRequestSchemaEditor.tsx | 44 ++++----
.../src/panels/mocks/MockResponseEditor.tsx | 78 ++++++++------
.../panels/mocks/MockServersPanel.test.tsx | 36 +++++++
5 files changed, 171 insertions(+), 97 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 55a67e9b..d445dcdc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -53,9 +53,12 @@
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, and the endpoint editor is fully
- read-only for a linked mock — every native control disabled via
- `
`, the Monaco response-body editor read-only, plus an
- explanatory banner — so a live contract mock can't be hand-edited. A live
+ read-only for a linked mock — mutation CTAs (Add rule, Import rule, Add param,
+ Add header, Add multiplier, …) are hidden (not just disabled) and their
+ empty-state copy drops the "click Add …" hints, every remaining native control
+ is disabled via `
`, the Monaco response-body editor is
+ read-only, and an explanatory banner says why — so a live contract mock can't
+ be hand-edited. A live
contract mock can be **converted to an editable mock** in place (the Mocks
kebab, the read-only banner, or the panel callout) — this flips it to a
materialized copy while keeping the spec link, so "Re-import from spec" still
diff --git a/packages/ui-components/src/panels/mocks/MockNodeEditor.tsx b/packages/ui-components/src/panels/mocks/MockNodeEditor.tsx
index d5694319..a642ea2b 100644
--- a/packages/ui-components/src/panels/mocks/MockNodeEditor.tsx
+++ b/packages/ui-components/src/panels/mocks/MockNodeEditor.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useContext, useState } from 'react';
import {
ArrowDown,
ArrowLeft,
@@ -29,6 +29,7 @@ import { MockResponseEditor } from './MockResponseEditor';
import { MockRequestSchemaEditor } from './MockRequestSchemaEditor';
import { MockRulePicker } from './MockRulePicker';
import { Select } from '../../primitives/Select';
+import { MockReadOnlyContext } from './mockReadOnly';
// Node-editor surface for whichever flow node the user has selected.
// Imports each inner editor below so the selection switch is a single
@@ -229,6 +230,7 @@ function ValidationOverview({
onSelect: (next: MockNodeSelection) => void;
}) {
const [importerOpen, setImporterOpen] = useState(false);
+ const readOnly = useContext(MockReadOnlyContext);
const move = (idx: number, dir: -1 | 1) => {
const next = [...endpoint.requestValidation];
const target = idx + dir;
@@ -281,7 +283,13 @@ function ValidationOverview({
{endpoint.requestValidation.length === 0 ? (
- No validation rules. Click Add rule below to create one.
+ No validation rules.
+ {!readOnly && (
+ <>
+ {' '}
+ Click Add rule below to create one.
+ >
+ )}
) : (
@@ -352,25 +360,27 @@ function ValidationOverview({
))}