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 +5 -0
- package/dist/declarations/lib/components/Search.d.ts +2 -0
- package/dist/declarations/lib/core/plugins.d.ts +1 -0
- package/dist/declarations/lib/plugins/openapi/graphql/gql.d.ts +1 -1
- package/dist/declarations/lib/plugins/openapi/graphql/graphql.d.ts +1 -0
- package/dist/declarations/lib/plugins/openapi/mcp-configs.d.ts +1 -1
- package/docs/configuration/search.md +5 -0
- package/docs/guides/mcp-servers.md +15 -0
- package/docs/openapi-extensions/x-mcp-server.md +30 -0
- package/package.json +1 -1
- package/src/lib/components/Header.tsx +93 -89
- package/src/lib/components/Search.tsx +53 -15
- package/src/lib/core/plugins.ts +1 -0
- package/src/lib/oas/graphql/index.ts +6 -0
- package/src/lib/plugins/openapi/MCPEndpoint.tsx +1 -1
- package/src/lib/plugins/openapi/graphql/gql.ts +3 -3
- package/src/lib/plugins/openapi/graphql/graphql.ts +2 -0
- package/src/lib/plugins/openapi/index.tsx +1 -0
- package/src/lib/plugins/openapi/mcp-configs.ts +32 -2
- package/src/lib/plugins/openapi/util/createNavigationCategory.tsx +10 -6
- package/src/lib/plugins/search-inkeep/index.tsx +51 -19
- package/src/lib/plugins/search-inkeep/inkeep.ts +5 -1
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
|
}),
|
|
@@ -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;
|
|
@@ -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
|
@@ -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
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
<
|
|
161
|
-
|
|
162
|
-
<div className="flex items-center
|
|
163
|
-
<
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
<
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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 === "
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
</
|
|
250
|
-
</
|
|
253
|
+
</header>
|
|
254
|
+
</SearchProvider>
|
|
251
255
|
);
|
|
252
256
|
});
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { SearchIcon } from "lucide-react";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
};
|
|
33
|
-
}, [isOpen]);
|
|
49
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
50
|
+
}, [isOpen, searchPlugin]);
|
|
34
51
|
|
|
35
|
-
const
|
|
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 (!
|
|
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
|
};
|
package/src/lib/core/plugins.ts
CHANGED
|
@@ -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 {
|
|
@@ -187,8 +187,38 @@ export const getMcpServerName = (
|
|
|
187
187
|
return (data?.name as string) ?? summary ?? "mcp-server";
|
|
188
188
|
};
|
|
189
189
|
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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 (
|
|
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 () =>
|
|
107
|
-
|
|
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
|
|
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
|
-
|
|
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 = {
|