zudoku 0.83.0 → 0.83.1

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
@@ -5104,6 +5104,11 @@ var OperationItem = builder.objectRef("OperationItem").implement({
5104
5104
  operationId: t.exposeString("operationId", { nullable: true }),
5105
5105
  summary: t.exposeString("summary", { nullable: true }),
5106
5106
  description: t.exposeString("description", { nullable: true }),
5107
+ // Lean flag so consumers (e.g. the sidebar) can tell an MCP server
5108
+ // endpoint apart without pulling in the whole `x-mcp-server` extension.
5109
+ isMcpServer: t.boolean({
5110
+ resolve: (parent) => resolveExtensions(parent)["x-mcp-server"] !== void 0
5111
+ }),
5107
5112
  contentTypes: t.stringList({
5108
5113
  resolve: (parent) => Object.keys(parent.requestBody?.content ?? {})
5109
5114
  }),
@@ -1,3 +1,5 @@
1
+ import { type PropsWithChildren } from "react";
2
+ export declare const SearchProvider: ({ children }: PropsWithChildren) => import("react").JSX.Element;
1
3
  export declare const Search: ({ className }: {
2
4
  className?: string;
3
5
  }) => import("react").JSX.Element | null;
@@ -23,6 +23,7 @@ export interface ApiIdentityPlugin {
23
23
  export interface SearchProviderPlugin {
24
24
  renderSearch: (o: {
25
25
  isOpen: boolean;
26
+ onOpen: () => void;
26
27
  onClose: () => void;
27
28
  }) => React.JSX.Element | null;
28
29
  }
@@ -4,5 +4,5 @@ export declare function graphql(source: "\n query OperationsForTag(\n $input
4
4
  export declare function graphql(source: "\n query SchemaInfo($input: JSON!, $type: SchemaType!) {\n schema(input: $input, type: $type) {\n servers {\n url\n description\n }\n license {\n name\n url\n identifier\n }\n termsOfService\n externalDocs {\n description\n url\n }\n contact {\n name\n url\n email\n }\n description\n summary\n title\n url\n version\n tags {\n name\n description\n extensions\n }\n components {\n securitySchemes {\n name\n type\n description\n in\n paramName\n scheme\n bearerFormat\n openIdConnectUrl\n flows {\n implicit {\n authorizationUrl\n scopes {\n name\n description\n }\n }\n password {\n tokenUrl\n scopes {\n name\n description\n }\n }\n clientCredentials {\n tokenUrl\n scopes {\n name\n description\n }\n }\n authorizationCode {\n authorizationUrl\n tokenUrl\n scopes {\n name\n description\n }\n }\n }\n }\n }\n webhooks {\n name\n method\n summary\n description\n }\n }\n }\n"): typeof import("./graphql.js").SchemaInfoDocument;
5
5
  export declare function graphql(source: "\n query GetSchemas($input: JSON!, $type: SchemaType!) {\n schema(input: $input, type: $type) {\n title\n description\n summary\n components {\n schemas {\n name\n schema\n extensions\n }\n }\n }\n }\n"): typeof import("./graphql.js").GetSchemasDocument;
6
6
  export declare function graphql(source: "\n query getServerQuery($input: JSON!, $type: SchemaType!) {\n schema(input: $input, type: $type) {\n url\n servers {\n url\n }\n }\n }\n"): typeof import("./graphql.js").GetServerQueryDocument;
7
- export declare function graphql(source: "\n query GetNavigationOperations($input: JSON!, $type: SchemaType!) {\n schema(input: $input, type: $type) {\n extensions\n description\n tags {\n slug\n name\n extensions\n operations {\n summary\n slug\n method\n operationId\n path\n }\n }\n components {\n schemas {\n __typename\n }\n }\n }\n }\n"): typeof import("./graphql.js").GetNavigationOperationsDocument;
7
+ export declare function graphql(source: "\n query GetNavigationOperations($input: JSON!, $type: SchemaType!) {\n schema(input: $input, type: $type) {\n extensions\n description\n tags {\n slug\n name\n extensions\n operations {\n summary\n slug\n method\n operationId\n path\n isMcpServer\n }\n }\n components {\n schemas {\n __typename\n }\n }\n }\n }\n"): typeof import("./graphql.js").GetNavigationOperationsDocument;
8
8
  export declare function graphql(source: "\n query SchemaWarmup($input: JSON!, $type: SchemaType!) {\n schema(input: $input, type: $type) {\n openapi\n }\n }\n"): typeof import("./graphql.js").SchemaWarmupDocument;
@@ -314,6 +314,7 @@ export type GetNavigationOperationsQuery = {
314
314
  method: string;
315
315
  operationId: string | null;
316
316
  path: string;
317
+ isMcpServer: boolean;
317
318
  }>;
318
319
  }>;
319
320
  components: {
@@ -19,7 +19,7 @@ export interface McpApp {
19
19
  export declare const MCP_APPS: McpApp[];
20
20
  export declare const getVisibleApps: (authType: AuthType) => McpApp[];
21
21
  export declare const getMcpServerName: (data?: McpServerData, summary?: string) => string;
22
- export declare const getMcpUrl: (serverUrl?: string, operationPath?: string) => string;
22
+ export declare const getMcpUrl: (serverUrl?: string, operationPath?: string, data?: McpServerData) => string;
23
23
  export declare const getClaudeCodeCommand: (name: string, mcpUrl: string, auth?: AuthHeader) => string;
24
24
  export declare const getCodexCliCommand: (name: string, mcpUrl: string, auth?: AuthHeader) => string;
25
25
  export declare const getCursorConfig: (name: string, mcpUrl: string, auth?: AuthHeader) => string;
@@ -182,6 +182,11 @@ You can also pass
182
182
  Zudoku's defaults and passed through to Inkeep as-is, so any option Inkeep supports can be used —
183
183
  including ones added after this Zudoku version was released.
184
184
 
185
+ Zudoku owns the search modal's open state, so `modalSettings.isOpen` is managed for you. The
186
+ <kbd>⌘</kbd>+<kbd>K</kbd> / <kbd>Ctrl</kbd>+<kbd>K</kbd> shortcut is handled by Zudoku's search
187
+ button as well, which is why Inkeep's own `shortcutKey` is disabled by default. Setting it adds an
188
+ additional shortcut rather than replacing it.
189
+
185
190
  For example, to categorize results into tabs based on their URL:
186
191
 
187
192
  ```typescript
@@ -59,6 +59,7 @@ metadata. In this case, the operation's `summary` is used as the server name.
59
59
  | --------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
60
60
  | `name` | `string` | No | Display name used in the generated client configuration snippets. Falls back to the operation `summary`, then `"mcp-server"` |
61
61
  | `version` | `string` | No | Version metadata (included for completeness; not currently rendered in UI) |
62
+ | `url` | `string` | No | Overrides the endpoint URL shown in the card and install snippets |
62
63
  | `tools` | `array` | No | Tools metadata (used by Zuplo enrichment; not currently rendered in UI) |
63
64
 
64
65
  Each tool in the `tools` array has:
@@ -92,6 +93,20 @@ For example, with this configuration:
92
93
 
93
94
  The displayed MCP URL will be `https://api.example.com/mcp/docs`.
94
95
 
96
+ If your MCP server lives on its own hostname, set `url` on the extension to override the derived
97
+ endpoint:
98
+
99
+ ```json
100
+ {
101
+ "x-mcp-server": { "name": "docs-mcp", "url": "https://mcp.example.com/mcp" }
102
+ }
103
+ ```
104
+
105
+ An absolute URL is used verbatim everywhere the endpoint appears, and takes precedence over the
106
+ server dropdown. A value without a scheme (such as `/v2/mcp`) is treated as a path on the server URL
107
+ instead. See the
108
+ [`x-mcp-server` reference](/docs/openapi-extensions/x-mcp-server#overriding-the-url) for details.
109
+
95
110
  ## Complete example
96
111
 
97
112
  This is a minimal but complete OpenAPI spec that produces an MCP endpoint page:
@@ -32,6 +32,7 @@ When using the object form, the following properties are available:
32
32
  | --------- | --------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
33
33
  | `name` | `string` | No | Display name used in the generated client configuration snippets. Falls back to the operation `summary`, then `"mcp-server"` |
34
34
  | `version` | `string` | No | Version metadata |
35
+ | `url` | `string` | No | Overrides the endpoint URL shown in the card and install snippets. See [MCP URL resolution](#mcp-url-resolution) |
35
36
  | `tools` | `[Tool Object]` | No | Array of tools provided by the MCP server |
36
37
 
37
38
  Each item in the `tools` array:
@@ -47,6 +48,35 @@ The displayed MCP URL is constructed from the **server URL** of the API and the
47
48
  operation. The server URL comes from the OpenAPI `servers` array (or the operation-level `servers`
48
49
  override if present).
49
50
 
51
+ ### Overriding the URL
52
+
53
+ Set `url` on `x-mcp-server` when the MCP server is not reachable under the documented API server —
54
+ for example when it runs on its own hostname:
55
+
56
+ ```yaml
57
+ servers:
58
+ - url: https://api.example.com
59
+ paths:
60
+ /mcp:
61
+ post:
62
+ summary: My MCP Server
63
+ x-mcp-server:
64
+ name: my-mcp-server
65
+ url: https://mcp.example.com/mcp
66
+ responses:
67
+ "200":
68
+ description: MCP response
69
+ ```
70
+
71
+ The card and every install snippet then use `https://mcp.example.com/mcp` instead of
72
+ `https://api.example.com/mcp`.
73
+
74
+ An absolute `url` (one with a scheme, such as `https://`) replaces the endpoint entirely and is used
75
+ verbatim — it also takes precedence over the server picked in the server dropdown, since it names a
76
+ host of its own. A value without a scheme is treated as a path on the server URL instead, so
77
+ `url: /v2/mcp` resolves to `https://api.example.com/v2/mcp` and still follows server selection.
78
+ Blank values are ignored and the URL falls back to the server URL plus the operation path.
79
+
50
80
  ## Examples
51
81
 
52
82
  ### Boolean shorthand
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zudoku",
3
- "version": "0.83.0",
3
+ "version": "0.83.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.22.0"
@@ -25,7 +25,7 @@ import { useZudoku } from "./context/ZudokuContext.js";
25
25
  import { HeaderNavigation } from "./HeaderNavigation.js";
26
26
  import { MobileTopNavigation } from "./MobileTopNavigation.js";
27
27
  import { PageProgress } from "./PageProgress.js";
28
- import { Search } from "./Search.js";
28
+ import { Search, SearchProvider } from "./Search.js";
29
29
  import { Slot } from "./Slot.js";
30
30
  import { ThemeSwitch } from "./ThemeSwitch.js";
31
31
  import { TopNavigation } from "./TopNavigation.js";
@@ -151,102 +151,106 @@ export const Header = memo(function HeaderInner() {
151
151
  const borderBottom = "inset-shadow-[0_-1px_0_0_var(--border)]";
152
152
 
153
153
  return (
154
- <header
155
- className="sticky lg:top-0 z-10 bg-background/80 backdrop-blur w-full"
156
- data-pagefind-ignore="all"
157
- >
158
- <Banner />
159
- <div className={cn(borderBottom, "relative")}>
160
- <PageProgress />
161
- <div className="max-w-screen-2xl mx-auto flex lg:grid lg:grid-cols-[1fr_auto_1fr] gap-2 items-center justify-between h-(--top-header-height) px-4 lg:px-8 border-transparent">
162
- <div className="flex items-center gap-4 min-w-0 justify-self-start">
163
- <Link
164
- to={site?.logo?.href ?? "/"}
165
- reloadDocument={site?.logo?.reloadDocument ?? true}
166
- className="shrink-0"
167
- >
168
- <div className="flex items-center gap-3.5">
169
- {site?.logo ? (
170
- <>
171
- <Head>
172
- <link rel="preload" as="image" href={logoLightSrc} />
173
- <link rel="preload" as="image" href={logoDarkSrc} />
174
- </Head>
175
- <img
176
- src={logoLightSrc}
177
- alt={site.logo.alt ?? site.title}
178
- style={{ width: site.logo.width }}
179
- className="max-h-(--top-header-height) dark:hidden"
180
- />
181
- <img
182
- src={logoDarkSrc}
183
- alt={site.logo.alt ?? site.title}
184
- style={{ width: site.logo.width }}
185
- className="max-h-(--top-header-height) hidden dark:block"
186
- />
187
- </>
188
- ) : (
189
- <span className="font-semibold text-2xl">{site?.title}</span>
190
- )}
191
- </div>
192
- </Link>
193
- <Slot.Target name="head-navigation-start" />
194
- {searchPosition === "start" && (
195
- <Search className="hidden lg:flex" />
196
- )}
197
- {authPosition === "start" && (
198
- <div className="hidden lg:flex">
199
- <ProfileMenu />
200
- </div>
201
- )}
202
- {navPosition === "start" && (
203
- <div className="hidden lg:block min-w-0">
204
- <HeaderNavigation />
205
- </div>
206
- )}
207
- </div>
208
-
209
- <div className="flex items-center justify-center">
210
- <Search
211
- className={cn(
212
- searchPosition === "center" ? "flex" : "flex lg:hidden",
154
+ <SearchProvider>
155
+ <header
156
+ className="sticky lg:top-0 z-10 bg-background/80 backdrop-blur w-full"
157
+ data-pagefind-ignore="all"
158
+ >
159
+ <Banner />
160
+ <div className={cn(borderBottom, "relative")}>
161
+ <PageProgress />
162
+ <div className="max-w-screen-2xl mx-auto flex lg:grid lg:grid-cols-[1fr_auto_1fr] gap-2 items-center justify-between h-(--top-header-height) px-4 lg:px-8 border-transparent">
163
+ <div className="flex items-center gap-4 min-w-0 justify-self-start">
164
+ <Link
165
+ to={site?.logo?.href ?? "/"}
166
+ reloadDocument={site?.logo?.reloadDocument ?? true}
167
+ className="shrink-0"
168
+ >
169
+ <div className="flex items-center gap-3.5">
170
+ {site?.logo ? (
171
+ <>
172
+ <Head>
173
+ <link rel="preload" as="image" href={logoLightSrc} />
174
+ <link rel="preload" as="image" href={logoDarkSrc} />
175
+ </Head>
176
+ <img
177
+ src={logoLightSrc}
178
+ alt={site.logo.alt ?? site.title}
179
+ style={{ width: site.logo.width }}
180
+ className="max-h-(--top-header-height) dark:hidden"
181
+ />
182
+ <img
183
+ src={logoDarkSrc}
184
+ alt={site.logo.alt ?? site.title}
185
+ style={{ width: site.logo.width }}
186
+ className="max-h-(--top-header-height) hidden dark:block"
187
+ />
188
+ </>
189
+ ) : (
190
+ <span className="font-semibold text-2xl">
191
+ {site?.title}
192
+ </span>
193
+ )}
194
+ </div>
195
+ </Link>
196
+ <Slot.Target name="head-navigation-start" />
197
+ {searchPosition === "start" && (
198
+ <Search className="hidden lg:flex" />
213
199
  )}
214
- />
215
- {navPosition === "center" && (
216
- <div className="hidden lg:block min-w-0">
217
- <HeaderNavigation />
218
- </div>
219
- )}
220
- {authPosition === "center" && (
221
- <div className="hidden lg:flex">
222
- <ProfileMenu />
223
- </div>
224
- )}
225
- </div>
200
+ {authPosition === "start" && (
201
+ <div className="hidden lg:flex">
202
+ <ProfileMenu />
203
+ </div>
204
+ )}
205
+ {navPosition === "start" && (
206
+ <div className="hidden lg:block min-w-0">
207
+ <HeaderNavigation />
208
+ </div>
209
+ )}
210
+ </div>
226
211
 
227
- <div className="flex items-center gap-2 justify-self-end">
228
- <MobileTopNavigation />
229
- <div className="hidden lg:flex items-center text-sm gap-2">
230
- {navPosition === "end" && (
231
- <div className="min-w-0">
212
+ <div className="flex items-center justify-center">
213
+ <Search
214
+ className={cn(
215
+ searchPosition === "center" ? "flex" : "flex lg:hidden",
216
+ )}
217
+ />
218
+ {navPosition === "center" && (
219
+ <div className="hidden lg:block min-w-0">
232
220
  <HeaderNavigation />
233
221
  </div>
234
222
  )}
235
- {authPosition === "end" && <ProfileMenu />}
236
- {searchPosition === "end" && <Search />}
237
- <Slot.Target name="head-navigation-end" />
238
- {themeSwitcherEnabled && <ThemeSwitch />}
223
+ {authPosition === "center" && (
224
+ <div className="hidden lg:flex">
225
+ <ProfileMenu />
226
+ </div>
227
+ )}
228
+ </div>
229
+
230
+ <div className="flex items-center gap-2 justify-self-end">
231
+ <MobileTopNavigation />
232
+ <div className="hidden lg:flex items-center text-sm gap-2">
233
+ {navPosition === "end" && (
234
+ <div className="min-w-0">
235
+ <HeaderNavigation />
236
+ </div>
237
+ )}
238
+ {authPosition === "end" && <ProfileMenu />}
239
+ {searchPosition === "end" && <Search />}
240
+ <Slot.Target name="head-navigation-end" />
241
+ {themeSwitcherEnabled && <ThemeSwitch />}
242
+ </div>
239
243
  </div>
240
244
  </div>
241
245
  </div>
242
- </div>
243
- <div className={cn("hidden lg:block", borderBottom)}>
244
- <div className="max-w-screen-2xl mx-auto border-transparent relative">
245
- <Slot.Target name="top-navigation-before" />
246
- <TopNavigation />
247
- <Slot.Target name="top-navigation-after" />
246
+ <div className={cn("hidden lg:block", borderBottom)}>
247
+ <div className="max-w-screen-2xl mx-auto border-transparent relative">
248
+ <Slot.Target name="top-navigation-before" />
249
+ <TopNavigation />
250
+ <Slot.Target name="top-navigation-after" />
251
+ </div>
248
252
  </div>
249
- </div>
250
- </header>
253
+ </header>
254
+ </SearchProvider>
251
255
  );
252
256
  });
@@ -1,5 +1,14 @@
1
1
  import { SearchIcon } from "lucide-react";
2
- import { Suspense, useCallback, useEffect, useState } from "react";
2
+ import {
3
+ createContext,
4
+ type PropsWithChildren,
5
+ Suspense,
6
+ use,
7
+ useCallback,
8
+ useEffect,
9
+ useMemo,
10
+ useState,
11
+ } from "react";
3
12
  import { isSearchPlugin } from "../core/plugins.js";
4
13
  import { focusRing } from "../ui/util.js";
5
14
  import { cn } from "../util/cn.js";
@@ -7,40 +16,70 @@ import { getOS } from "../util/os.js";
7
16
  import { ClientOnly } from "./ClientOnly.js";
8
17
  import { useZudoku } from "./context/ZudokuContext.js";
9
18
 
10
- export const Search = ({ className }: { className?: string }) => {
19
+ /** `null` when no search plugin is configured, `undefined` outside a provider */
20
+ const SearchContext = createContext<{ onOpen: () => void } | null | undefined>(
21
+ undefined,
22
+ );
23
+
24
+ /**
25
+ * Owns the search modal and the ⌘K / Ctrl+K shortcut for all `Search` buttons
26
+ * below it. The header renders a button per breakpoint and placement, so
27
+ * keeping this per button would open one modal for each of them.
28
+ */
29
+ export const SearchProvider = ({ children }: PropsWithChildren) => {
11
30
  const ctx = useZudoku();
12
31
  const [isOpen, setIsOpen] = useState(false);
13
32
  const onOpen = useCallback(() => setIsOpen(true), []);
14
33
  const onClose = useCallback(() => setIsOpen(false), []);
15
34
 
35
+ const searchPlugin = ctx.options.plugins?.find(isSearchPlugin);
36
+
16
37
  useEffect(() => {
17
- if (isOpen) {
18
- return;
19
- }
38
+ if (isOpen || !searchPlugin) return;
20
39
 
21
- function onKeyDown(event: KeyboardEvent) {
40
+ const onKeyDown = (event: KeyboardEvent) => {
22
41
  if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
23
42
  event.preventDefault();
24
43
  setIsOpen(true);
25
44
  }
26
- }
45
+ };
27
46
 
28
47
  window.addEventListener("keydown", onKeyDown);
29
48
 
30
- return () => {
31
- window.removeEventListener("keydown", onKeyDown);
32
- };
33
- }, [isOpen]);
49
+ return () => window.removeEventListener("keydown", onKeyDown);
50
+ }, [isOpen, searchPlugin]);
34
51
 
35
- const searchPlugin = ctx.options.plugins?.find(isSearchPlugin);
52
+ const value = useMemo(
53
+ () => (searchPlugin ? { onOpen } : null),
54
+ [searchPlugin, onOpen],
55
+ );
56
+
57
+ return (
58
+ <SearchContext value={value}>
59
+ {children}
60
+ {searchPlugin && (
61
+ <Suspense>
62
+ {searchPlugin.renderSearch({ isOpen, onOpen, onClose })}
63
+ </Suspense>
64
+ )}
65
+ </SearchContext>
66
+ );
67
+ };
68
+
69
+ export const Search = ({ className }: { className?: string }) => {
70
+ const search = use(SearchContext);
71
+
72
+ if (search === undefined) {
73
+ throw new Error("Search must be used within a SearchProvider.");
74
+ }
36
75
 
37
- if (!searchPlugin) return null;
76
+ if (!search) return null;
38
77
 
39
78
  return (
40
79
  <div className={className}>
41
80
  <button
42
81
  type="button"
43
- onClick={onOpen}
82
+ onClick={search.onOpen}
44
83
  className={cn(
45
84
  "relative w-full md:w-56 flex items-center border bg-clip-padding h-8 rounded-lg px-3 pr-14 text-sm transition-all",
46
85
  "border-input text-muted-foreground bg-background hover:bg-muted/50 hover:text-foreground shadow-xs",
@@ -55,7 +94,6 @@ export const Search = ({ className }: { className?: string }) => {
55
94
  <KbdShortcut />
56
95
  </ClientOnly>
57
96
  </button>
58
- <Suspense>{searchPlugin.renderSearch({ isOpen, onClose })}</Suspense>
59
97
  </div>
60
98
  );
61
99
  };
@@ -46,6 +46,7 @@ export interface ApiIdentityPlugin {
46
46
  export interface SearchProviderPlugin {
47
47
  renderSearch: (o: {
48
48
  isOpen: boolean;
49
+ onOpen: () => void;
49
50
  onClose: () => void;
50
51
  }) => React.JSX.Element | null;
51
52
  }
@@ -711,6 +711,12 @@ const OperationItem = builder
711
711
  operationId: t.exposeString("operationId", { nullable: true }),
712
712
  summary: t.exposeString("summary", { nullable: true }),
713
713
  description: t.exposeString("description", { nullable: true }),
714
+ // Lean flag so consumers (e.g. the sidebar) can tell an MCP server
715
+ // endpoint apart without pulling in the whole `x-mcp-server` extension.
716
+ isMcpServer: t.boolean({
717
+ resolve: (parent) =>
718
+ resolveExtensions(parent)["x-mcp-server"] !== undefined,
719
+ }),
714
720
  contentTypes: t.stringList({
715
721
  resolve: (parent) => Object.keys(parent.requestBody?.content ?? {}),
716
722
  }),
@@ -92,7 +92,7 @@ export const MCPEndpoint = ({
92
92
  summary?: string;
93
93
  }) => {
94
94
  const [isCopied, setIsCopied] = useState(false);
95
- const mcpUrl = getMcpUrl(serverUrl, operationPath);
95
+ const mcpUrl = getMcpUrl(serverUrl, operationPath, data);
96
96
  const name = getMcpServerName(data, summary);
97
97
  const auth = getAuthHeader(data);
98
98
  const authType = getAuthType(data);
@@ -19,7 +19,7 @@ type Documents = {
19
19
  "\n query SchemaInfo($input: JSON!, $type: SchemaType!) {\n schema(input: $input, type: $type) {\n servers {\n url\n description\n }\n license {\n name\n url\n identifier\n }\n termsOfService\n externalDocs {\n description\n url\n }\n contact {\n name\n url\n email\n }\n description\n summary\n title\n url\n version\n tags {\n name\n description\n extensions\n }\n components {\n securitySchemes {\n name\n type\n description\n in\n paramName\n scheme\n bearerFormat\n openIdConnectUrl\n flows {\n implicit {\n authorizationUrl\n scopes {\n name\n description\n }\n }\n password {\n tokenUrl\n scopes {\n name\n description\n }\n }\n clientCredentials {\n tokenUrl\n scopes {\n name\n description\n }\n }\n authorizationCode {\n authorizationUrl\n tokenUrl\n scopes {\n name\n description\n }\n }\n }\n }\n }\n webhooks {\n name\n method\n summary\n description\n }\n }\n }\n": typeof types.SchemaInfoDocument;
20
20
  "\n query GetSchemas($input: JSON!, $type: SchemaType!) {\n schema(input: $input, type: $type) {\n title\n description\n summary\n components {\n schemas {\n name\n schema\n extensions\n }\n }\n }\n }\n": typeof types.GetSchemasDocument;
21
21
  "\n query getServerQuery($input: JSON!, $type: SchemaType!) {\n schema(input: $input, type: $type) {\n url\n servers {\n url\n }\n }\n }\n": typeof types.GetServerQueryDocument;
22
- "\n query GetNavigationOperations($input: JSON!, $type: SchemaType!) {\n schema(input: $input, type: $type) {\n extensions\n description\n tags {\n slug\n name\n extensions\n operations {\n summary\n slug\n method\n operationId\n path\n }\n }\n components {\n schemas {\n __typename\n }\n }\n }\n }\n": typeof types.GetNavigationOperationsDocument;
22
+ "\n query GetNavigationOperations($input: JSON!, $type: SchemaType!) {\n schema(input: $input, type: $type) {\n extensions\n description\n tags {\n slug\n name\n extensions\n operations {\n summary\n slug\n method\n operationId\n path\n isMcpServer\n }\n }\n components {\n schemas {\n __typename\n }\n }\n }\n }\n": typeof types.GetNavigationOperationsDocument;
23
23
  "\n query SchemaWarmup($input: JSON!, $type: SchemaType!) {\n schema(input: $input, type: $type) {\n openapi\n }\n }\n": typeof types.SchemaWarmupDocument;
24
24
  };
25
25
  const documents: Documents = {
@@ -35,7 +35,7 @@ const documents: Documents = {
35
35
  types.GetSchemasDocument,
36
36
  "\n query getServerQuery($input: JSON!, $type: SchemaType!) {\n schema(input: $input, type: $type) {\n url\n servers {\n url\n }\n }\n }\n":
37
37
  types.GetServerQueryDocument,
38
- "\n query GetNavigationOperations($input: JSON!, $type: SchemaType!) {\n schema(input: $input, type: $type) {\n extensions\n description\n tags {\n slug\n name\n extensions\n operations {\n summary\n slug\n method\n operationId\n path\n }\n }\n components {\n schemas {\n __typename\n }\n }\n }\n }\n":
38
+ "\n query GetNavigationOperations($input: JSON!, $type: SchemaType!) {\n schema(input: $input, type: $type) {\n extensions\n description\n tags {\n slug\n name\n extensions\n operations {\n summary\n slug\n method\n operationId\n path\n isMcpServer\n }\n }\n components {\n schemas {\n __typename\n }\n }\n }\n }\n":
39
39
  types.GetNavigationOperationsDocument,
40
40
  "\n query SchemaWarmup($input: JSON!, $type: SchemaType!) {\n schema(input: $input, type: $type) {\n openapi\n }\n }\n":
41
41
  types.SchemaWarmupDocument,
@@ -81,7 +81,7 @@ export function graphql(
81
81
  * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
82
82
  */
83
83
  export function graphql(
84
- source: "\n query GetNavigationOperations($input: JSON!, $type: SchemaType!) {\n schema(input: $input, type: $type) {\n extensions\n description\n tags {\n slug\n name\n extensions\n operations {\n summary\n slug\n method\n operationId\n path\n }\n }\n components {\n schemas {\n __typename\n }\n }\n }\n }\n",
84
+ source: "\n query GetNavigationOperations($input: JSON!, $type: SchemaType!) {\n schema(input: $input, type: $type) {\n extensions\n description\n tags {\n slug\n name\n extensions\n operations {\n summary\n slug\n method\n operationId\n path\n isMcpServer\n }\n }\n components {\n schemas {\n __typename\n }\n }\n }\n }\n",
85
85
  ): typeof import("./graphql.js").GetNavigationOperationsDocument;
86
86
  /**
87
87
  * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
@@ -281,6 +281,7 @@ export type GetNavigationOperationsQuery = {
281
281
  method: string;
282
282
  operationId: string | null;
283
283
  path: string;
284
+ isMcpServer: boolean;
284
285
  }>;
285
286
  }>;
286
287
  components: { schemas: Array<{ __typename: "SchemaItem" }> | null } | null;
@@ -731,6 +732,7 @@ export const GetNavigationOperationsDocument = new TypedDocumentString(`
731
732
  method
732
733
  operationId
733
734
  path
735
+ isMcpServer
734
736
  }
735
737
  }
736
738
  components {
@@ -36,6 +36,7 @@ export const GetNavigationOperationsQuery = graphql(`
36
36
  method
37
37
  operationId
38
38
  path
39
+ isMcpServer
39
40
  }
40
41
  }
41
42
  components {
@@ -187,8 +187,38 @@ export const getMcpServerName = (
187
187
  return (data?.name as string) ?? summary ?? "mcp-server";
188
188
  };
189
189
 
190
- export const getMcpUrl = (serverUrl?: string, operationPath?: string) =>
191
- `${(serverUrl ?? "").replace(/\/+$/, "")}${operationPath ?? "/mcp"}`;
190
+ // Matches a URL that carries its own scheme, e.g. `https://mcp.example.com`.
191
+ const isAbsoluteUrl = (value: string) => /^[a-z][a-z0-9+.-]*:\/\//i.test(value);
192
+
193
+ // Reads the `url` override from x-mcp-server, ignoring blank and non-string
194
+ // values so callers fall back to the derived endpoint.
195
+ const getUrlOverride = (data?: McpServerData): string | undefined => {
196
+ if (typeof data === "boolean") return undefined;
197
+
198
+ const url = data?.url;
199
+ if (typeof url !== "string") return undefined;
200
+
201
+ const trimmed = url.trim();
202
+ return trimmed === "" ? undefined : trimmed;
203
+ };
204
+
205
+ // The MCP endpoint is derived from the API's server URL plus the operation
206
+ // path. An `x-mcp-server.url` override takes precedence, for servers that are
207
+ // not hosted under the documented API server: an absolute URL replaces the
208
+ // endpoint entirely, anything else is treated as a path on the server URL.
209
+ export const getMcpUrl = (
210
+ serverUrl?: string,
211
+ operationPath?: string,
212
+ data?: McpServerData,
213
+ ) => {
214
+ const override = getUrlOverride(data);
215
+ if (override && isAbsoluteUrl(override)) return override;
216
+
217
+ const path = override ?? operationPath ?? "/mcp";
218
+ return `${(serverUrl ?? "").replace(/\/+$/, "")}${
219
+ path.startsWith("/") ? path : `/${path}`
220
+ }`;
221
+ };
192
222
 
193
223
  export const getClaudeCodeCommand = (
194
224
  name: string,
@@ -29,11 +29,15 @@ export const createNavigationCategory = ({
29
29
  type: "link" as const,
30
30
  label: operation.summary ?? operation.path,
31
31
  to: `${path}#${operation.slug}`,
32
- badge: {
33
- label: operation.method,
34
- // biome-ignore lint/style/noNonNullAssertion: is guaranteed to be defined
35
- color: MethodColorMap[operation.method.toLowerCase()]!,
36
- invert: true,
37
- },
32
+ badge: operation.isMcpServer
33
+ ? // MCP server endpoints are reached over MCP, so the underlying HTTP
34
+ // method isn't meaningful to the reader.
35
+ { label: "MCP", color: "indigo" as const, invert: true }
36
+ : {
37
+ label: operation.method,
38
+ // biome-ignore lint/style/noNonNullAssertion: is guaranteed to be defined
39
+ color: MethodColorMap[operation.method.toLowerCase()]!,
40
+ invert: true,
41
+ },
38
42
  })),
39
43
  });
@@ -37,10 +37,12 @@ export type InkeepSearchPluginOptions = InkeepBaseSettings &
37
37
 
38
38
  const InkeepSearch = ({
39
39
  isOpen,
40
+ onOpen,
40
41
  onClose,
41
42
  settings,
42
43
  }: {
43
44
  isOpen: boolean;
45
+ onOpen: () => void;
44
46
  onClose: () => void;
45
47
  settings: InkeepSearchPluginOptions;
46
48
  }) => {
@@ -71,7 +73,16 @@ const InkeepSearch = ({
71
73
  ...modalSettings,
72
74
  onOpenChange: (newOpen: boolean) => {
73
75
  modalSettings?.onOpenChange?.(newOpen);
74
- if (!newOpen) onClose();
76
+ // The modal is controlled through `isOpen`, so Inkeep never opens or
77
+ // closes itself: it only reports what it wants (Escape, a
78
+ // `triggerSelector` click, or a `shortcutKey` if one was configured).
79
+ // Both directions have to be mirrored back into Zudoku's state,
80
+ // otherwise those interactions do nothing at all.
81
+ if (newOpen) {
82
+ onOpen();
83
+ } else {
84
+ onClose();
85
+ }
75
86
  },
76
87
  },
77
88
  searchSettings: {
@@ -83,28 +94,44 @@ const InkeepSearch = ({
83
94
  ...aiChatSettings,
84
95
  },
85
96
  };
86
- }, [onClose, settings]);
87
- const [searchInstance, setSearchInstance] = useState<
88
- InkeepComponentInstance | undefined
89
- >(
90
- typeof window !== "undefined" && window.Inkeep?.ModalSearchAndChat
91
- ? window.Inkeep.ModalSearchAndChat(config)
92
- : undefined,
93
- );
97
+ }, [onClose, onOpen, settings]);
98
+
99
+ const [searchInstance, setSearchInstance] =
100
+ useState<InkeepComponentInstance>();
94
101
 
95
102
  useEffect(() => {
96
- if (searchInstance) return;
103
+ let instance: InkeepComponentInstance | undefined;
104
+
105
+ // Every call mounts another widget into the DOM, so this must happen in an
106
+ // effect and never during render.
107
+ const createInstance = () => {
108
+ instance = window.Inkeep?.ModalSearchAndChat?.(config);
109
+ if (instance) setSearchInstance(instance);
110
+
111
+ return Boolean(instance);
112
+ };
113
+
114
+ const removeInstance = () => {
115
+ const created = instance;
116
+ if (!created) return;
117
+
118
+ instance = undefined;
119
+ // Defer so we never unmount synchronously mid-render (StrictMode).
120
+ setTimeout(() => created.unmount(), 0);
121
+ };
122
+
123
+ // The Inkeep script is loaded deferred, so poll until it is available
124
+ if (createInstance()) return removeInstance;
97
125
 
98
126
  const checkInkeep = setInterval(() => {
99
- if (typeof window !== "undefined" && window.Inkeep?.ModalSearchAndChat) {
100
- const inkeep = window.Inkeep.ModalSearchAndChat(config);
101
- setSearchInstance(inkeep);
102
- clearInterval(checkInkeep);
103
- }
127
+ if (createInstance()) clearInterval(checkInkeep);
104
128
  }, 100);
105
129
 
106
- return () => clearInterval(checkInkeep);
107
- }, [config, searchInstance]);
130
+ return () => {
131
+ clearInterval(checkInkeep);
132
+ removeInstance();
133
+ };
134
+ }, [config]);
108
135
 
109
136
  useEffect(() => {
110
137
  if (!searchInstance) return;
@@ -128,10 +155,15 @@ export const inkeepSearchPlugin = (
128
155
  />
129
156
  );
130
157
  },
131
- renderSearch: ({ isOpen, onClose }) => {
158
+ renderSearch: ({ isOpen, onOpen, onClose }) => {
132
159
  return (
133
160
  <ClientOnly>
134
- <InkeepSearch isOpen={isOpen} onClose={onClose} settings={settings} />
161
+ <InkeepSearch
162
+ isOpen={isOpen}
163
+ onOpen={onOpen}
164
+ onClose={onClose}
165
+ settings={settings}
166
+ />
135
167
  </ClientOnly>
136
168
  );
137
169
  },
@@ -10,7 +10,11 @@ const baseSettings: InkeepBaseSettings = {
10
10
  };
11
11
 
12
12
  const modalSettings: InkeepModalSettings = {
13
- shortcutKey: "k",
13
+ // Zudoku renders the search button and owns the ⌘K / Ctrl+K shortcut (see
14
+ // `Search.tsx`), so Inkeep's built-in one is disabled by default. Inkeep
15
+ // binds its shortcut on `document` and calls `stopPropagation()`, which
16
+ // swallows the event before it reaches Zudoku's listener on `window`.
17
+ shortcutKey: null,
14
18
  };
15
19
 
16
20
  const searchSettings: InkeepSearchSettings = {