handoff-app 2.0.0-9 → 2.0.0

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 (303) hide show
  1. package/{Changelog.md → CHANGELOG.md} +85 -1
  2. package/{License.md → LICENSE.md} +1 -1
  3. package/README.md +813 -406
  4. package/RECIPES.md +975 -0
  5. package/UPGRADE.md +57 -9
  6. package/components.json +20 -20
  7. package/config/docs/foundations/colors/guidelines.md +0 -1
  8. package/config/docs/foundations/colors.md +0 -1
  9. package/config/docs/foundations/effects/guidelines.md +0 -1
  10. package/config/docs/foundations/effects/resources.md +0 -1
  11. package/config/docs/foundations/effects.md +0 -1
  12. package/config/docs/foundations/fonts.md +0 -1
  13. package/config/docs/foundations/grid.md +0 -1
  14. package/config/docs/foundations/icon.md +0 -1
  15. package/config/docs/foundations/icons/guidelines.md +0 -1
  16. package/config/docs/foundations/icons.md +0 -1
  17. package/config/docs/foundations/images/guidelines.md +0 -1
  18. package/config/docs/foundations/images/resources.md +0 -1
  19. package/config/docs/foundations/images.md +0 -1
  20. package/config/docs/foundations/logo/resources.md +0 -1
  21. package/config/docs/foundations/logo.md +0 -1
  22. package/config/docs/foundations/typography/guidelines.md +0 -1
  23. package/config/docs/foundations/typography/resources.md +0 -1
  24. package/config/docs/foundations/typography.md +0 -1
  25. package/config/docs/foundations.md +0 -1
  26. package/config/docs/guidelines.md +0 -1
  27. package/config/docs/index.md +0 -1
  28. package/config/docs/system/tokens/foundations/colors.md +0 -4
  29. package/config/docs/system/tokens/foundations/effects.md +0 -4
  30. package/config/docs/system/tokens/foundations/typography.md +0 -2
  31. package/config/docs/system.md +0 -2
  32. package/config/templates/page.md +2 -1
  33. package/config/templates/page.mdx +2 -1
  34. package/dist/ai/connections.d.ts +66 -0
  35. package/dist/ai/connections.js +149 -0
  36. package/dist/ai/define.d.ts +10 -0
  37. package/dist/ai/define.js +13 -0
  38. package/dist/ai/types.d.ts +26 -0
  39. package/dist/ai/types.js +10 -0
  40. package/dist/app-builder/build.d.ts +14 -7
  41. package/dist/app-builder/build.js +195 -66
  42. package/dist/app-builder/client-config.js +3 -2
  43. package/dist/app-builder/paths.d.ts +0 -12
  44. package/dist/app-builder/paths.js +3 -20
  45. package/dist/app-builder/static-export.js +1 -1
  46. package/dist/app-builder/vercel-output.d.ts +1 -1
  47. package/dist/app-builder/vercel-output.js +5 -4
  48. package/dist/cache/build-cache.d.ts +27 -4
  49. package/dist/cache/build-cache.js +114 -47
  50. package/dist/cache/build-inputs.d.ts +13 -0
  51. package/dist/cache/build-inputs.js +19 -0
  52. package/dist/cache/index.d.ts +2 -1
  53. package/dist/cache/index.js +5 -1
  54. package/dist/cli/create.js +1 -2
  55. package/dist/commands/build/index.d.ts +1 -1
  56. package/dist/commands/build/index.js +1 -1
  57. package/dist/commands/utils.d.ts +3 -3
  58. package/dist/config/defaults.js +7 -4
  59. package/dist/config/from-env.d.ts +4 -0
  60. package/dist/config/from-env.js +38 -1
  61. package/dist/config/helpers.js +2 -2
  62. package/dist/config/index.d.ts +0 -1
  63. package/dist/config/index.js +1 -4
  64. package/dist/config/loader.js +26 -2
  65. package/dist/config/resolve-env.d.ts +2 -1
  66. package/dist/config/resolve-env.js +28 -12
  67. package/dist/config/runtime.js +1 -1
  68. package/dist/index.d.ts +12 -7
  69. package/dist/index.js +18 -22
  70. package/dist/nav/index.d.ts +2 -2
  71. package/dist/nav/registry.d.ts +3 -3
  72. package/dist/nav/registry.js +1 -1
  73. package/dist/nav/workspace.d.ts +3 -3
  74. package/dist/nav/workspace.js +1 -1
  75. package/dist/pipeline/figma.d.ts +13 -2
  76. package/dist/pipeline/figma.js +86 -46
  77. package/dist/pipeline/styles.js +1 -1
  78. package/dist/pipeline/validation.d.ts +1 -1
  79. package/dist/pipeline/validation.js +3 -3
  80. package/dist/providers.d.ts +10 -0
  81. package/dist/providers.js +13 -0
  82. package/dist/registry/asset-storage/define.d.ts +5 -5
  83. package/dist/registry/asset-storage/define.js +4 -4
  84. package/dist/registry/asset-storage/resolve.d.ts +9 -19
  85. package/dist/registry/asset-storage/resolve.js +33 -21
  86. package/dist/registry/asset-storage/types.d.ts +37 -17
  87. package/dist/registry/asset-storage/types.js +5 -5
  88. package/dist/registry/auth/ai-keys.d.ts +32 -0
  89. package/dist/registry/auth/ai-keys.js +69 -0
  90. package/dist/registry/auth/crypto.d.ts +17 -0
  91. package/dist/registry/auth/crypto.js +61 -1
  92. package/dist/registry/auth/index.d.ts +2 -1
  93. package/dist/registry/auth/index.js +11 -2
  94. package/dist/registry/checkout/index.d.ts +2 -1
  95. package/dist/registry/checkout/index.js +57 -127
  96. package/dist/registry/client.js +53 -17
  97. package/dist/registry/db/driver.js +3 -3
  98. package/dist/registry/db/schema.d.ts +261 -3
  99. package/dist/registry/db/schema.js +29 -4
  100. package/dist/registry/email/define.d.ts +10 -0
  101. package/dist/registry/email/define.js +13 -0
  102. package/dist/registry/email/providers/smtp.d.ts +10 -0
  103. package/dist/registry/email/providers/smtp.js +73 -0
  104. package/dist/registry/email/resolve.d.ts +28 -0
  105. package/dist/registry/email/resolve.js +74 -0
  106. package/dist/registry/email/types.d.ts +24 -0
  107. package/dist/registry/email/types.js +6 -0
  108. package/dist/registry/path.d.ts +2 -0
  109. package/dist/registry/path.js +11 -1
  110. package/dist/registry/publish/package.js +8 -0
  111. package/dist/registry/transfer.d.ts +4 -0
  112. package/dist/store/registry.d.ts +3 -3
  113. package/dist/store/registry.js +4 -4
  114. package/dist/transformers/preview/component/builder.d.ts +1 -9
  115. package/dist/transformers/preview/component/builder.js +17 -17
  116. package/dist/transformers/preview/component/css.d.ts +2 -1
  117. package/dist/transformers/preview/component/css.js +8 -5
  118. package/dist/transformers/preview/component/html.d.ts +3 -1
  119. package/dist/transformers/preview/component/html.js +6 -2
  120. package/dist/transformers/preview/component/javascript.d.ts +2 -1
  121. package/dist/transformers/preview/component/javascript.js +10 -4
  122. package/dist/transformers/utils/build.d.ts +5 -0
  123. package/dist/transformers/utils/build.js +21 -4
  124. package/dist/types/config.d.ts +129 -18
  125. package/dist/utils/path.d.ts +1 -5
  126. package/dist/utils/path.js +6 -23
  127. package/docs/api.md +6 -4
  128. package/docs/cli.md +275 -54
  129. package/drizzle/0007_add_ai_keys.sql +11 -0
  130. package/drizzle/0008_add_source_dir.sql +2 -0
  131. package/drizzle/meta/0007_snapshot.json +2013 -0
  132. package/drizzle/meta/0008_snapshot.json +2025 -0
  133. package/drizzle/meta/_journal.json +14 -0
  134. package/package.json +19 -8
  135. package/src/ai/README.md +42 -0
  136. package/src/ai/connections.ts +189 -0
  137. package/src/ai/define.ts +15 -0
  138. package/src/ai/types.ts +30 -0
  139. package/src/app/components/AiAssistant/AiAssistantPanel.tsx +144 -0
  140. package/src/app/components/AiAssistant/AiAssistantProvider.tsx +215 -0
  141. package/src/app/components/AiAssistant/AiConversation.tsx +737 -0
  142. package/src/app/components/AiAssistant/AiToolResult.tsx +99 -0
  143. package/src/app/components/AiAssistant/dockState.ts +86 -0
  144. package/src/app/components/AiAssistant/toolLabel.ts +24 -0
  145. package/src/app/components/AiAssistant/useAiChat.ts +147 -0
  146. package/src/app/components/AiAssistant/useAiConnections.ts +86 -0
  147. package/src/app/components/AiAssistant/usePageContext.ts +63 -0
  148. package/src/app/components/Auth/AccountLayout.tsx +58 -19
  149. package/src/app/components/Auth/AuthShell.tsx +27 -33
  150. package/src/app/components/Auth/RegistryInstallGate.tsx +4 -8
  151. package/src/app/components/Auth/installStatus.ts +29 -0
  152. package/src/app/components/Component/Preview.tsx +5 -2
  153. package/src/app/components/Foundations/ColorGrid.tsx +13 -14
  154. package/src/app/components/Layout/Header.tsx +21 -15
  155. package/src/app/components/Layout/Main.tsx +2 -2
  156. package/src/app/components/Markdown/MarkdownComponents.tsx +30 -32
  157. package/src/app/components/McpIntegration/McpConfigDialog.tsx +12 -51
  158. package/src/app/components/Navigation/MobileNav.tsx +2 -2
  159. package/src/app/components/Navigation/SideNav.tsx +1 -15
  160. package/src/app/components/context/NavProvider.tsx +7 -3
  161. package/src/app/components/ui/attachment.tsx +50 -0
  162. package/src/app/components/ui/bubble.tsx +57 -0
  163. package/src/app/components/ui/button.tsx +3 -1
  164. package/src/app/components/ui/confirm-dialog.tsx +87 -0
  165. package/src/app/components/ui/dialog.tsx +7 -2
  166. package/src/app/components/ui/marker.tsx +56 -0
  167. package/src/app/components/ui/sidebar.tsx +3 -1
  168. package/src/app/components/util/index.ts +3 -12
  169. package/src/app/css/index.css +64 -0
  170. package/src/app/lib/ai/auth.ts +33 -0
  171. package/src/app/lib/ai/context.ts +99 -0
  172. package/src/app/lib/ai/model.ts +79 -0
  173. package/src/app/lib/ai/prompt.ts +36 -0
  174. package/src/app/lib/ai/resolve.ts +97 -0
  175. package/src/app/lib/ai/tools.ts +120 -0
  176. package/src/app/lib/api/methods.ts +13 -0
  177. package/src/app/lib/asset-storage.ts +35 -36
  178. package/src/app/lib/auth/api.ts +4 -12
  179. package/src/app/lib/auth/email.ts +96 -23
  180. package/src/app/lib/docs-api/page-rendering.ts +1 -1
  181. package/src/app/lib/docs-api/registry.ts +2 -3
  182. package/src/app/lib/docs-api/runtime-config.ts +47 -21
  183. package/src/app/lib/mcp/components.ts +523 -0
  184. package/src/app/lib/mcp/page-url.ts +24 -0
  185. package/src/app/lib/mcp/pages.ts +84 -0
  186. package/src/app/lib/mcp/result.ts +33 -0
  187. package/src/app/lib/mcp/server.ts +39 -318
  188. package/src/app/lib/mcp/tokens.ts +334 -0
  189. package/src/app/lib/mcp/tool.ts +19 -0
  190. package/src/app/lib/registry-api/asset-transfer.ts +119 -11
  191. package/src/app/lib/registry-api/errors.ts +2 -0
  192. package/src/app/lib/registry-api/handler.ts +7 -1
  193. package/src/app/lib/registry-api/transfer.ts +28 -6
  194. package/src/app/lib/server-module.ts +34 -0
  195. package/src/app/lib/utils.ts +20 -0
  196. package/src/app/next.config.mjs +26 -24
  197. package/src/app/pages/[...slug]/index.tsx +2 -1
  198. package/src/app/pages/_app.tsx +6 -2
  199. package/src/app/pages/_document.tsx +6 -0
  200. package/src/app/pages/account/ai.tsx +183 -0
  201. package/src/app/pages/account/index.tsx +68 -68
  202. package/src/app/pages/account/tokens.tsx +37 -7
  203. package/src/app/pages/account/users.tsx +75 -15
  204. package/src/app/pages/api/account/ai/keys/[connectionId].ts +60 -0
  205. package/src/app/pages/api/account/ai/keys/index.ts +20 -0
  206. package/src/app/pages/api/admin/users/[id]/[action].ts +80 -11
  207. package/src/app/pages/api/admin/users/index.ts +2 -2
  208. package/src/app/pages/api/ai/chat.ts +115 -0
  209. package/src/app/pages/api/ai/connections.ts +23 -0
  210. package/src/app/pages/api/auth/request-reset.ts +14 -16
  211. package/src/app/pages/api/docs/nav.json.ts +1 -1
  212. package/src/app/pages/api/install.ts +3 -3
  213. package/src/app/pages/api/mcp.ts +6 -0
  214. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/complete.ts +11 -0
  215. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/upload.ts +12 -0
  216. package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +1 -1
  217. package/src/app/pages/cli/device.tsx +10 -5
  218. package/src/app/pages/foundations/colors.tsx +9 -8
  219. package/src/app/pages/foundations/index.tsx +3 -3
  220. package/src/app/pages/index.tsx +2 -2
  221. package/src/app/pages/install.tsx +7 -2
  222. package/src/app/pages/login.tsx +32 -27
  223. package/src/app/pages/reset-password.tsx +21 -20
  224. package/src/app/pages/system/component/[component]/index.tsx +2 -5
  225. package/src/app/pages/system/index.tsx +11 -11
  226. package/src/app/pages/system/pattern/[pattern]/index.tsx +2 -2
  227. package/src/app/pages/system/tokens/components/[component]/index.tsx +0 -4
  228. package/src/app-builder/README.md +2 -1
  229. package/src/app-builder/build.ts +218 -73
  230. package/src/app-builder/client-config.ts +3 -2
  231. package/src/app-builder/paths.ts +3 -22
  232. package/src/app-builder/static-export.ts +1 -1
  233. package/src/app-builder/vercel-output.ts +5 -4
  234. package/src/cache/README.md +4 -1
  235. package/src/cache/build-cache.ts +128 -43
  236. package/src/cache/build-inputs.ts +28 -0
  237. package/src/cache/index.ts +4 -0
  238. package/src/cli/create.ts +1 -2
  239. package/src/commands/build/index.ts +1 -1
  240. package/src/config/README.md +10 -9
  241. package/src/config/defaults.ts +7 -4
  242. package/src/config/from-env.ts +33 -0
  243. package/src/config/helpers.ts +1 -3
  244. package/src/config/index.ts +0 -3
  245. package/src/config/loader.ts +25 -3
  246. package/src/config/resolve-env.ts +28 -15
  247. package/src/config/runtime.ts +1 -1
  248. package/src/index.ts +33 -29
  249. package/src/nav/index.ts +2 -2
  250. package/src/nav/registry.ts +3 -3
  251. package/src/nav/workspace.ts +3 -3
  252. package/src/pipeline/README.md +1 -1
  253. package/src/pipeline/figma.ts +98 -49
  254. package/src/pipeline/styles.ts +1 -1
  255. package/src/pipeline/validation.ts +3 -3
  256. package/src/providers.ts +18 -0
  257. package/src/registry/asset-storage/define.ts +6 -6
  258. package/src/registry/asset-storage/resolve.ts +38 -37
  259. package/src/registry/asset-storage/types.ts +39 -17
  260. package/src/registry/auth/ai-keys.ts +62 -0
  261. package/src/registry/auth/crypto.ts +63 -1
  262. package/src/registry/auth/index.ts +11 -1
  263. package/src/registry/checkout/index.ts +60 -138
  264. package/src/registry/client.ts +60 -20
  265. package/src/registry/db/driver.ts +2 -2
  266. package/src/registry/db/schema.ts +49 -22
  267. package/src/registry/email/define.ts +15 -0
  268. package/src/registry/email/providers/smtp.ts +39 -0
  269. package/src/registry/email/resolve.ts +94 -0
  270. package/src/registry/email/types.ts +28 -0
  271. package/src/registry/path.ts +11 -0
  272. package/src/registry/publish/package.ts +9 -0
  273. package/src/registry/transfer.ts +4 -0
  274. package/src/store/registry.ts +7 -7
  275. package/src/transformers/preview/component/builder.ts +19 -25
  276. package/src/transformers/preview/component/css.ts +9 -4
  277. package/src/transformers/preview/component/html.ts +6 -2
  278. package/src/transformers/preview/component/javascript.ts +14 -4
  279. package/src/transformers/utils/build.ts +16 -3
  280. package/src/types/config.ts +142 -20
  281. package/src/utils/README.md +1 -1
  282. package/src/utils/path.ts +6 -29
  283. package/templates/default/handoff.config.js.tpl +10 -2
  284. package/templates/default/handoff.config.ts.tpl +10 -2
  285. package/templates/default/handoff.config.with-examples.js.tpl +10 -2
  286. package/templates/default/handoff.config.with-examples.ts.tpl +10 -2
  287. package/templates/default/package.json.tpl +2 -2
  288. package/.editorconfig +0 -19
  289. package/.eslintrc.json +0 -4
  290. package/.prettierignore +0 -0
  291. package/.prettierrc.json +0 -8
  292. package/.vscode/settings.json +0 -17
  293. package/dist/config/validator.d.ts +0 -9
  294. package/dist/config/validator.js +0 -24
  295. package/dist/registry/asset-storage/adapters/vercel-blob.d.ts +0 -17
  296. package/dist/registry/asset-storage/adapters/vercel-blob.js +0 -87
  297. package/src/app/components/old/Header.tsx +0 -89
  298. package/src/app/lib/mcp/shape.ts +0 -596
  299. package/src/app/pages/assets/fonts.tsx +0 -129
  300. package/src/app/pages/assets/index.tsx +0 -91
  301. package/src/app/pages/assets/logos.tsx +0 -75
  302. package/src/config/validator.ts +0 -22
  303. package/src/registry/asset-storage/adapters/vercel-blob.ts +0 -51
@@ -0,0 +1,24 @@
1
+ import { HOME_PAGE_ID } from '@handoff/registry/content-kinds';
2
+
3
+ /**
4
+ * Kept free of server imports: the AI assistant resolves page context in the browser through this
5
+ * helper, so importing it must not pull the docs backend into the client bundle.
6
+ */
7
+
8
+ /** Accept internal page routes without URL normalization hiding traversal segments. */
9
+ export const pageIdFromUrl = (url: string): string | null => {
10
+ if (!url.startsWith('/') || url.startsWith('//') || /[?#\\\u0000-\u001f\u007f]/.test(url)) {
11
+ return null;
12
+ }
13
+ if (url === '/') return HOME_PAGE_ID;
14
+ const id = url.slice(1).replace(/\/$/, '');
15
+ for (const segment of id.split('/')) {
16
+ try {
17
+ const decoded = decodeURIComponent(segment);
18
+ if (!decoded || decoded === '.' || decoded === '..' || /[/\\\u0000-\u001f\u007f]/.test(decoded)) return null;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+ return id;
24
+ };
@@ -0,0 +1,84 @@
1
+ import type { PageDetail } from '../docs-api/records';
2
+ import { createSearchRequest, DEFAULT_RESULT_LIMIT, MAX_QUERY_LENGTH, MAX_RESULT_LIMIT, MAX_TERMS } from '../docs-api/search';
3
+ import { pageIdFromUrl } from './page-url';
4
+ import { fail, ok, read } from './result';
5
+ import { defineTool, type McpTool } from './tool';
6
+
7
+ /** The documentation page tools: search and read. */
8
+
9
+ export const pageTools: McpTool[] = [
10
+ defineTool<{ query: string; group?: string; limit?: number }>(
11
+ {
12
+ name: 'handoff_search_pages',
13
+ title: 'Search pages',
14
+ description:
15
+ 'Search documentation pages by title, description and Markdown body. Returns ranked `results` ' +
16
+ 'with `url`, `title`, `description` and `snippet`. `truncated` is true when more matches exist. ' +
17
+ 'Read a page with handoff_get_page.',
18
+ annotations: { readOnlyHint: true, openWorldHint: false },
19
+ inputSchema: {
20
+ type: 'object',
21
+ properties: {
22
+ query: {
23
+ type: 'string',
24
+ description:
25
+ `Search text, at most ${MAX_QUERY_LENGTH} characters and ${MAX_TERMS} terms. Case-insensitive. ` +
26
+ 'Terms shorter than two characters are ignored.',
27
+ },
28
+ group: { type: 'string', description: 'Page group name. Exact match, case-insensitive.' },
29
+ limit: { type: 'integer', minimum: 1, maximum: MAX_RESULT_LIMIT, description: `Max results (default ${DEFAULT_RESULT_LIMIT}).` },
30
+ },
31
+ required: ['query'],
32
+ },
33
+ },
34
+ async ({ query, group, limit }) => {
35
+ const parsed = createSearchRequest(query, limit?.toString());
36
+ if ('error' in parsed) {
37
+ return fail(parsed.error.replace('`q` search parameter', '`query` argument'));
38
+ }
39
+ return read('pages', async (backend) => ok(await backend.searchPages({ ...parsed.request, group: group?.trim() || undefined })));
40
+ }
41
+ ),
42
+
43
+ defineTool<{ url: string }>(
44
+ {
45
+ name: 'handoff_get_page',
46
+ title: 'Get page',
47
+ description:
48
+ 'One documentation page by its URL from handoff_search_pages, for example /guides/setup, or / ' +
49
+ 'for the home page. Returns the page metadata and `content`, the full Markdown body. For an ' +
50
+ 'external-link page, the external site is not fetched.',
51
+ annotations: { readOnlyHint: true, openWorldHint: false },
52
+ inputSchema: {
53
+ type: 'object',
54
+ properties: {
55
+ url: { type: 'string', description: 'Internal route, without a base path, query string or fragment.' },
56
+ },
57
+ required: ['url'],
58
+ },
59
+ },
60
+ async ({ url }) => {
61
+ const id = pageIdFromUrl(url);
62
+ if (id === null) {
63
+ return fail('Provide an internal page URL such as /guides/setup or /, without a query string, fragment or traversal segments.');
64
+ }
65
+ return read(`page "${url}"`, async (backend) => {
66
+ const page = await backend.getPageDetail(id);
67
+ if (!page) {
68
+ return fail(`Page "${url}" was not found. Use handoff_search_pages to find available pages.`);
69
+ }
70
+ return ok(toPageResult(page));
71
+ });
72
+ }
73
+ ),
74
+ ];
75
+
76
+ export const toPageResult = (page: PageDetail) => ({
77
+ id: page.id,
78
+ url: page.path,
79
+ title: page.title,
80
+ description: page.description,
81
+ group: page.group,
82
+ external: page.external,
83
+ content: page.content,
84
+ });
@@ -0,0 +1,33 @@
1
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
+ import { resolveDocsBackend } from '../docs-api';
3
+ import type { DocsBackend } from '../docs-api/backend';
4
+
5
+ /** Result helpers shared by every tool. */
6
+
7
+ /** Base path the docs app is mounted under, so emitted preview URLs are actually fetchable. */
8
+ export const basePath = (): string => process.env.HANDOFF_APP_BASE_PATH ?? '';
9
+
10
+ /**
11
+ * A successful tool result: one compact JSON text block, since indentation only costs the agent
12
+ * tokens. A tool only ever returns design-system content read through {@link resolveDocsBackend}, so
13
+ * there is nothing here to withhold from a caller already authorized to read it.
14
+ */
15
+ export const ok = (data: unknown): CallToolResult => ({
16
+ content: [{ type: 'text', text: JSON.stringify(data) }],
17
+ });
18
+
19
+ /** A failed tool result. Not-found and bad-argument are tool errors, not protocol errors. */
20
+ export const fail = (message: string): CallToolResult => ({ isError: true, content: [{ type: 'text', text: message }] });
21
+
22
+ /**
23
+ * Run a tool body, turning an unexpected read failure into a safe message. The cause is logged
24
+ * server-side and never returned, matching the docs read API's `unexpected_error`.
25
+ */
26
+ export const read = async (what: string, body: (backend: DocsBackend) => Promise<CallToolResult>): Promise<CallToolResult> => {
27
+ try {
28
+ return await body(await resolveDocsBackend());
29
+ } catch (error) {
30
+ console.error(`MCP read failed: ${what}.`, error);
31
+ return fail(`Unable to read ${what}.`);
32
+ }
33
+ };
@@ -1,343 +1,64 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
3
- import { z } from 'zod';
4
- import type { ComponentListObject, TransformComponentTokensResult } from '@handoff/transformers/preview/types';
5
- import { resolveDocsBackend } from '../docs-api';
6
- import type { DocsBackend, TokenSetDetail } from '../docs-api/backend';
7
- import { createSearchRequest, DEFAULT_RESULT_LIMIT, MAX_QUERY_LENGTH, MAX_RESULT_LIMIT, MAX_TERMS } from '../docs-api/search';
8
- import {
9
- availableFormats,
10
- CODE_FIELDS,
11
- matchesComponent,
12
- pageIdFromUrl,
13
- stripFigmaIds,
14
- synthesizeComponentTokens,
15
- synthesizeFoundationTokens,
16
- toComponentResult,
17
- toComponentSummary,
18
- toPageResult,
19
- type CodeField,
20
- } from './shape';
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
3
+ import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv';
4
+ import { componentTools } from './components';
5
+ import { pageTools } from './pages';
6
+ import { fail } from './result';
7
+ import { tokenTools } from './tokens';
21
8
 
22
9
  /**
23
10
  * The Handoff MCP server: read-only access to the design system's components, tokens and pages.
24
11
  *
25
- * Every tool reads through {@link resolveDocsBackend}, the same mode-aware backing the `/api/docs/*`
26
- * routes use, so workspace and registry deployments answer identically. Results are projections (see
27
- * `./shape`) rather than raw records, since an agent pays for every field it is handed.
12
+ * Every tool reads through `resolveDocsBackend`, the same mode-aware backing the `/api/docs/*` routes
13
+ * use, so workspace and registry deployments answer identically.
14
+ *
15
+ * Results are projections rather than raw records. The docs read API returns records verbatim because
16
+ * the browser app needs every field. An MCP client pays for each field in context, so each domain
17
+ * module narrows its records: build config, validation state, absolute paths and Figma sync ids are
18
+ * dropped, and the rest is reshaped into the flatter arrays an agent can act on.
28
19
  *
29
20
  * A fresh server is built per request because the transport is stateless; nothing here holds state
30
21
  * between calls.
31
22
  */
32
23
 
33
24
  /** Version of the MCP surface itself, reported in `initialize`. Bump when a tool contract changes. */
34
- const MCP_SERVER_VERSION = '1.0.0';
35
-
36
- /** Search results are capped so a large design system cannot flood an agent's context. */
37
- const DEFAULT_SEARCH_LIMIT = 25;
38
- const MAX_SEARCH_LIMIT = 100;
39
-
40
- /** Base path the docs app is mounted under, so emitted preview URLs are actually fetchable. */
41
- const basePath = (): string => process.env.HANDOFF_APP_BASE_PATH ?? '';
25
+ const MCP_SERVER_VERSION = '2.0.0';
42
26
 
43
- /**
44
- * A successful tool result: one JSON text block, serialized verbatim the way every other API response
45
- * is. A tool only ever returns design-system content read through {@link resolveDocsBackend}, so
46
- * there is nothing here to withhold from a caller already authorized to read it.
47
- */
48
- const ok = (data: unknown): CallToolResult => ({
49
- content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
50
- });
27
+ const tools = [...componentTools, ...tokenTools, ...pageTools];
51
28
 
52
- /** A failed tool result. Not-found and bad-argument are tool errors, not protocol errors. */
53
- const fail = (message: string): CallToolResult => ({ isError: true, content: [{ type: 'text', text: message }] });
29
+ /** Compiled once per process, not per request. */
30
+ const validator = new AjvJsonSchemaValidator();
31
+ const toolsByName = new Map(
32
+ tools.map((tool) => [tool.definition.name, { ...tool, validate: validator.getValidator(tool.definition.inputSchema) }])
33
+ );
54
34
 
55
- /**
56
- * Run a tool body, turning an unexpected read failure into a safe message. The cause is logged
57
- * server-side and never returned, matching the docs read API's `unexpected_error`.
58
- */
59
- const read = async (what: string, body: (backend: DocsBackend) => Promise<CallToolResult>): Promise<CallToolResult> => {
60
- try {
61
- return await body(await resolveDocsBackend());
62
- } catch (error) {
63
- console.error(`MCP read failed: ${what}.`, error);
64
- return fail(`Unable to read ${what}.`);
65
- }
66
- };
67
-
68
- /**
69
- * Each preview's rendered document, keyed by preview id. A preview with no artifact (an unbuilt
70
- * component) is left out, so the rest still come back.
71
- */
72
- const readPreviewDocuments = async (backend: DocsBackend, id: string, previewIds: string[]): Promise<Record<string, string>> => {
73
- const documents = await Promise.all(
74
- previewIds.map(async (previewId) => {
75
- const artifact = await backend.resolveArtifact(['component', `${id}-${previewId}.html`]);
76
- return [previewId, artifact?.body.toString() ?? ''] as const;
77
- })
78
- );
79
- return Object.fromEntries(documents.filter(([, document]) => document));
80
- };
81
-
82
- /** Read and parse a component's build artifact, or `null` when the component has not been built. */
83
- const readComponentArtifact = async (backend: DocsBackend, id: string): Promise<TransformComponentTokensResult | null> => {
84
- const artifact = await backend.resolveArtifact(['component', `${id}.json`]);
85
- if (!artifact) {
86
- return null;
87
- }
88
- try {
89
- return JSON.parse(artifact.body.toString()) as TransformComponentTokensResult;
90
- } catch {
91
- // A corrupt artifact should not sink the metadata the agent can still use.
92
- return null;
93
- }
94
- };
95
-
96
- export const createMcpServer = (): McpServer => {
97
- const server = new McpServer(
35
+ export const createMcpServer = (): Server => {
36
+ const server = new Server(
98
37
  { name: 'handoff', version: MCP_SERVER_VERSION },
99
38
  {
39
+ capabilities: { tools: {} },
100
40
  instructions:
101
- 'Design-system knowledge for this project. Before writing UI code, search for an existing ' +
102
- 'component with handoff_search_components, read its props and variants with ' +
103
- 'handoff_get_component, and take colors, typography and spacing from handoff_get_tokens ' +
104
- 'rather than inventing values. Search documentation with handoff_search_pages, then pass a ' +
105
- 'result URL to handoff_get_page for the full Markdown content.',
41
+ 'Design-system knowledge for this project. Before writing UI code, find an existing component ' +
42
+ 'with handoff_search_components and read it with handoff_get_component. Take colors, typography ' +
43
+ 'and spacing from handoff_get_tokens instead of inventing values. Search documentation with ' +
44
+ 'handoff_search_pages and read a page with handoff_get_page.',
106
45
  }
107
46
  );
108
47
 
109
- server.registerTool(
110
- 'handoff_search_components',
111
- {
112
- title: 'Search components',
113
- description:
114
- 'Search the component catalog by id, title, group, category or tag. Returns identity and ' +
115
- 'classification only; pass an id to handoff_get_component for previews, props, variants and ' +
116
- 'code. Call with no arguments to list everything.',
117
- annotations: { readOnlyHint: true, openWorldHint: false },
118
- inputSchema: {
119
- query: z.string().optional().describe('Case-insensitive substring matched against id, title, group, categories and tags.'),
120
- group: z.string().optional().describe('Exact group name, e.g. "Forms".'),
121
- category: z.string().optional().describe('Exact category name.'),
122
- tag: z.string().optional().describe('Exact tag name.'),
123
- limit: z.number().int().min(1).max(MAX_SEARCH_LIMIT).optional().describe(`Max results (default ${DEFAULT_SEARCH_LIMIT}).`),
124
- },
125
- },
126
- async ({ query, group, category, tag, limit }) =>
127
- read('the component catalog', async (backend) => {
128
- const matched = (await backend.listComponents()).filter((record: ComponentListObject) =>
129
- matchesComponent(record, { query, group, category, tag })
130
- );
131
- const components = matched.slice(0, limit ?? DEFAULT_SEARCH_LIMIT).map(toComponentSummary);
132
- return ok({ total: matched.length, returned: components.length, components });
133
- })
134
- );
135
-
136
- server.registerTool(
137
- 'handoff_get_component',
138
- {
139
- title: 'Get component',
140
- description:
141
- 'One component by id: its properties, previews, variant axes (the values previews demonstrate ' +
142
- 'for each choice-typed property — `properties[].type` has the full set), usage guidance and its ' +
143
- 'source (`code`/`css`/`sass`/`js`). Each preview also carries the markup it renders to, as ' +
144
- '`previews[].html`, so rendered output is always tied to the state it came from. Use this ' +
145
- 'before writing markup for a component that already exists. `tokens.set` names its token set ' +
146
- 'for handoff_get_tokens when the component declares which Figma component it maps to; token ' +
147
- 'sets are keyed by Figma name, so do not assume it matches the component id.',
148
- annotations: { readOnlyHint: true, openWorldHint: false },
149
- inputSchema: {
150
- id: z.string().describe('Component id, as returned by handoff_search_components.'),
151
- include: z
152
- .array(z.enum(CODE_FIELDS))
153
- .optional()
154
- .describe(
155
- 'Which code fields to return. Omit for all of them; narrow it to keep the response small. ' +
156
- '`html` is rendered markup and lands on each preview, not in `code`.'
157
- ),
158
- },
159
- },
160
- async ({ id, include }) =>
161
- read(`component "${id}"`, async (backend) => {
162
- const record = await backend.getComponentDetail(id);
163
- if (!record) {
164
- return fail(`Component "${id}" was not found. Use handoff_search_components to list the components that exist.`);
165
- }
166
- const [artifact, tokenSets] = await Promise.all([readComponentArtifact(backend, id), backend.listTokenSets()]);
167
- const fields = (include as CodeField[]) ?? CODE_FIELDS;
168
- const previewDocuments = fields.includes('html')
169
- ? await readPreviewDocuments(backend, id, Object.keys(artifact?.previews ?? record.previews ?? {}))
170
- : {};
171
- return ok(
172
- toComponentResult(
173
- record,
174
- artifact,
175
- fields,
176
- basePath(),
177
- tokenSets.map((set) => set.id),
178
- previewDocuments
179
- )
180
- );
181
- })
182
- );
183
-
184
- server.registerTool(
185
- 'handoff_get_tokens',
186
- {
187
- title: 'Get design tokens',
188
- description:
189
- 'Design tokens. With no arguments: every available token set, plus the foundation tokens ' +
190
- '(colors, typography, effects) inline. With `set`: that one set. With `set` and `format`: the ' +
191
- 'generated stylesheet, verbatim. Use these tokens instead of hard-coding colors or type.\n' +
192
- 'A foundation token gives either `css` + `value` (a single variable) or `cssPrefix` + ' +
193
- '`properties` (a bundle, where each variable is `{cssPrefix}-{property}`). A component set ' +
194
- 'gives `variants`: one entry per variant combination, with the axis values it applies to and ' +
195
- 'the variables it sets. Emit the variable, not the literal, unless you have no stylesheet to ' +
196
- 'reference.',
197
- annotations: { readOnlyHint: true, openWorldHint: false },
198
- inputSchema: {
199
- set: z.string().optional().describe('Token set id, e.g. "foundation/colors" or "component/button".'),
200
- kind: z.enum(['foundation', 'component']).optional().describe('Restrict the listing to one kind of set.'),
201
- format: z
202
- .enum(['css', 'scss', 'styleDictionary', 'types'])
203
- .optional()
204
- .describe('Return the generated stylesheet for `set` in this format instead of the token record.'),
205
- },
206
- },
207
- async ({ set, kind, format }) =>
208
- read(set ? `token set "${set}"` : 'the token catalog', async (backend) => {
209
- if (!set) {
210
- if (format) {
211
- return fail('`format` needs a `set`. Call handoff_get_tokens with no arguments to list the available sets.');
212
- }
213
- return ok(await listTokens(backend, kind));
214
- }
215
-
216
- const detail = await backend.getTokenSetDetail(set);
217
- if (!detail) {
218
- return fail(`Token set "${set}" was not found. Call handoff_get_tokens with no arguments to list the available sets.`);
219
- }
220
- if (!format) {
221
- return ok({ id: detail.id, kind: detail.kind, formats: availableFormats(detail.artifacts), ...tokenPayload(detail) });
222
- }
48
+ server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: tools.map((tool) => tool.definition) }));
223
49
 
224
- const artifact = detail.artifacts.find((candidate) => candidate.format === format);
225
- if (!artifact) {
226
- const formats = availableFormats(detail.artifacts);
227
- return fail(
228
- `Token set "${set}" has no "${format}" output. ${formats.length ? `Available formats: ${formats.join(', ')}.` : 'It has no generated output.'}`
229
- );
230
- }
231
- return ok({ id: detail.id, kind: detail.kind, format, path: artifact.path, content: artifact.content });
232
- })
233
- );
234
-
235
- server.registerTool(
236
- 'handoff_search_pages',
237
- {
238
- title: 'Search pages',
239
- description:
240
- 'Search documentation titles, descriptions and Markdown bodies. Returns ranked matches with ' +
241
- 'internal URLs and snippets. Returns { query, results, truncated }; each result has url, title, ' +
242
- 'optional description and snippet. If truncated is true, narrow the query or group to find more ' +
243
- 'specific matches. Project pages replace package defaults at the same route; disabled pages ' +
244
- 'are excluded. External-link pages match visible metadata only. Pass a result URL to ' +
245
- 'handoff_get_page for the full content.',
246
- annotations: { readOnlyHint: true, openWorldHint: false },
247
- inputSchema: {
248
- query: z
249
- .string()
250
- .describe(
251
- `Required search text, at most ${MAX_QUERY_LENGTH} characters and ${MAX_TERMS} distinct terms. ` +
252
- 'Terms shorter than two characters are ignored; at least one longer term is required. ' +
253
- 'Matching ignores case and treats hyphens and underscores as spaces.'
254
- ),
255
- group: z.string().optional().describe('Exact group name, matched case-insensitively.'),
256
- limit: z.number().int().min(1).max(MAX_RESULT_LIMIT).optional().describe(`Max results (default ${DEFAULT_RESULT_LIMIT}).`),
257
- },
258
- },
259
- async ({ query, group, limit }) => {
260
- const parsed = createSearchRequest(query, limit?.toString());
261
- if ('error' in parsed) {
262
- return fail(parsed.error.replace('`q` search parameter', '`query` argument'));
263
- }
264
- return read('pages', async (backend) => ok(await backend.searchPages({ ...parsed.request, group: group?.trim() || undefined })));
50
+ // Unknown tools and invalid arguments are tool errors, so the agent can read them and retry.
51
+ server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
52
+ const tool = toolsByName.get(params.name);
53
+ if (!tool) {
54
+ return fail(`Tool "${params.name}" was not found.`);
265
55
  }
266
- );
267
-
268
- server.registerTool(
269
- 'handoff_get_page',
270
- {
271
- title: 'Get page',
272
- description:
273
- 'Read a documentation page by its internal URL from handoff_search_pages, such as /guides/setup ' +
274
- 'or / for the home page. Returns id, url, title, optional description, group and external, ' +
275
- 'plus content containing the full Markdown body without frontmatter. Project pages replace ' +
276
- 'package defaults at the same route. A missing page returns a tool error. ' +
277
- 'External-link pages return their stored content; external destinations are not fetched.',
278
- annotations: { readOnlyHint: true, openWorldHint: false },
279
- inputSchema: {
280
- url: z.string().describe('Internal page route without a site base path, query string or fragment. A trailing slash is accepted.'),
281
- },
282
- },
283
- async ({ url }) => {
284
- const id = pageIdFromUrl(url);
285
- if (id === null) {
286
- return fail('Provide an internal page URL such as /guides/setup or /, without a query string, fragment or traversal segments.');
287
- }
288
- return read(`page "${url}"`, async (backend) => {
289
- const page = await backend.getPageDetail(id);
290
- if (!page) {
291
- return fail(`Page "${url}" was not found. Use handoff_search_pages to find available pages.`);
292
- }
293
- return ok(toPageResult(page));
294
- });
56
+ const args = tool.validate(params.arguments ?? {});
57
+ if (!args.valid) {
58
+ return fail(`Invalid arguments for tool "${params.name}": ${args.errorMessage}.`);
295
59
  }
296
- );
60
+ return tool.run(args.data);
61
+ });
297
62
 
298
63
  return server;
299
64
  };
300
-
301
- /**
302
- * A token set's payload, under a key naming which of the four forms it is, so the agent never has to
303
- * guess: `tokens` for a foundation, `variants` for a component, or one of the two fallbacks.
304
- *
305
- * When neither reshape is possible we degrade rather than fail. The generated `stylesheet` still
306
- * lets an agent write correct code, and the raw `record` is the last resort, which is what a
307
- * workspace with unbuilt tokens gets.
308
- */
309
- const tokenPayload = (detail: TokenSetDetail): Record<string, unknown> => {
310
- const reshaped =
311
- detail.kind === 'foundation'
312
- ? synthesizeFoundationTokens(detail.record, detail.artifacts)
313
- : synthesizeComponentTokens(detail.artifacts);
314
- if (reshaped) {
315
- return detail.kind === 'foundation' ? { tokens: reshaped } : { variants: reshaped };
316
- }
317
- const stylesheet = detail.artifacts.find((a) => a.format === 'css') ?? detail.artifacts.find((a) => a.format === 'scss');
318
- if (stylesheet) {
319
- return { format: stylesheet.format, stylesheet: stylesheet.content };
320
- }
321
- return { record: stripFigmaIds(detail.record) };
322
- };
323
-
324
- /**
325
- * The token catalog. Foundation tokens are inlined because they are what an agent needs before it
326
- * writes anything. Component sets are listed by id and fetched one at a time, since inlining them
327
- * all would be most of the design system in one response.
328
- */
329
- const listTokens = async (backend: DocsBackend, kind?: 'foundation' | 'component') => {
330
- const sets = (await backend.listTokenSets()).filter((candidate) => !kind || candidate.kind === kind);
331
- if (kind === 'component') {
332
- return { sets };
333
- }
334
-
335
- const foundations: Record<string, unknown> = {};
336
- for (const candidate of sets.filter((entry) => entry.kind === 'foundation')) {
337
- const detail = await backend.getTokenSetDetail(candidate.id);
338
- if (detail) {
339
- foundations[candidate.id] = tokenPayload(detail);
340
- }
341
- }
342
- return { sets, foundations };
343
- };