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.
Files changed (41) hide show
  1. package/README.md +45 -0
  2. package/dist/app-builder/build.js +9 -2
  3. package/dist/app-builder/client-config.js +1 -0
  4. package/dist/config/defaults.d.ts +2 -0
  5. package/dist/config/defaults.js +4 -1
  6. package/dist/config/index.d.ts +1 -1
  7. package/dist/config/index.js +2 -1
  8. package/dist/types/config.d.ts +7 -0
  9. package/package.json +4 -2
  10. package/src/app/components/CopyToClipboard.tsx +27 -26
  11. package/src/app/components/Layout/Header.tsx +4 -12
  12. package/src/app/components/Layout/RuntimeModeBadge.tsx +18 -0
  13. package/src/app/components/McpIntegration/McpConfigDialog.tsx +349 -0
  14. package/src/app/components/McpIntegration/McpIcon.tsx +10 -0
  15. package/src/app/components/Navigation/MobileNav.tsx +3 -1
  16. package/src/app/components/ui/dialog.tsx +73 -0
  17. package/src/app/lib/docs-api/backend.ts +5 -1
  18. package/src/app/lib/docs-api/page-discovery.ts +1 -6
  19. package/src/app/lib/docs-api/page-rendering.ts +21 -1
  20. package/src/app/lib/docs-api/records.ts +25 -10
  21. package/src/app/lib/docs-api/registry.ts +4 -2
  22. package/src/app/lib/docs-api/runtime-config.ts +5 -0
  23. package/src/app/lib/docs-api/search.ts +7 -2
  24. package/src/app/lib/mcp/auth.ts +64 -0
  25. package/src/app/lib/mcp/index.ts +2 -0
  26. package/src/app/lib/mcp/server.ts +343 -0
  27. package/src/app/lib/mcp/shape.ts +595 -0
  28. package/src/app/next.config.mjs +3 -0
  29. package/src/app/pages/[...slug]/index.tsx +16 -30
  30. package/src/app/pages/api/mcp.ts +56 -0
  31. package/src/app/pages/index.tsx +10 -9
  32. package/src/app-builder/build.ts +9 -2
  33. package/src/app-builder/client-config.ts +2 -1
  34. package/src/config/defaults.ts +3 -0
  35. package/src/config/index.ts +1 -1
  36. package/src/types/config.ts +7 -0
  37. package/templates/default/README.md.tpl +10 -0
  38. package/templates/default/handoff.config.js.tpl +6 -3
  39. package/templates/default/handoff.config.ts.tpl +6 -3
  40. package/templates/default/handoff.config.with-examples.js.tpl +6 -3
  41. 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
- /** A page's record + rendered markdown body, or `null` when absent. Read by the catch-all route. */
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
- /** Normalize registry records and baked frontmatter into the page component's metadata contract. */
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. These scan `<workingPath>/pages/` directly (there is no generated page
81
- * summary). Vestigial in practice — workspace nav is baked and the catch-all reads markdown through
82
- * `fetchDocPageMarkdown` — but implemented honestly so the {@link DocsBackend} contract holds.
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 root = workingPagesRoot();
102
- const segments = collectPageSlugs(root).find((candidate) => candidate.join('/') === id);
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 = slug === HOME_PAGE_ID ? HOME_PAGE_PATH : `/${slug}`;
110
- return { ...normalizePageDeclaration(data, { id: slug, routePath, sourcePath }), content };
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 { BakedDefaultPage } from './page-rendering';
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
- return row ? { ...row.record, content: row.body ?? '' } : null;
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
- const query = singleQueryValue(req.query.q)?.trim() ?? '';
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(singleQueryValue(req.query.limit)) } };
119
+ return { request: { query, terms, phrase, limit: parseLimit(rawLimit) } };
115
120
  };