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
package/README.md CHANGED
@@ -385,6 +385,51 @@ runtime: {
385
385
  },
386
386
  ```
387
387
 
388
+ ## MCP
389
+
390
+ The documentation app serves a Model Context Protocol endpoint at `/api/mcp/`, so
391
+ coding agents can look up the pages, components and tokens that already exist instead
392
+ of inventing markup and values. It is stateless Streamable HTTP over `POST`;
393
+ there is nothing to start and no extra port. Keep the trailing slash: the app
394
+ sets `trailingSlash: true`, so `/api/mcp` answers with a 308 redirect.
395
+
396
+ | Target | Endpoint | Credential |
397
+ | --- | --- | --- |
398
+ | Workspace (`dev` / `start`) | `http://localhost:3000/api/mcp/` | none, as with `/api/docs/*` |
399
+ | Registry (standalone or Vercel) | `<registry url>/api/mcp/` | an access token, read-only is enough |
400
+ | Static export | not available, a static site has no API routes | n/a |
401
+
402
+ Registry tokens come from `handoff-app login` or Account settings in the
403
+ registry itself, and are the same credentials the rest of the registry API takes.
404
+
405
+ The endpoint is served unless a project turns it off:
406
+
407
+ ```ts
408
+ runtime: {
409
+ mcp: false,
410
+ },
411
+ ```
412
+
413
+ Like `runtime.mode`, this is config-only and baked at build time, so changing it
414
+ in a deployed registry means a rebuild. A disabled build answers 404 on the route,
415
+ drops the MCP SDK from the packaged bundle, and shows no connect affordance at all
416
+ rather than one that fails.
417
+
418
+ The documentation app carries its own endpoint and a ready-made client config
419
+ for Claude Code, Cursor and VS Code behind the plug icon in the header. By hand:
420
+
421
+ ```json
422
+ {
423
+ "mcpServers": {
424
+ "handoff": {
425
+ "type": "http",
426
+ "url": "https://registry.example.com/api/mcp/",
427
+ "headers": { "Authorization": "Bearer hnd_..." }
428
+ }
429
+ }
430
+ }
431
+ ```
432
+
388
433
  ## Configuration
389
434
 
390
435
  Configuration is read from `handoff.config.ts`, `.js`, `.cjs`, or `.json`, in
@@ -40,6 +40,7 @@ const p = __importStar(require("@clack/prompts"));
40
40
  const cross_spawn_1 = __importDefault(require("cross-spawn"));
41
41
  const fs_extra_1 = __importDefault(require("fs-extra"));
42
42
  const path_1 = __importDefault(require("path"));
43
+ const config_1 = require("../config");
43
44
  const components_1 = require("../pipeline/components");
44
45
  const patterns_1 = require("../pipeline/patterns");
45
46
  const resolve_1 = require("../registry/asset-storage/resolve");
@@ -216,6 +217,7 @@ const initializeProjectApp = (handoff_1, ...args_1) => __awaiter(void 0, [handof
216
217
  // Resolved runtime mode + registry connection inputs (names only) baked into the Next bundle so the
217
218
  // deployed registry app resolves its mode/DB env-var name without any build-machine filesystem.
218
219
  const escapedRuntimeMode = escapeForSingleQuotedJsString(runtimeMode);
220
+ const escapedMcpEnabled = escapeForSingleQuotedJsString(String((0, config_1.isMcpEnabled)(handoff.config)));
219
221
  const escapedRegistryDriver = escapeForSingleQuotedJsString((0, driver_1.resolveRegistryDriver)(handoff.config));
220
222
  const escapedDatabaseUrlEnv = escapeForSingleQuotedJsString((0, driver_1.resolveDatabaseUrlEnv)(handoff.config));
221
223
  // Asset storage selection baked (provider + module + env-var names + non-secret options JSON).
@@ -233,6 +235,7 @@ const initializeProjectApp = (handoff_1, ...args_1) => __awaiter(void 0, [handof
233
235
  '%HANDOFF_EXPORT_PATH%': escapedExportPath,
234
236
  '%HANDOFF_WEBSOCKET_PORT%': escapedWebsocketPort,
235
237
  '%HANDOFF_RUNTIME_MODE%': escapedRuntimeMode,
238
+ '%HANDOFF_MCP_ENABLED%': escapedMcpEnabled,
236
239
  '%HANDOFF_REGISTRY_DRIVER%': escapedRegistryDriver,
237
240
  '%HANDOFF_REGISTRY_DATABASE_URL_ENV%': escapedDatabaseUrlEnv,
238
241
  '%HANDOFF_ASSET_STORAGE_ADAPTER%': escapedAssetStorageAdapter,
@@ -426,12 +429,16 @@ containers, custom Node servers, and other non-Vercel hosts.
426
429
  /**
427
430
  * Resolve the set of npm packages the packaged registry app must be able to `require`/`import` at
428
431
  * runtime, given the configured database driver. `next`/`react`/`react-dom` run the server and React
429
- * runtime; `drizzle-orm` backs both the request-time DB client and the migration runner; the driver
430
- * package is driver-specific (the Neon serverless driver also needs `ws` for its Node WebSocket transport).
432
+ * runtime; `drizzle-orm` backs both the request-time DB client and the migration runner; the MCP SDK
433
+ * (and its `zod` peer) is only needed when `/api/mcp/` is enabled; the driver package is
434
+ * driver-specific (the Neon serverless driver also needs `ws` for its Node WebSocket transport).
431
435
  */
432
436
  const getRequiredRegistryRuntimeModules = (handoff) => {
433
437
  var _a;
434
438
  const base = ['next', 'next-auth', 'react', 'react-dom', 'drizzle-orm'];
439
+ if ((0, config_1.isMcpEnabled)(handoff.config)) {
440
+ base.push('@modelcontextprotocol/sdk', 'zod');
441
+ }
435
442
  const driver = (0, driver_1.resolveRegistryDriver)(handoff.config);
436
443
  const driverModules = driver === 'neon' ? ['@neondatabase/serverless', 'ws'] : ['pg'];
437
444
  // Asset-storage SDKs the deployed registry must be able to load at request time. The pre-packaged
@@ -65,6 +65,7 @@ const buildServerRuntimeConfig = (config, modeOverride) => {
65
65
  const assetStorage = (0, resolve_1.resolveAssetStorageFromConfig)(config);
66
66
  return {
67
67
  mode: (_b = modeOverride !== null && modeOverride !== void 0 ? modeOverride : (_a = config === null || config === void 0 ? void 0 : config.runtime) === null || _a === void 0 ? void 0 : _a.mode) !== null && _b !== void 0 ? _b : 'workspace',
68
+ mcp: (0, config_1.isMcpEnabled)(config),
68
69
  registry: {
69
70
  driver: (0, driver_1.resolveRegistryDriver)(config),
70
71
  databaseUrlEnv: (0, driver_1.resolveDatabaseUrlEnv)(config),
@@ -6,6 +6,8 @@ export interface ImageStyle {
6
6
  width: number;
7
7
  description: string;
8
8
  }
9
+ /** Whether the MCP endpoint is served. On unless `runtime.mcp` is explicitly `false`. */
10
+ export declare const isMcpEnabled: (config: Config | null | undefined) => boolean;
9
11
  export declare const defaultConfig: () => Config;
10
12
  /**
11
13
  * Retrieves the client configuration from the provided handoff configuration.
@@ -1,7 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getClientConfig = exports.defaultConfig = void 0;
3
+ exports.getClientConfig = exports.defaultConfig = exports.isMcpEnabled = void 0;
4
4
  const connection_1 = require("../registry/connection");
5
+ /** Whether the MCP endpoint is served. On unless `runtime.mcp` is explicitly `false`. */
6
+ const isMcpEnabled = (config) => { var _a; return ((_a = config === null || config === void 0 ? void 0 : config.runtime) === null || _a === void 0 ? void 0 : _a.mcp) !== false; };
7
+ exports.isMcpEnabled = isMcpEnabled;
5
8
  const defaultConfig = () => {
6
9
  var _a, _b, _c, _d;
7
10
  return ({
@@ -1,4 +1,4 @@
1
- export { defaultConfig, getClientConfig } from './defaults';
1
+ export { defaultConfig, getClientConfig, isMcpEnabled } from './defaults';
2
2
  export type { ImageStyle } from './defaults';
3
3
  export { HandoffConfigError, initConfig, initConfigWithMetadata } from './loader';
4
4
  export type { ConfigLoadContext } from './loader';
@@ -1,10 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.writeEntries = exports.isEntryCovered = exports.validateConfig = exports.initRuntimeConfig = exports.defineConfig = exports.initConfigWithMetadata = exports.initConfig = exports.HandoffConfigError = exports.getClientConfig = exports.defaultConfig = void 0;
3
+ exports.writeEntries = exports.isEntryCovered = exports.validateConfig = exports.initRuntimeConfig = exports.defineConfig = exports.initConfigWithMetadata = exports.initConfig = exports.HandoffConfigError = exports.isMcpEnabled = exports.getClientConfig = exports.defaultConfig = void 0;
4
4
  // Config defaults and client config
5
5
  var defaults_1 = require("./defaults");
6
6
  Object.defineProperty(exports, "defaultConfig", { enumerable: true, get: function () { return defaults_1.defaultConfig; } });
7
7
  Object.defineProperty(exports, "getClientConfig", { enumerable: true, get: function () { return defaults_1.getClientConfig; } });
8
+ Object.defineProperty(exports, "isMcpEnabled", { enumerable: true, get: function () { return defaults_1.isMcpEnabled; } });
8
9
  // Config file loading
9
10
  var loader_1 = require("./loader");
10
11
  Object.defineProperty(exports, "HandoffConfigError", { enumerable: true, get: function () { return loader_1.HandoffConfigError; } });
@@ -127,6 +127,13 @@ export interface HandoffRuntimeConfig {
127
127
  * Mode is never inferred from environment variables, database settings, or token presence.
128
128
  */
129
129
  mode?: RuntimeMode;
130
+ /**
131
+ * Whether the app serves the `/api/mcp/` Model Context Protocol endpoint. Set to `false` to leave
132
+ * the endpoint and the header affordance out of the build; the route then answers 404. Like
133
+ * `mode`, this is config-only and baked at build time, so changing it needs a rebuild.
134
+ * @default true
135
+ */
136
+ mcp?: boolean;
130
137
  /** Workspace-mode settings. */
131
138
  workspace?: {
132
139
  entries?: ConfigEntries;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-app",
3
- "version": "2.0.0-5",
3
+ "version": "2.0.0-6",
4
4
  "description": "Automated documentation toolchain for building client side documentation from figma",
5
5
  "author ": {
6
6
  "name": "Convertiv",
@@ -53,6 +53,7 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@clack/prompts": "^0.7.0",
56
+ "@modelcontextprotocol/sdk": "^1.30.0",
56
57
  "@neondatabase/serverless": "^1.1.0",
57
58
  "@radix-ui/react-accordion": "^1.2.8",
58
59
  "@radix-ui/react-collapsible": "^1.1.2",
@@ -121,7 +122,8 @@
121
122
  "vaul": "^1.1.2",
122
123
  "vite": "^6.3.5",
123
124
  "ws": "^8.18.3",
124
- "yargs": "^17.7.2"
125
+ "yargs": "^17.7.2",
126
+ "zod": "^4.4.3"
125
127
  },
126
128
  "peerDependencies": {
127
129
  "typescript": ">=5.6 <7"
@@ -7,11 +7,14 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/t
7
7
 
8
8
  type CopyToClipboardProps = {
9
9
  value: string;
10
+ /** Accessible name for the control, and the hover tooltip unless `showTooltip` is off. */
10
11
  tooltip: string;
12
+ /** Hide the tooltip where the surrounding UI already says what the button copies. */
13
+ showTooltip?: boolean;
11
14
  className?: string;
12
15
  };
13
16
 
14
- export const CopyToClipboard: React.FC<CopyToClipboardProps> = ({ value, tooltip, className }) => {
17
+ export const CopyToClipboard: React.FC<CopyToClipboardProps> = ({ value, tooltip, showTooltip = true, className }) => {
15
18
  const [copied, setCopied] = React.useState<boolean>(false);
16
19
 
17
20
  const handleCopy = async () => {
@@ -24,33 +27,32 @@ export const CopyToClipboard: React.FC<CopyToClipboardProps> = ({ value, tooltip
24
27
  }
25
28
  };
26
29
 
30
+ const button = (
31
+ <Button
32
+ variant="outline"
33
+ size="icon"
34
+ className={cn('disabled:opacity-100', className)}
35
+ onClick={handleCopy}
36
+ aria-label={copied ? 'Copied' : tooltip}
37
+ disabled={copied}
38
+ >
39
+ <div className={cn('transition-all', copied ? 'scale-100 opacity-100' : 'scale-0 opacity-0')}>
40
+ <CheckIcon className="stroke-gray-600" size={16} aria-hidden="true" />
41
+ </div>
42
+ <div className={cn('absolute transition-all', copied ? 'scale-0 opacity-0' : 'scale-100 opacity-100')}>
43
+ <CopyIcon aria-hidden="true" />
44
+ </div>
45
+ </Button>
46
+ );
47
+
48
+ if (!showTooltip) {
49
+ return button;
50
+ }
51
+
27
52
  return (
28
53
  <TooltipProvider delayDuration={0}>
29
54
  <Tooltip>
30
- <TooltipTrigger asChild>
31
- <Button
32
- variant="outline"
33
- size="icon"
34
- className={cn('disabled:opacity-100', className)}
35
- onClick={handleCopy}
36
- aria-label={copied ? 'Copied' : 'Copy to clipboard'}
37
- disabled={copied}
38
- >
39
- <div
40
- className={cn('transition-all', copied ? 'scale-100 opacity-100' : 'scale-0 opacity-0')}
41
- >
42
- <CheckIcon className="stroke-gray-600" size={16} aria-hidden="true" />
43
- </div>
44
- <div
45
- className={cn(
46
- 'absolute transition-all',
47
- copied ? 'scale-0 opacity-0' : 'scale-100 opacity-100'
48
- )}
49
- >
50
- <CopyIcon aria-hidden="true" />
51
- </div>
52
- </Button>
53
- </TooltipTrigger>
55
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
54
56
  <TooltipContent className="px-2 py-1 text-xs">{tooltip}</TooltipContent>
55
57
  </Tooltip>
56
58
  </TooltipProvider>
@@ -58,4 +60,3 @@ export const CopyToClipboard: React.FC<CopyToClipboardProps> = ({ value, tooltip
58
60
  };
59
61
 
60
62
  export default CopyToClipboard;
61
-
@@ -1,23 +1,18 @@
1
1
  import Link from 'next/link';
2
2
  import { useEffect, useState } from 'react';
3
+ import { McpConfigDialog } from '../../components/McpIntegration/McpConfigDialog';
3
4
  import { ModeToggle } from '../../components/ModeSwitcher';
4
5
  import { MainNav } from '../../components/Navigation/MainNav';
5
6
  import { MobileNav } from '../../components/Navigation/MobileNav';
6
7
  import { cn } from '../../lib/utils';
7
- import { Badge } from '../ui/badge';
8
8
  import { useConfigContext } from '../context/ConfigContext';
9
9
  import { AuthControls } from '../Auth/AuthControls';
10
-
11
- const RUNTIME_MODE_LABEL: Record<'workspace' | 'registry', string> = {
12
- workspace: 'Workspace',
13
- registry: 'Registry',
14
- };
10
+ import { RuntimeModeBadge } from './RuntimeModeBadge';
15
11
 
16
12
  export function Header() {
17
13
  const context = useConfigContext();
18
14
  const [isScrolled, setIsScrolled] = useState(false);
19
15
  const runtimeMode = context.config?.runtime?.mode ?? 'workspace';
20
- const runtimeModeLabel = RUNTIME_MODE_LABEL[runtimeMode];
21
16
  // The static export is a self-contained snapshot of the workspace, not a live runtime, so the
22
17
  // runtime-mode badge carries no meaning there and is omitted.
23
18
  const isStaticSnapshot = process.env.HANDOFF_BUILD_TARGET === 'static';
@@ -44,14 +39,11 @@ export function Header() {
44
39
  <Link href="/">
45
40
  <img className="max-h-5" src={`${process.env.HANDOFF_APP_BASE_PATH ?? ''}/logo.svg`} alt={context.config?.app?.title} />
46
41
  </Link>
47
- {!isStaticSnapshot && (
48
- <Badge variant={runtimeMode === 'registry' ? 'info' : 'default'} aria-label={`Runtime mode: ${runtimeModeLabel}`}>
49
- {runtimeModeLabel}
50
- </Badge>
51
- )}
42
+ {!isStaticSnapshot && <RuntimeModeBadge mode={runtimeMode} />}
52
43
  </div>
53
44
  <div className="hidden items-center gap-4 @2xl:flex">
54
45
  <MainNav />
46
+ <McpConfigDialog />
55
47
  {runtimeMode === 'registry' ? <AuthControls /> : null}
56
48
  <ModeToggle />
57
49
  </div>
@@ -0,0 +1,18 @@
1
+ import type { RuntimeMode } from '@handoff/types/config';
2
+ import { Badge } from '../ui/badge';
3
+
4
+ const LABEL: Record<RuntimeMode, string> = {
5
+ workspace: 'Workspace',
6
+ registry: 'Registry',
7
+ };
8
+
9
+ /**
10
+ * The Workspace/Registry marker, shared by the header and the MCP connect dialog. `mode` is a prop
11
+ * because the header reads the projected client config while the dialog reads the mode baked into
12
+ * the bundle.
13
+ */
14
+ export const RuntimeModeBadge: React.FC<{ mode: RuntimeMode; className?: string }> = ({ mode, className }) => (
15
+ <Badge variant={mode === 'registry' ? 'info' : 'default'} className={className} aria-label={`Runtime mode: ${LABEL[mode]}`}>
16
+ {LABEL[mode]}
17
+ </Badge>
18
+ );