zudoku 0.71.10 → 0.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/cli.js CHANGED
@@ -3810,7 +3810,7 @@ import {
3810
3810
  // package.json
3811
3811
  var package_default = {
3812
3812
  name: "zudoku",
3813
- version: "0.71.9",
3813
+ version: "0.72.0",
3814
3814
  type: "module",
3815
3815
  sideEffects: [
3816
3816
  "**/*.css",
@@ -3871,7 +3871,6 @@ var package_default = {
3871
3871
  "./vite": "./src/vite/index.ts",
3872
3872
  "./app/*": "./src/app/*",
3873
3873
  "./hooks": "./src/lib/hooks/index.ts",
3874
- "./main.css": "./src/app/main.css",
3875
3874
  "./processors/*": "./src/lib/plugins/openapi/processors/*.ts",
3876
3875
  "./with-zuplo": "./src/zuplo/with-zuplo.ts",
3877
3876
  "./testing": "./src/lib/testing/index.tsx"
@@ -3882,7 +3881,7 @@ var package_default = {
3882
3881
  typecheck: "tsc --project tsconfig.app.json --noEmit",
3883
3882
  "generate:types": "tsx scripts/generate-types.js && tsx scripts/generate-flat-config.js",
3884
3883
  "build:standalone": "vite build --mode standalone --config vite.standalone.config.ts --log-level=error",
3885
- prepublishOnly: "tsx scripts/generate-publish-exports.ts",
3884
+ prepublishOnly: "tsx scripts/generate-publish-exports.ts && publint .",
3886
3885
  postpublish: "git checkout -- package.json",
3887
3886
  clean: "rm -rf dist",
3888
3887
  codegen: "graphql-codegen --config ./src/codegen.ts"
@@ -4002,10 +4001,10 @@ var package_default = {
4002
4001
  vaul: "1.1.2",
4003
4002
  vfile: "6.0.3",
4004
4003
  vite: "7.3.1",
4005
- yaml: "2.8.2",
4004
+ yaml: "2.8.3",
4006
4005
  yargs: "18.0.0",
4007
4006
  zod: "4.3.6",
4008
- zustand: "5.0.11"
4007
+ zustand: "5.0.12"
4009
4008
  },
4010
4009
  devDependencies: {
4011
4010
  "@clerk/clerk-js": "^5.125.3",
@@ -6606,6 +6605,10 @@ ${markdownContent}`;
6606
6605
  description: frontmatter.description
6607
6606
  };
6608
6607
  };
6608
+ var getMarkdownOutputPath = (distDir, routePath) => {
6609
+ const segments = routePath === "/" ? ["index"] : routePath.split("/").filter(Boolean);
6610
+ return `${path14.join(distDir, ...segments)}.md`;
6611
+ };
6609
6612
  var viteMarkdownExportPlugin = () => {
6610
6613
  let markdownFiles = {};
6611
6614
  let markdownFileInfos = [];
@@ -6699,8 +6702,7 @@ var viteMarkdownExportPlugin = () => {
6699
6702
  description,
6700
6703
  content: finalMarkdown
6701
6704
  });
6702
- const segments = routePath === "/" ? ["index"] : routePath.split("/").filter(Boolean);
6703
- const outputPath = `${path14.join(distDir, ...segments)}.md`;
6705
+ const outputPath = getMarkdownOutputPath(distDir, routePath);
6704
6706
  await mkdir2(path14.dirname(outputPath), { recursive: true });
6705
6707
  await writeFile2(outputPath, finalMarkdown, "utf-8");
6706
6708
  } catch (error) {
@@ -7896,8 +7898,14 @@ var prerender = async ({
7896
7898
  }
7897
7899
  if (!docsConfig.publishMarkdown) {
7898
7900
  await Promise.all(
7899
- markdownFileInfos.map((info) => rm(info.filePath).catch(() => {
7900
- }))
7901
+ markdownFileInfos.map((info) => {
7902
+ const outputPath = getMarkdownOutputPath(distDir, info.routePath);
7903
+ if (!path21.resolve(outputPath).startsWith(path21.resolve(distDir))) {
7904
+ return;
7905
+ }
7906
+ return rm(outputPath).catch(() => {
7907
+ });
7908
+ })
7901
7909
  );
7902
7910
  }
7903
7911
  }
@@ -1,5 +1,6 @@
1
- export declare const MCPEndpoint: ({ serverUrl, summary, data, }: {
1
+ export declare const MCPEndpoint: ({ serverUrl, operationPath, summary, data, }: {
2
2
  serverUrl?: string;
3
+ operationPath?: string;
3
4
  data?: boolean | Record<string, unknown>;
4
5
  summary?: string;
5
6
  }) => import("react/jsx-runtime").JSX.Element;
@@ -11,7 +11,8 @@ import { Book, Code, FileText } from "zudoku/icons";
11
11
 
12
12
  Zudoku uses a single `navigation` array to control both the top navigation tabs and the sidebar.
13
13
  Items at the root of this array appear as tabs, and nested items build the sidebar tree. Navigation
14
- entries can be links, document references, categories or custom pages.
14
+ entries can be links, document references, categories, custom pages, separators, sections, or
15
+ filters.
15
16
 
16
17
  ## Basic configuration
17
18
 
@@ -45,7 +46,6 @@ one of several types. At the simplest level you may only have links and categori
45
46
  "to": "/api",
46
47
  "label": "API Reference",
47
48
  "icon": "code",
48
- "description": "Complete API documentation",
49
49
  "badge": {
50
50
  "label": "v2.0",
51
51
  "color": "blue"
@@ -58,13 +58,18 @@ one of several types. At the simplest level you may only have links and categori
58
58
 
59
59
  ## Navigation Items
60
60
 
61
- Navigation items can be of these types: `category`, `doc`, `link`, or `custom-page`.
61
+ Navigation items can be of these types: `category`, `doc`, `link`, `custom-page`, `separator`,
62
+ `section`, or `filter`.
62
63
 
63
64
  - `link`: A direct link to a page or external URL.
64
65
  - `category`: A group of links that can be expanded or collapsed.
65
- - `doc`: A reference to a document by it's file path: `file`.
66
- - `custom-pages`: A custom page that is made of a React component, see
66
+ - `doc`: A reference to a document by its file path: `file`.
67
+ - `custom-page`: A custom page that is made of a React component, see
67
68
  [Custom Pages](../guides/custom-pages.md)
69
+ - `separator`: A horizontal line to visually divide sidebar items.
70
+ - `section`: A non-interactive heading label to group sidebar items.
71
+ - `filter`: An inline search input that filters navigation items. Multiple filter inputs share the
72
+ same search query.
68
73
 
69
74
  ### `type: link`
70
75
 
@@ -87,10 +92,10 @@ type NavigationLink = {
87
92
  to: string;
88
93
  label: string;
89
94
  icon?: string; // Lucide icon name
90
- description?: string;
91
95
  badge?: {
92
96
  label: string;
93
97
  color: "green" | "blue" | "yellow" | "red" | "purple" | "indigo" | "gray" | "outline";
98
+ invert?: boolean;
94
99
  };
95
100
  display?:
96
101
  | "auth"
@@ -106,7 +111,8 @@ type NavigationLink = {
106
111
  ### `type: category`
107
112
 
108
113
  The `category` type groups related items under a collapsible section. The `label` is the displayed
109
- text, and the `items` array contains `id`s of documents, links, or other categories.
114
+ text, and the `items` array can contain any navigation item type (documents, links, categories,
115
+ custom pages, separators, sections, and filters).
110
116
 
111
117
  ```json
112
118
  {
@@ -131,11 +137,11 @@ text, and the `items` array contains `id`s of documents, links, or other categor
131
137
  type NavigationCategory = {
132
138
  type: "category";
133
139
  icon?: string; // Lucide icon name
134
- items: Array<NavigationDoc | NavigationLink | NavigationCategory | NavigationCustomPage>;
140
+ items: Array<NavigationItem>; // any navigation item type, including string shorthands for docs
135
141
  label: string;
136
142
  collapsible?: boolean;
137
143
  collapsed?: boolean;
138
- link?: string | { type: "doc"; file: string; label?: string };
144
+ link?: string | { type: "doc"; file: string; label?: string; path?: string };
139
145
  display?:
140
146
  | "auth"
141
147
  | "anon"
@@ -147,6 +153,50 @@ type NavigationCategory = {
147
153
 
148
154
  </details>
149
155
 
156
+ #### Category links
157
+
158
+ A category can have a `link` property that makes the category label itself clickable, navigating to
159
+ a document. This is useful when you want a category that acts as both a group and a landing page.
160
+
161
+ The `link` can be a simple string pointing to a file path, or an object for more control:
162
+
163
+ ```tsx title="String shorthand"
164
+ {
165
+ type: "category",
166
+ label: "Configuration",
167
+ link: "docs/configuration/overview",
168
+ items: [
169
+ "docs/configuration/navigation",
170
+ "docs/configuration/site",
171
+ ],
172
+ }
173
+ ```
174
+
175
+ ```tsx title="Object form with custom path"
176
+ {
177
+ type: "category",
178
+ label: "Documentation",
179
+ link: {
180
+ type: "doc",
181
+ file: "home.md",
182
+ path: "/",
183
+ },
184
+ items: [
185
+ "guides/getting-started",
186
+ "guides/advanced",
187
+ ],
188
+ }
189
+ ```
190
+
191
+ The object form supports these properties:
192
+
193
+ | Property | Type | Description |
194
+ | -------- | -------- | -------------------------------------------------------- |
195
+ | `type` | `"doc"` | Must be `"doc"` |
196
+ | `file` | `string` | Path to the markdown file |
197
+ | `label` | `string` | Override the label (defaults to the document title) |
198
+ | `path` | `string` | Custom URL path (overrides the default file-based route) |
199
+
150
200
  ### `type: doc`
151
201
 
152
202
  Doc is used to reference markdown files. The `label` is the text that will be displayed, and the
@@ -173,6 +223,7 @@ type NavigationDoc = {
173
223
  badge?: {
174
224
  label: string;
175
225
  color: "green" | "blue" | "yellow" | "red" | "purple" | "indigo" | "gray" | "outline";
226
+ invert?: boolean;
176
227
  };
177
228
  display?:
178
229
  | "auth"
@@ -223,6 +274,37 @@ Learn more in the [Markdown documentation](/docs/markdown/overview)
223
274
  The `path` property allows you to customize the URL path for a document. By default, Zudoku uses the
224
275
  file path to generate the URL, but you can override this behavior by specifying a custom path.
225
276
 
277
+ ```tsx title="Serving a doc at the root URL"
278
+ {
279
+ type: "doc",
280
+ file: "home.md",
281
+ path: "/",
282
+ label: "Home",
283
+ }
284
+ ```
285
+
286
+ ```tsx title="Custom slug"
287
+ {
288
+ type: "doc",
289
+ file: "guides/getting-started.md",
290
+ path: "/start-here",
291
+ label: "Start Here",
292
+ }
293
+ ```
294
+
295
+ When a file has a custom path, it will only be accessible at that custom path, not at its original
296
+ file-based path. See [Documentation - Custom Paths](/docs/configuration/docs#custom-paths) for more
297
+ details.
298
+
299
+ :::note
300
+
301
+ Avoid naming files `index.md` or `index.mdx` and relying on their default path. Some hosting
302
+ providers (e.g. Vercel) automatically strip `/index` from URLs with a redirect, which can cause
303
+ routing issues. Instead, give files descriptive names and use the `path` property to serve them at
304
+ the desired URL.
305
+
306
+ :::
307
+
226
308
  ### `type: custom-page`
227
309
 
228
310
  Custom pages allow you to create standalone pages that are not tied to a Markdown document. This is
@@ -247,6 +329,7 @@ type NavigationCustomPage = {
247
329
  label?: string;
248
330
  element: any;
249
331
  icon?: string; // Lucide icon name
332
+ layout?: "default" | "none";
250
333
  badge?: {
251
334
  label: string;
252
335
  color: "green" | "blue" | "yellow" | "red" | "purple" | "indigo" | "gray" | "outline";
@@ -263,6 +346,120 @@ type NavigationCustomPage = {
263
346
 
264
347
  </details>
265
348
 
349
+ Set `layout: "none"` to render the page without the default Zudoku layout (header, sidebar, footer).
350
+ This is useful for fully custom landing pages.
351
+
352
+ ### `type: separator`
353
+
354
+ A visual divider line in the sidebar. Use separators to create visual breaks between groups of
355
+ items.
356
+
357
+ ```tsx
358
+ {
359
+ type: "category",
360
+ label: "Documentation",
361
+ items: [
362
+ "guides/getting-started",
363
+ "guides/installation",
364
+ { type: "separator" },
365
+ "guides/advanced",
366
+ "guides/troubleshooting",
367
+ ],
368
+ }
369
+ ```
370
+
371
+ <details>
372
+ <summary>**TypeScript type declaration**</summary>
373
+
374
+ ```ts
375
+ type NavigationSeparator = {
376
+ type: "separator";
377
+ display?:
378
+ | "auth"
379
+ | "anon"
380
+ | "always"
381
+ | "hide"
382
+ | ((params: { context: ZudokuContext; auth: UseAuthReturn }) => boolean);
383
+ };
384
+ ```
385
+
386
+ </details>
387
+
388
+ ### `type: section`
389
+
390
+ A non-interactive heading label in the sidebar. Sections are rendered as small uppercase text and
391
+ are useful for labeling groups of items without adding a collapsible wrapper.
392
+
393
+ ```tsx
394
+ {
395
+ type: "category",
396
+ label: "Documentation",
397
+ items: [
398
+ { type: "section", label: "Getting Started" },
399
+ "guides/quickstart",
400
+ "guides/installation",
401
+ { type: "section", label: "Advanced" },
402
+ "guides/plugins",
403
+ "guides/deployment",
404
+ ],
405
+ }
406
+ ```
407
+
408
+ <details>
409
+ <summary>**TypeScript type declaration**</summary>
410
+
411
+ ```ts
412
+ type NavigationSection = {
413
+ type: "section";
414
+ label: string;
415
+ display?:
416
+ | "auth"
417
+ | "anon"
418
+ | "always"
419
+ | "hide"
420
+ | ((params: { context: ZudokuContext; auth: UseAuthReturn }) => boolean);
421
+ };
422
+ ```
423
+
424
+ </details>
425
+
426
+ ### `type: filter`
427
+
428
+ An inline search input that updates the shared navigation filter. When the user types, the query is
429
+ applied across the sidebar so that only matching items remain visible.
430
+
431
+ ```tsx
432
+ {
433
+ type: "category",
434
+ label: "Documentation",
435
+ items: [
436
+ { type: "filter", placeholder: "Filter documentation" },
437
+ "guides/getting-started",
438
+ "guides/installation",
439
+ "guides/authentication",
440
+ "guides/deployment",
441
+ ],
442
+ }
443
+ ```
444
+
445
+ <details>
446
+ <summary>**TypeScript type declaration**</summary>
447
+
448
+ ```ts
449
+ type NavigationFilter = {
450
+ type: "filter";
451
+ placeholder?: string;
452
+ display?:
453
+ | "auth"
454
+ | "anon"
455
+ | "always"
456
+ | "hide"
457
+ | ((params: { context: ZudokuContext; auth: UseAuthReturn }) => boolean);
458
+ };
459
+ ```
460
+
461
+ </details>
462
+
266
463
  ## Display Control
267
464
 
268
465
  All navigation items support a `display` property that controls when the item should be visible:
@@ -363,6 +560,94 @@ sidebar_label: Short Title
363
560
  In this example, the document's title remains "My Long Title," but the sidebar displays "Short
364
561
  Title."
365
562
 
563
+ For the complete list of supported frontmatter properties, see
564
+ [Frontmatter](/docs/markdown/frontmatter).
565
+
566
+ ## Common Patterns
567
+
568
+ ### Serving a document at the root URL
569
+
570
+ To make a markdown document accessible at `/`, use the `path` property to override the default
571
+ file-based route:
572
+
573
+ ```tsx title="Standalone root doc"
574
+ navigation: [
575
+ {
576
+ type: "doc",
577
+ file: "home.md",
578
+ path: "/",
579
+ label: "Home",
580
+ },
581
+ ],
582
+ ```
583
+
584
+ ```tsx title="Category with root landing page"
585
+ navigation: [
586
+ {
587
+ type: "category",
588
+ label: "Documentation",
589
+ link: {
590
+ type: "doc",
591
+ file: "home.md",
592
+ path: "/",
593
+ },
594
+ items: [
595
+ "guides/getting-started",
596
+ "guides/installation",
597
+ ],
598
+ },
599
+ ],
600
+ ```
601
+
602
+ ### Landing page with hidden tab
603
+
604
+ Use a `custom-page` with `display: "hide"` and `layout: "none"` to create a full-page landing
605
+ experience that doesn't appear in the navigation tabs:
606
+
607
+ ```tsx
608
+ navigation: [
609
+ {
610
+ type: "custom-page",
611
+ path: "/",
612
+ display: "hide",
613
+ layout: "none",
614
+ element: <LandingPage />,
615
+ },
616
+ {
617
+ type: "category",
618
+ label: "Documentation",
619
+ items: ["docs/quickstart", "docs/installation"],
620
+ },
621
+ ],
622
+ ```
623
+
624
+ ### Organized sidebar with sections and separators
625
+
626
+ Combine `section`, `separator`, and `filter` items to create a well-structured sidebar:
627
+
628
+ ```tsx
629
+ navigation: [
630
+ {
631
+ type: "category",
632
+ label: "Documentation",
633
+ items: [
634
+ { type: "filter", placeholder: "Filter documentation" },
635
+ { type: "section", label: "Getting Started" },
636
+ "guides/quickstart",
637
+ "guides/installation",
638
+ { type: "separator" },
639
+ { type: "section", label: "Advanced" },
640
+ {
641
+ type: "category",
642
+ label: "Plugins",
643
+ icon: "blocks",
644
+ items: ["plugins/overview", "plugins/custom"],
645
+ },
646
+ ],
647
+ },
648
+ ],
649
+ ```
650
+
366
651
  ## Navigation Rules
367
652
 
368
653
  Plugins generate sidebar navigation automatically (e.g. from OpenAPI tags). Navigation rules let you
@@ -0,0 +1,189 @@
1
+ ---
2
+ title: Documenting MCP Servers
3
+ sidebar_icon: bot
4
+ zuplo: false
5
+ ---
6
+
7
+ Zudoku can render a dedicated [MCP](https://modelcontextprotocol.io/) endpoint UI for any OpenAPI
8
+ operation that has the `x-mcp-server` extension. When detected, the operation page replaces the
9
+ standard request/response view with an MCP card showing the endpoint URL, a copy button, and tabbed
10
+ installation instructions for Claude, ChatGPT, Cursor, VS Code, and a generic config.
11
+
12
+ ## Adding the extension
13
+
14
+ Add the `x-mcp-server` extension to an operation in your OpenAPI spec. While MCP servers typically
15
+ use `POST`, the extension works on any HTTP method:
16
+
17
+ ```json title="openapi.json (paths section)"
18
+ {
19
+ "paths": {
20
+ "/mcp": {
21
+ "post": {
22
+ "summary": "My MCP Server",
23
+ "description": "MCP endpoint for querying documentation.",
24
+ "operationId": "mcpEndpoint",
25
+ "x-mcp-server": {
26
+ "name": "my-mcp-server",
27
+ "version": "1.0.0",
28
+ "tools": [
29
+ {
30
+ "name": "search_docs",
31
+ "description": "Search the documentation"
32
+ },
33
+ {
34
+ "name": "get_page",
35
+ "description": "Retrieve a specific documentation page"
36
+ }
37
+ ]
38
+ },
39
+ "responses": {
40
+ "200": {
41
+ "description": "MCP response"
42
+ }
43
+ }
44
+ }
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ The UI will display beneath the operation heading, showing the full MCP URL derived from the server
51
+ URL and the operation path.
52
+
53
+ You can also use the shorthand `"x-mcp-server": true` to enable the MCP UI without specifying any
54
+ metadata. In this case, the operation's `summary` is used as the server name.
55
+
56
+ ## Extension properties
57
+
58
+ | Property | Type | Required | Description |
59
+ | --------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
60
+ | `name` | `string` | No | Display name used in the generated client configuration snippets. Falls back to the operation `summary`, then `"mcp-server"` |
61
+ | `version` | `string` | No | Version metadata (included for completeness; not currently rendered in UI) |
62
+ | `tools` | `array` | No | Tools metadata (used by Zuplo enrichment; not currently rendered in UI) |
63
+
64
+ Each tool in the `tools` array has:
65
+
66
+ | Property | Type | Required | Description |
67
+ | ------------- | -------- | -------- | ------------------------------- |
68
+ | `name` | `string` | Yes | Tool name |
69
+ | `description` | `string` | No | Human-readable tool description |
70
+
71
+ ## MCP URL resolution
72
+
73
+ The displayed MCP URL is constructed from the **server URL** of the API and the **path** of the
74
+ operation. The server URL comes from the OpenAPI `servers` array (or the operation-level `servers`
75
+ override if present).
76
+
77
+ For example, with this configuration:
78
+
79
+ ```json
80
+ {
81
+ "servers": [{ "url": "https://api.example.com" }],
82
+ "paths": {
83
+ "/mcp/docs": {
84
+ "post": {
85
+ "x-mcp-server": { "name": "docs-mcp" },
86
+ "responses": { "200": { "description": "OK" } }
87
+ }
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
93
+ The displayed MCP URL will be `https://api.example.com/mcp/docs`.
94
+
95
+ ## Complete example
96
+
97
+ This is a minimal but complete OpenAPI spec that produces an MCP endpoint page:
98
+
99
+ ```json title="mcp-api.json"
100
+ {
101
+ "openapi": "3.0.3",
102
+ "info": {
103
+ "title": "Documentation MCP Server",
104
+ "version": "1.0.0"
105
+ },
106
+ "servers": [
107
+ {
108
+ "url": "https://api.example.com",
109
+ "description": "Production"
110
+ }
111
+ ],
112
+ "paths": {
113
+ "/mcp": {
114
+ "post": {
115
+ "tags": ["MCP"],
116
+ "summary": "Documentation MCP Server",
117
+ "description": "MCP endpoint powered by Inkeep for searching and querying documentation.",
118
+ "operationId": "mcpEndpoint",
119
+ "x-mcp-server": {
120
+ "name": "example-docs",
121
+ "version": "1.0.0",
122
+ "tools": [
123
+ {
124
+ "name": "search_docs",
125
+ "description": "Search the documentation"
126
+ }
127
+ ]
128
+ },
129
+ "responses": {
130
+ "200": {
131
+ "description": "MCP response"
132
+ }
133
+ }
134
+ }
135
+ }
136
+ }
137
+ }
138
+ ```
139
+
140
+ Then reference this spec in your Zudoku config (see
141
+ [API Reference](/docs/configuration/api-reference) for full `apis` configuration):
142
+
143
+ ```tsx title="zudoku.config.tsx"
144
+ import type { ZudokuConfig } from "zudoku";
145
+
146
+ const config: ZudokuConfig = {
147
+ apis: [
148
+ {
149
+ type: "file",
150
+ input: "./mcp-api.json",
151
+ path: "mcp",
152
+ },
153
+ ],
154
+ navigation: [
155
+ {
156
+ type: "link",
157
+ label: "MCP Server",
158
+ to: "/mcp",
159
+ icon: "bot",
160
+ },
161
+ ],
162
+ };
163
+
164
+ export default config;
165
+ ```
166
+
167
+ You can see a live example of this in the
168
+ [Cosmo Cargo demo](https://www.cosmocargo.dev/catalog/api-ai-cargo/ai-operations#universal-mcp-endpoint).
169
+
170
+ ## Generated UI
171
+
172
+ When Zudoku detects the `x-mcp-server` extension on an operation, the page shows:
173
+
174
+ - **MCP Endpoint card** with the full URL and a copy button
175
+ - **AI Tool Configuration** tabs with setup instructions for:
176
+ - **Claude** — `claude_desktop_config.json` using native HTTP transport
177
+ - **ChatGPT** — connector setup via Settings
178
+ - **Cursor** — `mcp.json` configuration (global or project-level)
179
+ - **VS Code** — `.vscode/mcp.json` for GitHub Copilot
180
+ - **Generic** — standard `mcp.json` format compatible with most MCP clients
181
+
182
+ The standard method badge, request body, parameters, and sidecar panels are hidden for MCP endpoints
183
+ since they use a different interaction model.
184
+
185
+ ## Using with Zuplo
186
+
187
+ If you are using [Zuplo](https://zuplo.com) to host your API, the `x-mcp-server` extension is
188
+ automatically added to POST operations that use the `mcpServerHandler`. No manual schema changes are
189
+ needed. See the [Zuplo MCP documentation](https://zuplo.com/docs/handlers/mcp-handler) for details.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zudoku",
3
- "version": "0.71.10",
3
+ "version": "0.73.0",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -130,7 +130,6 @@
130
130
  "types": "./dist/declarations/lib/hooks/index.d.ts",
131
131
  "default": "./src/lib/hooks/index.ts"
132
132
  },
133
- "./main.css": "./src/app/main.css",
134
133
  "./processors/*": {
135
134
  "types": "./dist/declarations/lib/plugins/openapi/processors/*.d.ts",
136
135
  "default": "./src/lib/plugins/openapi/processors/*.ts"
@@ -259,10 +258,10 @@
259
258
  "vaul": "1.1.2",
260
259
  "vfile": "6.0.3",
261
260
  "vite": "7.3.1",
262
- "yaml": "2.8.2",
261
+ "yaml": "2.8.3",
263
262
  "yargs": "18.0.0",
264
263
  "zod": "4.3.6",
265
- "zustand": "5.0.11"
264
+ "zustand": "5.0.12"
266
265
  },
267
266
  "devDependencies": {
268
267
  "@clerk/clerk-js": "^5.125.3",
@@ -13,19 +13,17 @@ const errorDetailsMap: Record<string, { message: string }> = {
13
13
  "The authentication request was invalid. Please try signing in again.",
14
14
  },
15
15
  unauthorized_client: {
16
- message:
17
- "This application is not authorized to access your account. Please contact support.",
16
+ message: "This application is not authorized to access your account.",
18
17
  },
19
18
  access_denied: {
20
19
  message:
21
20
  "You denied access to this application. To continue, please sign in and grant access.",
22
21
  },
23
22
  unsupported_response_type: {
24
- message:
25
- "The authentication method is not supported. Please contact support.",
23
+ message: "The authentication method is not supported.",
26
24
  },
27
25
  invalid_scope: {
28
- message: "The requested permissions are invalid. Please contact support.",
26
+ message: "The requested permissions are invalid.",
29
27
  },
30
28
  server_error: {
31
29
  message:
@@ -37,15 +35,14 @@ const errorDetailsMap: Record<string, { message: string }> = {
37
35
  },
38
36
  // Token errors
39
37
  invalid_client: {
40
- message: "Invalid application credentials. Please contact support.",
38
+ message: "Invalid application credentials.",
41
39
  },
42
40
  invalid_grant: {
43
41
  message:
44
42
  "The authentication code has expired or is invalid. Please sign in again.",
45
43
  },
46
44
  unsupported_grant_type: {
47
- message:
48
- "The authentication method is not supported. Please contact support.",
45
+ message: "The authentication method is not supported.",
49
46
  },
50
47
  // Custom errors
51
48
  invalid_state: {
@@ -64,8 +61,7 @@ const errorDetailsMap: Record<string, { message: string }> = {
64
61
  message: "Your authentication session has expired. Please sign in again.",
65
62
  },
66
63
  configuration_error: {
67
- message:
68
- "There is an issue with the authentication configuration. Please contact support.",
64
+ message: "There is an issue with the authentication configuration.",
69
65
  },
70
66
  unknown_error: {
71
67
  message:
@@ -13,7 +13,7 @@ import type {
13
13
  import { CoreAuthenticationPlugin } from "../AuthenticationPlugin.js";
14
14
  import { CallbackHandler } from "../components/CallbackHandler.js";
15
15
  import { OAuthErrorPage } from "../components/OAuthErrorPage.js";
16
- import { AuthorizationError } from "../errors.js";
16
+ import { AuthorizationError, OAuthAuthorizationError } from "../errors.js";
17
17
  import { type UserProfile, useAuthState } from "../state.js";
18
18
 
19
19
  const CODE_VERIFIER_KEY = "code-verifier";
@@ -106,6 +106,19 @@ export class OpenIDAuthenticationProvider
106
106
  throw new AuthorizationError("No expires_in in response");
107
107
  }
108
108
 
109
+ const accessToken = response.access_token;
110
+ if (accessToken.split(".").length !== 3) {
111
+ throw new OAuthAuthorizationError(
112
+ "The access token received is not a valid JWT.",
113
+ {
114
+ error: "configuration_error",
115
+ error_description:
116
+ "The authentication provider is issuing opaque tokens instead of JWTs. " +
117
+ "Ensure you have configured the correct `audience` in your authentication settings.",
118
+ },
119
+ );
120
+ }
121
+
109
122
  const claims = response.id_token
110
123
  ? oauth.getValidatedIdTokenClaims(response)
111
124
  : undefined;
@@ -17,7 +17,7 @@ export const DeveloperHint = ({
17
17
  if (process.env.NODE_ENV !== "development") return null;
18
18
 
19
19
  return (
20
- <Alert variant="info" className={className}>
20
+ <Alert variant="info" className={className} fit="loose">
21
21
  <InfoIcon />
22
22
  <AlertTitle>Developer hint</AlertTitle>
23
23
  <AlertDescription>
@@ -12,9 +12,9 @@ import { Slot } from "./Slot.js";
12
12
  import { Spinner } from "./Spinner.js";
13
13
 
14
14
  const LoadingFallback = () => (
15
- <main className="col-span-full row-span-full grid place-items-center">
15
+ <div className="col-span-full row-span-full grid place-items-center">
16
16
  <Spinner />
17
- </main>
17
+ </div>
18
18
  );
19
19
 
20
20
  export const Layout = ({ children }: { children?: ReactNode }) => {
@@ -11,15 +11,17 @@ import { cn } from "../../util/cn.js";
11
11
 
12
12
  export const MCPEndpoint = ({
13
13
  serverUrl,
14
+ operationPath,
14
15
  summary,
15
16
  data,
16
17
  }: {
17
18
  serverUrl?: string;
19
+ operationPath?: string;
18
20
  data?: boolean | Record<string, unknown>;
19
21
  summary?: string;
20
22
  }) => {
21
23
  const [isCopied, setIsCopied] = useState(false);
22
- const mcpUrl = `${(serverUrl ?? "").replace(/\/+$/, "")}/mcp`;
24
+ const mcpUrl = `${(serverUrl ?? "").replace(/\/+$/, "")}${operationPath ?? "/mcp"}`;
23
25
 
24
26
  const name =
25
27
  typeof data === "boolean"
@@ -29,11 +31,8 @@ export const MCPEndpoint = ({
29
31
  const claudeConfig = `{
30
32
  "mcpServers": {
31
33
  "${name}": {
32
- "command": "npx",
33
- "args": [
34
- "mcp-remote",
35
- "${mcpUrl}"
36
- ]
34
+ "type": "http",
35
+ "url": "${mcpUrl}"
37
36
  }
38
37
  }
39
38
  }`;
@@ -48,6 +47,14 @@ export const MCPEndpoint = ({
48
47
 
49
48
  const chatgptConfig = mcpUrl;
50
49
 
50
+ const genericConfig = `{
51
+ "mcpServers": {
52
+ "${name}": {
53
+ "url": "${mcpUrl}"
54
+ }
55
+ }
56
+ }`;
57
+
51
58
  const vscodeConfig = `{
52
59
  "servers": {
53
60
  "${name}": {
@@ -117,11 +124,12 @@ export const MCPEndpoint = ({
117
124
  <hr className="my-4" />
118
125
 
119
126
  <Tabs defaultValue="claude" className="w-full">
120
- <TabsList className="grid w-full grid-cols-4">
127
+ <TabsList className="grid w-full grid-cols-5">
121
128
  <TabsTrigger value="claude">Claude</TabsTrigger>
122
129
  <TabsTrigger value="chatgpt">ChatGPT</TabsTrigger>
123
130
  <TabsTrigger value="cursor">Cursor</TabsTrigger>
124
131
  <TabsTrigger value="vscode">VS Code</TabsTrigger>
132
+ <TabsTrigger value="generic">Generic</TabsTrigger>
125
133
  </TabsList>
126
134
 
127
135
  <Typography className="text-sm max-w-full">
@@ -264,6 +272,34 @@ export const MCPEndpoint = ({
264
272
  <ExternalLinkIcon className="h-3 w-3" />
265
273
  </a>
266
274
  </TabsContent>
275
+
276
+ <TabsContent value="generic" className="space-y-3">
277
+ <p>
278
+ Generic <InlineCode>.mcp.json</InlineCode> configuration
279
+ format that works with most MCP-compatible AI tools.
280
+ </p>
281
+ <SyntaxHighlight
282
+ showLanguageIndicator
283
+ title=".mcp.json"
284
+ language="json"
285
+ code={genericConfig}
286
+ className="mt-2"
287
+ />
288
+ <p className="text-sm text-muted-foreground">
289
+ Place this file in your project root or the appropriate
290
+ configuration directory for your AI tool. The exact location
291
+ depends on your specific tool.
292
+ </p>
293
+ <a
294
+ href="https://modelcontextprotocol.io/"
295
+ target="_blank"
296
+ rel="noopener noreferrer"
297
+ className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
298
+ >
299
+ Learn more about MCP
300
+ <ExternalLinkIcon className="h-3 w-3" />
301
+ </a>
302
+ </TabsContent>
267
303
  </Typography>
268
304
  </Tabs>
269
305
  </div>
@@ -88,6 +88,7 @@ export const OperationListItem = ({
88
88
  <div className="col-span-full">
89
89
  <MCPEndpoint
90
90
  serverUrl={displayServerUrl}
91
+ operationPath={operation.path}
91
92
  summary={operation.summary ?? undefined}
92
93
  data={operation.extensions?.["x-mcp-server"]}
93
94
  />
@@ -256,12 +256,13 @@ export const Playground = ({
256
256
  break;
257
257
  }
258
258
 
259
+ const upperMethod = method.toUpperCase();
259
260
  const request = new Request(
260
261
  createUrl(server ?? selectedServer, url, data),
261
262
  {
262
- method,
263
+ method: upperMethod,
263
264
  headers,
264
- body: ["GET", "HEAD"].includes(method.toUpperCase()) ? null : body,
265
+ body: ["GET", "HEAD"].includes(upperMethod) ? null : body,
265
266
  },
266
267
  );
267
268
 
@@ -153,7 +153,7 @@ const ObjectSchemaView = ({
153
153
  return properties ? { group, properties } : [];
154
154
  });
155
155
 
156
- const deprecatedProperties = groupedProperties["deprecated"];
156
+ const deprecatedProperties = groupedProperties.deprecated;
157
157
 
158
158
  const additionalObjectProperties = typeof schema.additionalProperties ===
159
159
  "object" && <SchemaView schema={schema.additionalProperties} embedded />;
@@ -120,7 +120,7 @@ const StaticZudoku = ({
120
120
  ],
121
121
  },
122
122
  ],
123
- { initialEntries: [path] },
123
+ { initialEntries: [path], basename: options.basePath },
124
124
  );
125
125
 
126
126
  return (
@@ -41,6 +41,26 @@ const normalizeProblemJson = (data: unknown): ProblemJson | undefined => {
41
41
  } as ProblemJson;
42
42
  };
43
43
 
44
+ /** Some gateways return RFC 7807-shaped bodies with `application/json`. */
45
+ const normalizeProblemJsonIfProblemLike = (
46
+ data: unknown,
47
+ ): ProblemJson | undefined => {
48
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
49
+ return;
50
+ }
51
+
52
+ const record = data as Record<string, unknown>;
53
+ const hasDetail = typeof record.detail === "string";
54
+ const hasTitle = typeof record.title === "string";
55
+ const hasStatus = typeof record.status === "number";
56
+
57
+ if (!hasDetail && !(hasTitle && hasStatus)) {
58
+ return;
59
+ }
60
+
61
+ return normalizeProblemJson(data);
62
+ };
63
+
44
64
  export const getProblemJson = async (
45
65
  response: Response,
46
66
  ): Promise<ProblemJson | undefined> => {
@@ -54,10 +74,25 @@ export const getProblemJson = async (
54
74
  };
55
75
 
56
76
  export const throwIfProblemJson = async (response: Response) => {
57
- if (!response.ok) {
77
+ if (response.ok) {
78
+ return;
79
+ }
80
+
81
+ const contentType = response.headers.get("content-type") ?? "";
82
+
83
+ if (isProblemJsonContentType(response)) {
58
84
  const problem = await getProblemJson(response);
59
85
  if (problem) {
60
86
  throw new Error(problem.detail ?? problem.title ?? "Unknown error");
61
87
  }
88
+ return;
89
+ }
90
+
91
+ if (contentType.includes("application/json")) {
92
+ const data = await parseJsonSafe(response.clone());
93
+ const problem = normalizeProblemJsonIfProblemLike(data);
94
+ if (problem) {
95
+ throw new Error(problem.detail ?? problem.title ?? "Unknown error");
96
+ }
62
97
  }
63
98
  };
@@ -49,6 +49,12 @@ const processMarkdownFile = async (
49
49
  *
50
50
  * It also writes metadata to markdown-info.json used by the llms.txt generator.
51
51
  */
52
+ export const getMarkdownOutputPath = (distDir: string, routePath: string) => {
53
+ const segments =
54
+ routePath === "/" ? ["index"] : routePath.split("/").filter(Boolean);
55
+ return `${path.join(distDir, ...segments)}.md`;
56
+ };
57
+
52
58
  const viteMarkdownExportPlugin = (): Plugin => {
53
59
  let markdownFiles: Record<string, string> = {};
54
60
  let markdownFileInfos: MarkdownFileInfo[] = [];
@@ -180,12 +186,7 @@ const viteMarkdownExportPlugin = (): Plugin => {
180
186
  content: finalMarkdown,
181
187
  });
182
188
 
183
- const segments =
184
- routePath === "/"
185
- ? ["index"]
186
- : routePath.split("/").filter(Boolean);
187
-
188
- const outputPath = `${path.join(distDir, ...segments)}.md`;
189
+ const outputPath = getMarkdownOutputPath(distDir, routePath);
189
190
 
190
191
  await mkdir(path.dirname(outputPath), { recursive: true });
191
192
 
@@ -13,7 +13,10 @@ import type { ZudokuConfig } from "../../config/validators/ZudokuConfig.js";
13
13
  import { runPluginTransformConfig } from "../../lib/core/transform-config.js";
14
14
  import invariant from "../../lib/util/invariant.js";
15
15
  import { joinUrl } from "../../lib/util/joinUrl.js";
16
- import type { MarkdownFileInfo } from "../plugin-markdown-export.js";
16
+ import {
17
+ getMarkdownOutputPath,
18
+ type MarkdownFileInfo,
19
+ } from "../plugin-markdown-export.js";
17
20
  import { isTTY, throttle, writeLine } from "../reporter.js";
18
21
  import { generateSitemap } from "../sitemap.js";
19
22
  import { routesToPaths, routesToRewrites } from "./utils.js";
@@ -224,7 +227,13 @@ export const prerender = async ({
224
227
 
225
228
  if (!docsConfig.publishMarkdown) {
226
229
  await Promise.all(
227
- markdownFileInfos.map((info) => rm(info.filePath).catch(() => {})),
230
+ markdownFileInfos.map((info) => {
231
+ const outputPath = getMarkdownOutputPath(distDir, info.routePath);
232
+ if (!path.resolve(outputPath).startsWith(path.resolve(distDir))) {
233
+ return;
234
+ }
235
+ return rm(outputPath).catch(() => {});
236
+ }),
228
237
  );
229
238
  }
230
239
  }