handoff-app 2.0.0-5 → 2.0.0-6
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/README.md +45 -0
- package/dist/app-builder/build.js +9 -2
- package/dist/app-builder/client-config.js +1 -0
- package/dist/config/defaults.d.ts +2 -0
- package/dist/config/defaults.js +4 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +2 -1
- package/dist/types/config.d.ts +7 -0
- package/package.json +4 -2
- package/src/app/components/CopyToClipboard.tsx +27 -26
- package/src/app/components/Layout/Header.tsx +4 -12
- package/src/app/components/Layout/RuntimeModeBadge.tsx +18 -0
- package/src/app/components/McpIntegration/McpConfigDialog.tsx +349 -0
- package/src/app/components/McpIntegration/McpIcon.tsx +10 -0
- package/src/app/components/Navigation/MobileNav.tsx +3 -1
- package/src/app/components/ui/dialog.tsx +73 -0
- package/src/app/lib/docs-api/backend.ts +5 -1
- package/src/app/lib/docs-api/page-discovery.ts +1 -6
- package/src/app/lib/docs-api/page-rendering.ts +21 -1
- package/src/app/lib/docs-api/records.ts +25 -10
- package/src/app/lib/docs-api/registry.ts +4 -2
- package/src/app/lib/docs-api/runtime-config.ts +5 -0
- package/src/app/lib/docs-api/search.ts +7 -2
- package/src/app/lib/mcp/auth.ts +64 -0
- package/src/app/lib/mcp/index.ts +2 -0
- package/src/app/lib/mcp/server.ts +343 -0
- package/src/app/lib/mcp/shape.ts +595 -0
- package/src/app/next.config.mjs +3 -0
- package/src/app/pages/[...slug]/index.tsx +16 -30
- package/src/app/pages/api/mcp.ts +56 -0
- package/src/app/pages/index.tsx +10 -9
- package/src/app-builder/build.ts +9 -2
- package/src/app-builder/client-config.ts +2 -1
- package/src/config/defaults.ts +3 -0
- package/src/config/index.ts +1 -1
- package/src/types/config.ts +7 -0
- package/templates/default/README.md.tpl +10 -0
- package/templates/default/handoff.config.js.tpl +6 -3
- package/templates/default/handoff.config.ts.tpl +6 -3
- package/templates/default/handoff.config.with-examples.js.tpl +6 -3
- package/templates/default/handoff.config.with-examples.ts.tpl +6 -3
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ExternalLink } from 'lucide-react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
|
|
7
|
+
import { cn } from '../../lib/utils';
|
|
8
|
+
import { useConfigContext } from '../context/ConfigContext';
|
|
9
|
+
import CopyToClipboard from '../CopyToClipboard';
|
|
10
|
+
import { RuntimeModeBadge } from '../Layout/RuntimeModeBadge';
|
|
11
|
+
import { Button } from '../ui/button';
|
|
12
|
+
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog';
|
|
13
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
|
14
|
+
import { McpIcon } from './McpIcon';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* How to point a coding agent at this app's `/api/mcp/` endpoint.
|
|
18
|
+
*
|
|
19
|
+
* Availability comes from values baked in at build time (the `env` block in `next.config.mjs`): a
|
|
20
|
+
* static export has no API routes, and `runtime.mcp: false` leaves the endpoint out of any build.
|
|
21
|
+
* Both mean there is nothing to offer, so this UI is gated on the same value the route is and a
|
|
22
|
+
* build without the endpoint shows no affordance rather than a status dot that reds out on a 404.
|
|
23
|
+
*
|
|
24
|
+
* Registry mode puts `/api/mcp/` behind a `registry:read` credential where a workspace leaves it
|
|
25
|
+
* open. Standalone and Vercel registries need no telling apart: both serve the endpoint from their
|
|
26
|
+
* own origin, which is where the URL comes from.
|
|
27
|
+
*/
|
|
28
|
+
const isAvailable = process.env.HANDOFF_MCP_ENABLED !== 'false' && process.env.HANDOFF_BUILD_TARGET !== 'static';
|
|
29
|
+
const isRegistryRuntime = process.env.HANDOFF_RUNTIME_MODE === 'registry';
|
|
30
|
+
|
|
31
|
+
/** Placeholder for the reader's own token. No real credential is ever rendered here. */
|
|
32
|
+
const CREDENTIAL_HEADERS = { Authorization: 'Bearer <your-token>' };
|
|
33
|
+
|
|
34
|
+
interface McpTarget {
|
|
35
|
+
url: string;
|
|
36
|
+
headers?: Record<string, string>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One server entry, as a client config file carries it. */
|
|
40
|
+
interface ServerEntry {
|
|
41
|
+
type?: 'http';
|
|
42
|
+
url: string;
|
|
43
|
+
headers?: Record<string, string>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const httpEntry = ({ url, headers }: McpTarget): ServerEntry => ({ type: 'http', url, ...(headers ? { headers } : {}) });
|
|
47
|
+
|
|
48
|
+
/** Cursor's remote form is `url` + `headers`; it uses `type` for local stdio servers only. */
|
|
49
|
+
const urlEntry = ({ url, headers }: McpTarget): ServerEntry => ({ url, ...(headers ? { headers } : {}) });
|
|
50
|
+
|
|
51
|
+
interface McpClient {
|
|
52
|
+
id: string;
|
|
53
|
+
name: string;
|
|
54
|
+
/** Where the client reads the config from. */
|
|
55
|
+
configPath: string;
|
|
56
|
+
/** How to reach the same config outside the project. */
|
|
57
|
+
note: string;
|
|
58
|
+
/** The whole config file, in the shape this client expects. */
|
|
59
|
+
file: (name: string, target: McpTarget) => Record<string, unknown>;
|
|
60
|
+
/** Equivalent command line, where the client has one. */
|
|
61
|
+
command?: (name: string, target: McpTarget) => string;
|
|
62
|
+
/** One-click install URL. Only offered when the endpoint needs no credential. */
|
|
63
|
+
deeplink?: (name: string, target: McpTarget) => string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const CLIENTS: McpClient[] = [
|
|
67
|
+
{
|
|
68
|
+
id: 'claude-code',
|
|
69
|
+
name: 'Claude Code',
|
|
70
|
+
configPath: '.mcp.json',
|
|
71
|
+
note: 'Shared with the project. `--scope user` instead adds it to every project on this machine.',
|
|
72
|
+
file: (name, target) => ({ mcpServers: { [name]: httpEntry(target) } }),
|
|
73
|
+
command: (name, target) =>
|
|
74
|
+
[
|
|
75
|
+
`claude mcp add --transport http ${name} ${target.url}`,
|
|
76
|
+
...(target.headers ? [` --header "Authorization: ${target.headers.Authorization}"`] : []),
|
|
77
|
+
].join(' \\\n'),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: 'cursor',
|
|
81
|
+
name: 'Cursor',
|
|
82
|
+
configPath: '.cursor/mcp.json',
|
|
83
|
+
note: 'Project scope. `~/.cursor/mcp.json` instead makes it available in every project.',
|
|
84
|
+
file: (name, target) => ({ mcpServers: { [name]: urlEntry(target) } }),
|
|
85
|
+
deeplink: (name, target) =>
|
|
86
|
+
`cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(name)}&config=${btoa(JSON.stringify(urlEntry(target)))}`,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'vscode',
|
|
90
|
+
name: 'VS Code',
|
|
91
|
+
configPath: '.vscode/mcp.json',
|
|
92
|
+
note: 'Workspace scope. "MCP: Open User Configuration" instead makes it available everywhere.',
|
|
93
|
+
file: (name, target) => ({ servers: { [name]: httpEntry(target) } }),
|
|
94
|
+
deeplink: (name, target) => `vscode:mcp/install?${encodeURIComponent(JSON.stringify({ name, ...httpEntry(target) }))}`,
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const slugify = (value: string): string =>
|
|
99
|
+
value
|
|
100
|
+
.toLowerCase()
|
|
101
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
102
|
+
.replace(/^-+|-+$/g, '');
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Name the server after the design system, and mark a registry one. A workspace endpoint serves
|
|
106
|
+
* local build output including unpublished work while a registry serves what was published to it,
|
|
107
|
+
* so both can end up in one config file and the keys must not collide.
|
|
108
|
+
*/
|
|
109
|
+
const useServerName = (): string => {
|
|
110
|
+
const client = useConfigContext().config?.app?.client;
|
|
111
|
+
return ['handoff', client ? slugify(client) : '', isRegistryRuntime ? 'registry' : ''].filter(Boolean).join('-');
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The endpoint as the reader's own browser reached it, so host, port and base path are never guessed.
|
|
116
|
+
*
|
|
117
|
+
* The trailing slash matters: the app sets `trailingSlash: true`, so a POST to `/api/mcp` gets a 308
|
|
118
|
+
* to `/api/mcp/`. Clients that follow it pay two round trips per call and the ones that don't fail
|
|
119
|
+
* outright, so hand out the URL that answers directly.
|
|
120
|
+
*/
|
|
121
|
+
const useMcpEndpoint = (): string => {
|
|
122
|
+
const [url, setUrl] = React.useState('');
|
|
123
|
+
React.useEffect(() => {
|
|
124
|
+
setUrl(`${window.location.origin}${process.env.HANDOFF_APP_BASE_PATH ?? ''}/api/mcp/`);
|
|
125
|
+
}, []);
|
|
126
|
+
return url;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** What a probe of the endpoint found. */
|
|
130
|
+
type EndpointStatus = 'checking' | 'live' | 'unreachable';
|
|
131
|
+
|
|
132
|
+
// One probe per hard load, cached at module level, so soft navigation does not re-ask and a refresh
|
|
133
|
+
// does.
|
|
134
|
+
let cachedStatus: EndpointStatus | null = null;
|
|
135
|
+
let inFlight: Promise<EndpointStatus> | null = null;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Statuses that prove the route is being served. A GET is the cheapest question to ask: the handler
|
|
139
|
+
* answers 405 without ever building an MCP server, and a registry answers 401 ahead of that because
|
|
140
|
+
* the credential check runs first. Anything else, 404 and 5xx included, means it is not there.
|
|
141
|
+
*/
|
|
142
|
+
const LIVE_STATUSES = new Set([200, 401, 403, 405]);
|
|
143
|
+
|
|
144
|
+
const probeEndpoint = (url: string): Promise<EndpointStatus> => {
|
|
145
|
+
if (cachedStatus) return Promise.resolve(cachedStatus);
|
|
146
|
+
if (inFlight) return inFlight;
|
|
147
|
+
inFlight = fetch(url, { method: 'GET' })
|
|
148
|
+
.then((res): EndpointStatus => (LIVE_STATUSES.has(res.status) ? 'live' : 'unreachable'))
|
|
149
|
+
.catch((): EndpointStatus => 'unreachable')
|
|
150
|
+
.then((status) => {
|
|
151
|
+
cachedStatus = status;
|
|
152
|
+
return status;
|
|
153
|
+
})
|
|
154
|
+
.finally(() => {
|
|
155
|
+
inFlight = null;
|
|
156
|
+
});
|
|
157
|
+
return inFlight;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const useEndpointStatus = (url: string): EndpointStatus => {
|
|
161
|
+
const [status, setStatus] = React.useState<EndpointStatus>(cachedStatus ?? 'checking');
|
|
162
|
+
|
|
163
|
+
React.useEffect(() => {
|
|
164
|
+
if (!url || cachedStatus) return;
|
|
165
|
+
let active = true;
|
|
166
|
+
void probeEndpoint(url).then((next) => {
|
|
167
|
+
if (active) setStatus(next);
|
|
168
|
+
});
|
|
169
|
+
return () => {
|
|
170
|
+
active = false;
|
|
171
|
+
};
|
|
172
|
+
}, [url]);
|
|
173
|
+
|
|
174
|
+
return status;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const DOT: Record<EndpointStatus, { className: string; label: string }> = {
|
|
178
|
+
checking: { className: 'bg-muted-foreground/40', label: 'Checking the MCP endpoint' },
|
|
179
|
+
live: { className: 'bg-green-500', label: 'MCP endpoint is live' },
|
|
180
|
+
unreachable: { className: 'bg-red-500', label: 'MCP endpoint is not responding' },
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const StatusDot: React.FC<{ status: EndpointStatus; className?: string }> = ({ status, className }) => (
|
|
184
|
+
<span role="status" aria-label={DOT[status].label} title={DOT[status].label} className={cn('size-1.5 rounded-full', DOT[status].className, className)} />
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const Snippet: React.FC<{ label: string; value: string; compact?: boolean }> = ({ label, value, compact }) => (
|
|
188
|
+
<div className="min-w-0 space-y-1.5">
|
|
189
|
+
<div className="flex items-center justify-between gap-2">
|
|
190
|
+
<code className="truncate rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">{label}</code>
|
|
191
|
+
<CopyToClipboard value={value} tooltip={`Copy ${label}`} showTooltip={false} className="relative size-7 shrink-0" />
|
|
192
|
+
</div>
|
|
193
|
+
<pre
|
|
194
|
+
className={cn(
|
|
195
|
+
'overflow-auto rounded-md border bg-muted/50 p-3 leading-relaxed',
|
|
196
|
+
compact ? 'max-h-48 text-[10px]' : 'max-h-64 text-xs'
|
|
197
|
+
)}
|
|
198
|
+
>
|
|
199
|
+
<code>{value}</code>
|
|
200
|
+
</pre>
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const ClientConfig: React.FC<{ client: McpClient; name: string; target: McpTarget; compact?: boolean }> = ({
|
|
205
|
+
client,
|
|
206
|
+
name,
|
|
207
|
+
target,
|
|
208
|
+
compact,
|
|
209
|
+
}) => {
|
|
210
|
+
// A one-click install would write the `<your-token>` placeholder verbatim and register a server
|
|
211
|
+
// that cannot connect, so a registry gets the snippet and the token pointer instead.
|
|
212
|
+
const deeplink = isRegistryRuntime ? undefined : client.deeplink?.(name, target);
|
|
213
|
+
const command = client.command?.(name, target);
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<div className="min-w-0 space-y-3">
|
|
217
|
+
{deeplink && (
|
|
218
|
+
<Button asChild size="sm" className="w-full gap-2">
|
|
219
|
+
<a href={deeplink}>
|
|
220
|
+
<ExternalLink aria-hidden="true" />
|
|
221
|
+
Install in {client.name}
|
|
222
|
+
</a>
|
|
223
|
+
</Button>
|
|
224
|
+
)}
|
|
225
|
+
{command && <Snippet label="command line" value={command} compact={compact} />}
|
|
226
|
+
<Snippet label={client.configPath} value={JSON.stringify(client.file(name, target), null, 2)} compact={compact} />
|
|
227
|
+
<p className="text-xs text-muted-foreground">{client.note}</p>
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const Endpoint: React.FC<{ url: string; status: EndpointStatus }> = ({ url, status }) => (
|
|
233
|
+
<div className="min-w-0 space-y-1.5">
|
|
234
|
+
<div className="flex flex-wrap items-center justify-between gap-x-2">
|
|
235
|
+
<p className="flex items-center gap-1.5 text-xs font-medium">
|
|
236
|
+
<StatusDot status={status} />
|
|
237
|
+
Endpoint
|
|
238
|
+
</p>
|
|
239
|
+
{/* Saying so up front heads off opening the URL in a browser and getting a 405. */}
|
|
240
|
+
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">POST · Streamable HTTP</p>
|
|
241
|
+
</div>
|
|
242
|
+
<div className="flex items-stretch overflow-hidden rounded-md border">
|
|
243
|
+
<code className="flex flex-1 items-center truncate px-3 py-2 font-mono text-xs text-gray-700 dark:text-gray-300">{url}</code>
|
|
244
|
+
<CopyToClipboard
|
|
245
|
+
value={url}
|
|
246
|
+
tooltip="Copy endpoint"
|
|
247
|
+
showTooltip={false}
|
|
248
|
+
className="relative h-auto w-9 shrink-0 self-stretch rounded-none border-0 border-l shadow-none"
|
|
249
|
+
/>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const Credential: React.FC = () => (
|
|
255
|
+
<p className="border-t pt-3 text-xs text-muted-foreground">
|
|
256
|
+
Needs an access token — read-only access is enough.{' '}
|
|
257
|
+
<Link href="/account/tokens" className="font-medium text-foreground underline">
|
|
258
|
+
Create one in Account settings
|
|
259
|
+
</Link>
|
|
260
|
+
.
|
|
261
|
+
</p>
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
/** Endpoint, per-client config and credential guidance. Shared by the dialog and the mobile nav. */
|
|
265
|
+
const ConnectionDetails: React.FC<{ url: string; status: EndpointStatus; compact?: boolean }> = ({ url, status, compact }) => {
|
|
266
|
+
const name = useServerName();
|
|
267
|
+
const target: McpTarget = { url, headers: isRegistryRuntime ? CREDENTIAL_HEADERS : undefined };
|
|
268
|
+
|
|
269
|
+
return (
|
|
270
|
+
<div className="min-w-0 space-y-4">
|
|
271
|
+
<Endpoint url={url} status={status} />
|
|
272
|
+
<Tabs defaultValue={CLIENTS[0].id} className="min-w-0">
|
|
273
|
+
<TabsList className="w-full">
|
|
274
|
+
{CLIENTS.map((client) => (
|
|
275
|
+
<TabsTrigger key={client.id} value={client.id} className="flex-1 text-xs">
|
|
276
|
+
{client.name}
|
|
277
|
+
</TabsTrigger>
|
|
278
|
+
))}
|
|
279
|
+
</TabsList>
|
|
280
|
+
{CLIENTS.map((client) => (
|
|
281
|
+
<TabsContent key={client.id} value={client.id} className="min-w-0">
|
|
282
|
+
<ClientConfig client={client} name={name} target={target} compact={compact} />
|
|
283
|
+
</TabsContent>
|
|
284
|
+
))}
|
|
285
|
+
</Tabs>
|
|
286
|
+
{isRegistryRuntime && <Credential />}
|
|
287
|
+
</div>
|
|
288
|
+
);
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const mode = isRegistryRuntime ? 'registry' : 'workspace';
|
|
292
|
+
|
|
293
|
+
const DESCRIPTION =
|
|
294
|
+
"This app serves an MCP endpoint, so a coding agent can read the components and tokens that already exist instead of inventing markup and values.";
|
|
295
|
+
|
|
296
|
+
export function McpConfigDialog() {
|
|
297
|
+
return isAvailable ? <McpDialog /> : null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const McpDialog: React.FC = () => {
|
|
301
|
+
const url = useMcpEndpoint();
|
|
302
|
+
const status = useEndpointStatus(url);
|
|
303
|
+
|
|
304
|
+
return (
|
|
305
|
+
<Dialog>
|
|
306
|
+
<DialogTrigger asChild>
|
|
307
|
+
<Button variant="ghost" size="icon" className="relative" title="Connect a coding agent">
|
|
308
|
+
<McpIcon className="h-[1.2rem] w-[1.2rem]" />
|
|
309
|
+
<StatusDot status={status} className="absolute right-1.5 top-1.5" />
|
|
310
|
+
<span className="sr-only">Connect a coding agent</span>
|
|
311
|
+
</Button>
|
|
312
|
+
</DialogTrigger>
|
|
313
|
+
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-[560px]">
|
|
314
|
+
<DialogHeader>
|
|
315
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
316
|
+
<DialogTitle>Connect a coding agent</DialogTitle>
|
|
317
|
+
<RuntimeModeBadge mode={mode} />
|
|
318
|
+
</div>
|
|
319
|
+
<DialogDescription>{DESCRIPTION}</DialogDescription>
|
|
320
|
+
</DialogHeader>
|
|
321
|
+
<ConnectionDetails url={url} status={status} />
|
|
322
|
+
</DialogContent>
|
|
323
|
+
</Dialog>
|
|
324
|
+
);
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Inline variant for the mobile nav, which is itself a sheet, so a nested dialog would sit on top of
|
|
329
|
+
* it. It owns its divider, so a build without the endpoint renders nothing and leaves no stray rule.
|
|
330
|
+
*/
|
|
331
|
+
export function McpConfigMobile() {
|
|
332
|
+
return isAvailable ? <McpMobile /> : null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const McpMobile: React.FC = () => {
|
|
336
|
+
const url = useMcpEndpoint();
|
|
337
|
+
const status = useEndpointStatus(url);
|
|
338
|
+
|
|
339
|
+
return (
|
|
340
|
+
<div className="mt-4 space-y-3 border-t pt-4">
|
|
341
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
342
|
+
<McpIcon className="h-4 w-4" />
|
|
343
|
+
<span className="text-sm font-medium">Connect a coding agent</span>
|
|
344
|
+
<RuntimeModeBadge mode={mode} />
|
|
345
|
+
</div>
|
|
346
|
+
<ConnectionDetails url={url} status={status} compact />
|
|
347
|
+
</div>
|
|
348
|
+
);
|
|
349
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Model Context Protocol mark. Path from Simple Icons (`modelcontextprotocol`), CC0-1.0.
|
|
3
|
+
*
|
|
4
|
+
* Unlike the stroked lucide icons around it this is a filled path, so it takes `fill`, not `stroke`.
|
|
5
|
+
*/
|
|
6
|
+
export const McpIcon: React.FC<{ className?: string }> = ({ className }) => (
|
|
7
|
+
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
|
|
8
|
+
<path d="M13.85 0a4.16 4.16 0 0 0-2.95 1.217L1.456 10.66a.835.835 0 0 0 0 1.18.835.835 0 0 0 1.18 0l9.442-9.442a2.49 2.49 0 0 1 3.541 0 2.49 2.49 0 0 1 0 3.541L8.59 12.97l-.1.1a.835.835 0 0 0 0 1.18.835.835 0 0 0 1.18 0l.1-.098 7.03-7.034a2.49 2.49 0 0 1 3.542 0l.049.05a2.49 2.49 0 0 1 0 3.54l-8.54 8.54a1.96 1.96 0 0 0 0 2.755l1.753 1.753a.835.835 0 0 0 1.18 0 .835.835 0 0 0 0-1.18l-1.753-1.753a.266.266 0 0 1 0-.394l8.54-8.54a4.185 4.185 0 0 0 0-5.9l-.05-.05a4.16 4.16 0 0 0-2.95-1.218c-.2 0-.401.02-.6.048a4.17 4.17 0 0 0-1.17-3.552A4.16 4.16 0 0 0 13.85 0m0 3.333a.84.84 0 0 0-.59.245L6.275 10.56a4.186 4.186 0 0 0 0 5.902 4.186 4.186 0 0 0 5.902 0L19.16 9.48a.835.835 0 0 0 0-1.18.835.835 0 0 0-1.18 0l-6.985 6.984a2.49 2.49 0 0 1-3.54 0 2.49 2.49 0 0 1 0-3.54l6.983-6.985a.835.835 0 0 0 0-1.18.84.84 0 0 0-.59-.245" />
|
|
9
|
+
</svg>
|
|
10
|
+
);
|
|
@@ -3,6 +3,7 @@ import { Menu, Monitor, Moon, Sun, X } from 'lucide-react';
|
|
|
3
3
|
import { useTheme } from 'next-themes';
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import { useRouter } from 'next/router';
|
|
6
|
+
import { McpConfigMobile } from '../../components/McpIntegration/McpConfigDialog';
|
|
6
7
|
import { Button } from '../../components/ui/button';
|
|
7
8
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '../../components/ui/sheet';
|
|
8
9
|
import { cn } from '../../lib/utils';
|
|
@@ -37,7 +38,7 @@ export function MobileNav() {
|
|
|
37
38
|
<Menu />
|
|
38
39
|
</Button>
|
|
39
40
|
</SheetTrigger>
|
|
40
|
-
<SheetContent side="right" className="w-full max-w-[300px] [&>button:first-of-type]:hidden">
|
|
41
|
+
<SheetContent side="right" className="w-full max-w-[300px] overflow-y-auto [&>button:first-of-type]:hidden">
|
|
41
42
|
<SheetHeader className="flex flex-row items-center justify-between">
|
|
42
43
|
<SheetTitle>
|
|
43
44
|
<Link href="/">
|
|
@@ -69,6 +70,7 @@ export function MobileNav() {
|
|
|
69
70
|
</Link>
|
|
70
71
|
);
|
|
71
72
|
})}
|
|
73
|
+
<McpConfigMobile />
|
|
72
74
|
<div className="mt-4 border-t pt-4">
|
|
73
75
|
<Button variant="ghost" className="w-full justify-start font-normal" onClick={toggleTheme}>
|
|
74
76
|
{theme === 'light' && (
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
4
|
+
import { X } from 'lucide-react';
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
|
|
7
|
+
import { cn } from '../../lib/utils';
|
|
8
|
+
|
|
9
|
+
const Dialog = DialogPrimitive.Root;
|
|
10
|
+
|
|
11
|
+
const DialogTrigger = DialogPrimitive.Trigger;
|
|
12
|
+
|
|
13
|
+
const DialogOverlay = React.forwardRef<
|
|
14
|
+
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
|
15
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
|
16
|
+
>(({ className, ...props }, ref) => (
|
|
17
|
+
<DialogPrimitive.Overlay
|
|
18
|
+
ref={ref}
|
|
19
|
+
className={cn(
|
|
20
|
+
'fixed inset-0 z-50 bg-gray-200/50 backdrop-blur-xs data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
21
|
+
className
|
|
22
|
+
)}
|
|
23
|
+
{...props}
|
|
24
|
+
/>
|
|
25
|
+
));
|
|
26
|
+
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
|
27
|
+
|
|
28
|
+
const DialogContent = React.forwardRef<
|
|
29
|
+
React.ElementRef<typeof DialogPrimitive.Content>,
|
|
30
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
|
31
|
+
>(({ className, children, ...props }, ref) => (
|
|
32
|
+
<DialogPrimitive.Portal>
|
|
33
|
+
<DialogOverlay />
|
|
34
|
+
<DialogPrimitive.Content
|
|
35
|
+
ref={ref}
|
|
36
|
+
className={cn(
|
|
37
|
+
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
|
|
38
|
+
className
|
|
39
|
+
)}
|
|
40
|
+
{...props}
|
|
41
|
+
>
|
|
42
|
+
{children}
|
|
43
|
+
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2">
|
|
44
|
+
<X className="h-4 w-4" />
|
|
45
|
+
<span className="sr-only">Close</span>
|
|
46
|
+
</DialogPrimitive.Close>
|
|
47
|
+
</DialogPrimitive.Content>
|
|
48
|
+
</DialogPrimitive.Portal>
|
|
49
|
+
));
|
|
50
|
+
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
|
51
|
+
|
|
52
|
+
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
|
53
|
+
<div className={cn('flex flex-col space-y-1.5', className)} {...props} />
|
|
54
|
+
);
|
|
55
|
+
DialogHeader.displayName = 'DialogHeader';
|
|
56
|
+
|
|
57
|
+
const DialogTitle = React.forwardRef<
|
|
58
|
+
React.ElementRef<typeof DialogPrimitive.Title>,
|
|
59
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
|
60
|
+
>(({ className, ...props }, ref) => (
|
|
61
|
+
<DialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
|
|
62
|
+
));
|
|
63
|
+
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
|
64
|
+
|
|
65
|
+
const DialogDescription = React.forwardRef<
|
|
66
|
+
React.ElementRef<typeof DialogPrimitive.Description>,
|
|
67
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
|
68
|
+
>(({ className, ...props }, ref) => (
|
|
69
|
+
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
|
70
|
+
));
|
|
71
|
+
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
|
72
|
+
|
|
73
|
+
export { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger };
|
|
@@ -65,7 +65,11 @@ export interface DocsBackend {
|
|
|
65
65
|
listPages(): Promise<PageListObject[]>;
|
|
66
66
|
getComponentDetail(id: string): Promise<ComponentDetail | null>;
|
|
67
67
|
getPatternDetail(id: string): Promise<PatternDetail | null>;
|
|
68
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* The effective page for an id: its record plus the markdown body. A package default resolves
|
|
70
|
+
* here like a published or project page, so a caller can read every route the site serves. Same
|
|
71
|
+
* overlay rule as {@link DocsBackend.searchPages}.
|
|
72
|
+
*/
|
|
69
73
|
getPageDetail(id: string): Promise<PageDetail | null>;
|
|
70
74
|
/**
|
|
71
75
|
* Search the effective page set and return ranked, display-ready results. Project pages replace
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type PageSearchResult,
|
|
16
16
|
type SearchablePage,
|
|
17
17
|
} from './page-search';
|
|
18
|
+
import { pageRoots } from './records';
|
|
18
19
|
import { MAX_SEARCH_CANDIDATES, truncateSearchBody, type SearchResponse } from './search';
|
|
19
20
|
|
|
20
21
|
/**
|
|
@@ -38,12 +39,6 @@ interface CachedPage extends DiscoveredPage {
|
|
|
38
39
|
|
|
39
40
|
const discoveryCache = new Map<string, CachedPage>();
|
|
40
41
|
|
|
41
|
-
/** Project pages replace package defaults with the same ID. */
|
|
42
|
-
const pageRoots = (): string[] => [
|
|
43
|
-
path.resolve(process.env.HANDOFF_MODULE_PATH ?? '', 'config', 'docs'),
|
|
44
|
-
path.resolve(process.env.HANDOFF_WORKING_PATH ?? '', 'pages'),
|
|
45
|
-
];
|
|
46
|
-
|
|
47
42
|
/** Reuse a cached record when the file size and modification time have not changed. */
|
|
48
43
|
const readRecord = (id: string, absolutePath: string): CachedPage | null => {
|
|
49
44
|
let stats: fs.Stats;
|
|
@@ -1,9 +1,29 @@
|
|
|
1
|
+
import { normalizePageDeclaration } from '@handoff/config/normalizers/page';
|
|
2
|
+
import { HOME_PAGE_ID, HOME_PAGE_PATH } from '@handoff/registry/content-kinds';
|
|
3
|
+
import type { PageDetail } from './records';
|
|
4
|
+
// A static import puts the package defaults in every bundle. A deployed registry function cannot
|
|
5
|
+
// read the build machine's `config/docs` directory.
|
|
6
|
+
import defaultPages from '../../generated/default-pages.json';
|
|
7
|
+
|
|
1
8
|
/** Registry pages use short-lived ISR so independent instances converge after a publish. */
|
|
2
9
|
export const REGISTRY_PAGE_REVALIDATE_SECONDS = 1;
|
|
3
10
|
|
|
4
11
|
export type BakedDefaultPage = { metadata: Record<string, unknown>; content: string };
|
|
5
12
|
|
|
6
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* The packaged default for a page id. Shared by the registry backing and by the registry build,
|
|
15
|
+
* which must resolve the home page without a database connection.
|
|
16
|
+
*/
|
|
17
|
+
export const defaultPageDetail = (id: string): PageDetail | null => {
|
|
18
|
+
const page = (defaultPages as Record<string, BakedDefaultPage>)[id];
|
|
19
|
+
if (!page) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
const routePath = id === HOME_PAGE_ID ? HOME_PAGE_PATH : `/${id}`;
|
|
23
|
+
return { ...normalizePageDeclaration(page.metadata, { id, routePath }), content: page.content };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Normalize a page record into the page component's metadata contract. */
|
|
7
27
|
export const documentationMetadata = (source: Record<string, unknown>) => {
|
|
8
28
|
const value = (key: string): string => (typeof source[key] === 'string' ? (source[key] as string) : '');
|
|
9
29
|
const title = value('title');
|
|
@@ -77,15 +77,33 @@ export const getPatternDetail = (id: string): PatternDetail | null => {
|
|
|
77
77
|
};
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
|
-
* Workspace-mode page reads
|
|
81
|
-
*
|
|
82
|
-
* `
|
|
80
|
+
* Workspace-mode page reads, scanned from the filesystem because there is no generated page summary.
|
|
81
|
+
* `getPageDetail` resolves the effective page, so it answers for every route the site serves.
|
|
82
|
+
* `listPages` stays project-only: it feeds the nav resolver, which already gets the package defaults
|
|
83
|
+
* from the baked menu shell.
|
|
83
84
|
*/
|
|
84
85
|
const workingPagesRoot = (): string => path.resolve(process.env.HANDOFF_WORKING_PATH ?? '', 'pages');
|
|
85
86
|
|
|
87
|
+
/** Effective page roots in precedence order: a project page replaces a package default of the same id. */
|
|
88
|
+
export const pageRoots = (): string[] => [path.resolve(process.env.HANDOFF_MODULE_PATH ?? '', 'config', 'docs'), workingPagesRoot()];
|
|
89
|
+
|
|
86
90
|
/** Collect page slugs, including the root home page but excluding nested section index files. */
|
|
87
91
|
const collectPageSlugs = (root: string): string[][] => collectPageSlugSegments(root, { includeRootIndex: true });
|
|
88
92
|
|
|
93
|
+
/**
|
|
94
|
+
* The markdown file for a page id. The id is matched against the collected slugs rather than joined
|
|
95
|
+
* onto a root, so a request cannot reach outside them.
|
|
96
|
+
*/
|
|
97
|
+
const resolvePageSource = (id: string): string | null => {
|
|
98
|
+
let sourcePath: string | null = null;
|
|
99
|
+
for (const root of pageRoots()) {
|
|
100
|
+
if (collectPageSlugs(root).some((segments) => segments.join('/') === id)) {
|
|
101
|
+
sourcePath = path.resolve(root, `${id}.md`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return sourcePath;
|
|
105
|
+
};
|
|
106
|
+
|
|
89
107
|
export const listPages = (): PageListObject[] => {
|
|
90
108
|
const root = workingPagesRoot();
|
|
91
109
|
return collectPageSlugs(root).map((segments) => {
|
|
@@ -98,16 +116,13 @@ export const listPages = (): PageListObject[] => {
|
|
|
98
116
|
};
|
|
99
117
|
|
|
100
118
|
export const getPageDetail = (id: string): PageDetail | null => {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
if (!segments) {
|
|
119
|
+
const sourcePath = resolvePageSource(id);
|
|
120
|
+
if (!sourcePath) {
|
|
104
121
|
return null;
|
|
105
122
|
}
|
|
106
|
-
const slug = segments.join('/');
|
|
107
|
-
const sourcePath = path.resolve(root, `${slug}.md`);
|
|
108
123
|
const { data, content } = parseMarkdown(fs.readFileSync(sourcePath, 'utf8'));
|
|
109
|
-
const routePath =
|
|
110
|
-
return { ...normalizePageDeclaration(data, { id
|
|
124
|
+
const routePath = id === HOME_PAGE_ID ? HOME_PAGE_PATH : `/${id}`;
|
|
125
|
+
return { ...normalizePageDeclaration(data, { id, routePath, sourcePath }), content };
|
|
111
126
|
};
|
|
112
127
|
|
|
113
128
|
/**
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
type PageSearchResult,
|
|
19
19
|
type SearchablePage,
|
|
20
20
|
} from './page-search';
|
|
21
|
-
import type
|
|
21
|
+
import { defaultPageDetail, type BakedDefaultPage } from './page-rendering';
|
|
22
22
|
import { MAX_SEARCH_BODY_LENGTH, MAX_SEARCH_CANDIDATES, truncateSearchBody, type SearchResponse } from './search';
|
|
23
23
|
import { getRegistryConnection } from '../registry-connection';
|
|
24
24
|
import { getAssetStorageAdapter } from '../asset-storage';
|
|
@@ -181,7 +181,9 @@ export const createRegistryDocsBackend = async (): Promise<DocsBackend> => {
|
|
|
181
181
|
.from(pages)
|
|
182
182
|
.leftJoin(pageFiles, eq(pageFiles.pageId, pages.id))
|
|
183
183
|
.where(eq(pages.id, id));
|
|
184
|
-
|
|
184
|
+
// A package default the project never published is still served at its route, so it resolves
|
|
185
|
+
// here too. Publishing the same id replaces it, as it does in search.
|
|
186
|
+
return row ? { ...row.record, content: row.body ?? '' } : defaultPageDetail(id);
|
|
185
187
|
},
|
|
186
188
|
async searchPages(request: PageSearchRequest) {
|
|
187
189
|
return searchRegistryPages(db, request);
|
|
@@ -33,6 +33,8 @@ export interface ServerRegistryRuntimeConfig {
|
|
|
33
33
|
/** Resolved server-side runtime configuration for the docs read API. */
|
|
34
34
|
export interface ServerRuntimeConfig {
|
|
35
35
|
mode: RuntimeMode;
|
|
36
|
+
/** Whether this build serves `/api/mcp/`. Config-only, baked at build time. */
|
|
37
|
+
mcp: boolean;
|
|
36
38
|
registry: ServerRegistryRuntimeConfig;
|
|
37
39
|
/**
|
|
38
40
|
* Asset storage selection for registry mode (provider + non-secret options + env-var names). Secret
|
|
@@ -46,6 +48,7 @@ let cached: ServerRuntimeConfig | null = null;
|
|
|
46
48
|
/** Safe defaults: workspace mode with the default Postgres driver + `DATABASE_URL` env var. */
|
|
47
49
|
const defaults = (): ServerRuntimeConfig => ({
|
|
48
50
|
mode: 'workspace',
|
|
51
|
+
mcp: true,
|
|
49
52
|
registry: {
|
|
50
53
|
driver: DEFAULT_REGISTRY_DRIVER,
|
|
51
54
|
databaseUrlEnv: DEFAULT_DATABASE_URL_ENV,
|
|
@@ -103,6 +106,7 @@ const fromEnv = (): ServerRuntimeConfig | null => {
|
|
|
103
106
|
const databaseUrlEnv = process.env.HANDOFF_REGISTRY_DATABASE_URL_ENV?.trim() || DEFAULT_DATABASE_URL_ENV;
|
|
104
107
|
return {
|
|
105
108
|
mode: mode === 'registry' ? 'registry' : 'workspace',
|
|
109
|
+
mcp: process.env.HANDOFF_MCP_ENABLED?.trim() !== 'false',
|
|
106
110
|
registry: {
|
|
107
111
|
driver: process.env.HANDOFF_REGISTRY_DRIVER?.trim() === 'neon' ? 'neon' : 'pg',
|
|
108
112
|
databaseUrlEnv,
|
|
@@ -142,6 +146,7 @@ export const getServerRuntimeConfig = (): ServerRuntimeConfig => {
|
|
|
142
146
|
: { adapter: DEFAULT_ASSET_STORAGE_ADAPTER };
|
|
143
147
|
cached = {
|
|
144
148
|
mode: parsed?.mode === 'registry' ? 'registry' : 'workspace',
|
|
149
|
+
mcp: parsed?.mcp !== false,
|
|
145
150
|
registry: {
|
|
146
151
|
driver: parsed?.registry?.driver === 'neon' ? 'neon' : 'pg',
|
|
147
152
|
databaseUrlEnv,
|
|
@@ -96,7 +96,12 @@ const parseLimit = (raw: string | undefined): number => {
|
|
|
96
96
|
* of rejecting the full query. The request fails only when it contains no usable term.
|
|
97
97
|
*/
|
|
98
98
|
export const parseSearchRequest = (req: NextApiRequest): { request: SearchRequest } | { error: string } => {
|
|
99
|
-
|
|
99
|
+
return createSearchRequest(singleQueryValue(req.query.q), singleQueryValue(req.query.limit));
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Shared validation and normalization for HTTP and MCP search callers. */
|
|
103
|
+
export const createSearchRequest = (rawQuery?: string, rawLimit?: string): { request: SearchRequest } | { error: string } => {
|
|
104
|
+
const query = rawQuery?.trim() ?? '';
|
|
100
105
|
if (!query) {
|
|
101
106
|
return { error: 'Provide a non-empty `q` search parameter.' };
|
|
102
107
|
}
|
|
@@ -111,5 +116,5 @@ export const parseSearchRequest = (req: NextApiRequest): { request: SearchReques
|
|
|
111
116
|
if (terms.length > MAX_TERMS) {
|
|
112
117
|
return { error: `Search query must contain no more than ${MAX_TERMS} distinct terms.` };
|
|
113
118
|
}
|
|
114
|
-
return { request: { query, terms, phrase, limit: parseLimit(
|
|
119
|
+
return { request: { query, terms, phrase, limit: parseLimit(rawLimit) } };
|
|
115
120
|
};
|