handoff-app 2.0.0-11 → 2.0.0-13

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 (141) hide show
  1. package/{Changelog.md → CHANGELOG.md} +84 -0
  2. package/{License.md → LICENSE.md} +1 -1
  3. package/README.md +760 -801
  4. package/RECIPES.md +975 -0
  5. package/components.json +20 -20
  6. package/config/docs/foundations/colors/guidelines.md +0 -1
  7. package/config/docs/foundations/colors.md +0 -1
  8. package/config/docs/foundations/effects/guidelines.md +0 -1
  9. package/config/docs/foundations/effects/resources.md +0 -1
  10. package/config/docs/foundations/effects.md +0 -1
  11. package/config/docs/foundations/fonts.md +0 -1
  12. package/config/docs/foundations/grid.md +0 -1
  13. package/config/docs/foundations/icon.md +0 -1
  14. package/config/docs/foundations/icons/guidelines.md +0 -1
  15. package/config/docs/foundations/icons.md +0 -1
  16. package/config/docs/foundations/images/guidelines.md +0 -1
  17. package/config/docs/foundations/images/resources.md +0 -1
  18. package/config/docs/foundations/images.md +0 -1
  19. package/config/docs/foundations/logo/resources.md +0 -1
  20. package/config/docs/foundations/logo.md +0 -1
  21. package/config/docs/foundations/typography/guidelines.md +0 -1
  22. package/config/docs/foundations/typography/resources.md +0 -1
  23. package/config/docs/foundations/typography.md +0 -1
  24. package/config/docs/foundations.md +0 -1
  25. package/config/docs/guidelines.md +0 -1
  26. package/config/docs/index.md +0 -1
  27. package/config/docs/system/tokens/foundations/colors.md +0 -4
  28. package/config/docs/system/tokens/foundations/effects.md +0 -4
  29. package/config/docs/system/tokens/foundations/typography.md +0 -2
  30. package/config/docs/system.md +0 -2
  31. package/config/templates/page.md +2 -1
  32. package/config/templates/page.mdx +2 -1
  33. package/dist/ai/define.d.ts +1 -1
  34. package/dist/ai/define.js +1 -1
  35. package/dist/ai/types.d.ts +1 -1
  36. package/dist/ai/types.js +1 -1
  37. package/dist/app-builder/build.js +16 -16
  38. package/dist/app-builder/client-config.js +1 -1
  39. package/dist/config/loader.js +1 -2
  40. package/dist/index.d.ts +2 -2
  41. package/dist/index.js +2 -2
  42. package/dist/nav/index.d.ts +2 -2
  43. package/dist/nav/registry.d.ts +3 -3
  44. package/dist/nav/registry.js +1 -1
  45. package/dist/nav/workspace.d.ts +3 -3
  46. package/dist/nav/workspace.js +1 -1
  47. package/dist/providers.d.ts +10 -0
  48. package/dist/{define.js → providers.js} +4 -4
  49. package/dist/registry/asset-storage/define.d.ts +5 -5
  50. package/dist/registry/asset-storage/define.js +4 -4
  51. package/dist/registry/asset-storage/resolve.d.ts +8 -11
  52. package/dist/registry/asset-storage/resolve.js +12 -13
  53. package/dist/registry/asset-storage/types.d.ts +12 -12
  54. package/dist/registry/asset-storage/types.js +2 -2
  55. package/dist/registry/db/schema.d.ts +2 -2
  56. package/dist/registry/db/schema.js +2 -2
  57. package/dist/registry/email/define.d.ts +1 -1
  58. package/dist/registry/email/define.js +1 -1
  59. package/dist/registry/email/resolve.d.ts +7 -8
  60. package/dist/registry/email/resolve.js +21 -23
  61. package/dist/registry/email/types.d.ts +1 -1
  62. package/dist/registry/email/types.js +1 -1
  63. package/dist/store/registry.d.ts +3 -3
  64. package/dist/store/registry.js +4 -4
  65. package/dist/types/config.d.ts +4 -13
  66. package/docs/cli.md +275 -54
  67. package/package.json +8 -9
  68. package/src/ai/define.ts +1 -1
  69. package/src/ai/types.ts +1 -1
  70. package/src/app/components/Auth/AccountLayout.tsx +1 -1
  71. package/src/app/components/Auth/AuthShell.tsx +10 -4
  72. package/src/app/components/Foundations/ColorGrid.tsx +13 -14
  73. package/src/app/components/Layout/Main.tsx +2 -2
  74. package/src/app/components/McpIntegration/McpConfigDialog.tsx +12 -51
  75. package/src/app/components/Navigation/MobileNav.tsx +0 -2
  76. package/src/app/components/util/index.ts +0 -11
  77. package/src/app/lib/asset-storage.ts +25 -25
  78. package/src/app/lib/auth/email.ts +4 -5
  79. package/src/app/lib/docs-api/page-rendering.ts +1 -1
  80. package/src/app/lib/docs-api/registry.ts +2 -3
  81. package/src/app/lib/docs-api/runtime-config.ts +8 -8
  82. package/src/app/lib/mcp/components.ts +33 -29
  83. package/src/app/lib/mcp/pages.ts +26 -19
  84. package/src/app/lib/mcp/server.ts +33 -9
  85. package/src/app/lib/mcp/tokens.ts +22 -16
  86. package/src/app/lib/mcp/tool.ts +19 -0
  87. package/src/app/lib/registry-api/asset-transfer.ts +25 -25
  88. package/src/app/lib/server-module.ts +1 -1
  89. package/src/app/lib/utils.ts +7 -0
  90. package/src/app/next.config.mjs +2 -2
  91. package/src/app/pages/[...slug]/index.tsx +2 -1
  92. package/src/app/pages/account/ai.tsx +1 -1
  93. package/src/app/pages/account/index.tsx +1 -1
  94. package/src/app/pages/account/tokens.tsx +1 -1
  95. package/src/app/pages/account/users.tsx +1 -1
  96. package/src/app/pages/api/docs/nav.json.ts +1 -1
  97. package/src/app/pages/api/mcp.ts +6 -0
  98. package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +1 -1
  99. package/src/app/pages/cli/device.tsx +1 -1
  100. package/src/app/pages/foundations/colors.tsx +9 -8
  101. package/src/app/pages/foundations/index.tsx +3 -3
  102. package/src/app/pages/index.tsx +2 -2
  103. package/src/app/pages/login.tsx +10 -5
  104. package/src/app/pages/reset-password.tsx +7 -2
  105. package/src/app/pages/system/component/[component]/index.tsx +2 -5
  106. package/src/app/pages/system/index.tsx +11 -11
  107. package/src/app/pages/system/pattern/[pattern]/index.tsx +2 -2
  108. package/src/app/pages/system/tokens/components/[component]/index.tsx +0 -4
  109. package/src/app-builder/build.ts +12 -12
  110. package/src/app-builder/client-config.ts +1 -1
  111. package/src/config/loader.ts +1 -2
  112. package/src/index.ts +6 -6
  113. package/src/nav/index.ts +2 -2
  114. package/src/nav/registry.ts +3 -3
  115. package/src/nav/workspace.ts +3 -3
  116. package/src/{define.ts → providers.ts} +8 -8
  117. package/src/registry/asset-storage/define.ts +6 -6
  118. package/src/registry/asset-storage/resolve.ts +16 -20
  119. package/src/registry/asset-storage/types.ts +12 -12
  120. package/src/registry/db/schema.ts +3 -3
  121. package/src/registry/email/define.ts +1 -1
  122. package/src/registry/email/resolve.ts +23 -23
  123. package/src/registry/email/types.ts +1 -1
  124. package/src/store/registry.ts +7 -7
  125. package/src/types/config.ts +4 -14
  126. package/.editorconfig +0 -19
  127. package/.eslintrc.json +0 -4
  128. package/.prettierignore +0 -0
  129. package/.prettierrc.json +0 -8
  130. package/.vscode/settings.json +0 -17
  131. package/dist/define.d.ts +0 -10
  132. package/dist/registry/email/adapters/resend.d.ts +0 -7
  133. package/dist/registry/email/adapters/resend.js +0 -35
  134. package/src/app/components/old/Header.tsx +0 -89
  135. package/src/app/pages/assets/fonts.tsx +0 -129
  136. package/src/app/pages/assets/index.tsx +0 -91
  137. package/src/app/pages/assets/logos.tsx +0 -75
  138. package/src/registry/email/adapters/resend.ts +0 -22
  139. /package/dist/registry/email/{adapters → providers}/smtp.d.ts +0 -0
  140. /package/dist/registry/email/{adapters → providers}/smtp.js +0 -0
  141. /package/src/registry/email/{adapters → providers}/smtp.ts +0 -0
@@ -134,18 +134,13 @@ type EndpointStatus = 'checking' | 'live' | 'unreachable';
134
134
  let cachedStatus: EndpointStatus | null = null;
135
135
  let inFlight: Promise<EndpointStatus> | null = null;
136
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
137
  const probeEndpoint = (url: string): Promise<EndpointStatus> => {
145
138
  if (cachedStatus) return Promise.resolve(cachedStatus);
146
139
  if (inFlight) return inFlight;
147
- inFlight = fetch(url, { method: 'GET' })
148
- .then((res): EndpointStatus => (LIVE_STATUSES.has(res.status) ? 'live' : 'unreachable'))
140
+ // The endpoint answers HEAD with 204 before the credential check. Thus a served route never logs an
141
+ // error status to the console.
142
+ inFlight = fetch(url, { method: 'HEAD' })
143
+ .then((res): EndpointStatus => (res.ok ? 'live' : 'unreachable'))
149
144
  .catch((): EndpointStatus => 'unreachable')
150
145
  .then((status) => {
151
146
  cachedStatus = status;
@@ -184,29 +179,19 @@ const StatusDot: React.FC<{ status: EndpointStatus; className?: string }> = ({ s
184
179
  <span role="status" aria-label={DOT[status].label} title={DOT[status].label} className={cn('size-1.5 rounded-full', DOT[status].className, className)} />
185
180
  );
186
181
 
187
- const Snippet: React.FC<{ label: string; value: string; compact?: boolean }> = ({ label, value, compact }) => (
182
+ const Snippet: React.FC<{ label: string; value: string }> = ({ label, value }) => (
188
183
  <div className="min-w-0 space-y-1.5">
189
184
  <div className="flex items-center justify-between gap-2">
190
185
  <code className="truncate rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">{label}</code>
191
186
  <CopyToClipboard value={value} tooltip={`Copy ${label}`} showTooltip={false} className="relative size-7 shrink-0" />
192
187
  </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
- >
188
+ <pre className="max-h-64 overflow-auto rounded-md border bg-muted/50 p-3 text-xs leading-relaxed">
199
189
  <code>{value}</code>
200
190
  </pre>
201
191
  </div>
202
192
  );
203
193
 
204
- const ClientConfig: React.FC<{ client: McpClient; name: string; target: McpTarget; compact?: boolean }> = ({
205
- client,
206
- name,
207
- target,
208
- compact,
209
- }) => {
194
+ const ClientConfig: React.FC<{ client: McpClient; name: string; target: McpTarget }> = ({ client, name, target }) => {
210
195
  // A one-click install would write the `<your-token>` placeholder verbatim and register a server
211
196
  // that cannot connect, so a registry gets the snippet and the token pointer instead.
212
197
  const deeplink = isRegistryRuntime ? undefined : client.deeplink?.(name, target);
@@ -222,8 +207,8 @@ const ClientConfig: React.FC<{ client: McpClient; name: string; target: McpTarge
222
207
  </a>
223
208
  </Button>
224
209
  )}
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} />
210
+ {command && <Snippet label="command line" value={command} />}
211
+ <Snippet label={client.configPath} value={JSON.stringify(client.file(name, target), null, 2)} />
227
212
  <p className="text-xs text-muted-foreground">{client.note}</p>
228
213
  </div>
229
214
  );
@@ -261,8 +246,8 @@ const Credential: React.FC = () => (
261
246
  </p>
262
247
  );
263
248
 
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 }) => {
249
+ /** Endpoint, per-client config and credential guidance. */
250
+ const ConnectionDetails: React.FC<{ url: string; status: EndpointStatus }> = ({ url, status }) => {
266
251
  const name = useServerName();
267
252
  const target: McpTarget = { url, headers: isRegistryRuntime ? CREDENTIAL_HEADERS : undefined };
268
253
 
@@ -279,7 +264,7 @@ const ConnectionDetails: React.FC<{ url: string; status: EndpointStatus; compact
279
264
  </TabsList>
280
265
  {CLIENTS.map((client) => (
281
266
  <TabsContent key={client.id} value={client.id} className="min-w-0">
282
- <ClientConfig client={client} name={name} target={target} compact={compact} />
267
+ <ClientConfig client={client} name={name} target={target} />
283
268
  </TabsContent>
284
269
  ))}
285
270
  </Tabs>
@@ -323,27 +308,3 @@ const McpDialog: React.FC = () => {
323
308
  </Dialog>
324
309
  );
325
310
  };
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
- };
@@ -4,7 +4,6 @@ import { useTheme } from 'next-themes';
4
4
  import Link from 'next/link';
5
5
  import { useRouter } from 'next/router';
6
6
  import { AiAssistantMobileTrigger } from '../../components/AiAssistant/AiAssistantProvider';
7
- import { McpConfigMobile } from '../../components/McpIntegration/McpConfigDialog';
8
7
  import { Button } from '../../components/ui/button';
9
8
  import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '../../components/ui/sheet';
10
9
  import { cn } from '../../lib/utils';
@@ -72,7 +71,6 @@ export function MobileNav() {
72
71
  </Link>
73
72
  );
74
73
  })}
75
- <McpConfigMobile />
76
74
  <div className="mt-4 border-t pt-4">
77
75
  <Button variant="ghost" className="w-full justify-start font-normal" onClick={toggleTheme}>
78
76
  {theme === 'light' && (
@@ -557,17 +557,6 @@ export const fetchDocPageMetadataAndContent = (localPath: string, slug: string |
557
557
  */
558
558
  export const filterOutUndefined = <T>(value: T): value is NonNullable<T> => value !== undefined;
559
559
 
560
- /**
561
- * Create a title string from a prefix
562
- * @param prefix
563
- * @returns
564
- */
565
- export const titleString = (prefix: string | null): string => {
566
- const config = getClientRuntimeConfig();
567
- const prepend = prefix ? `${prefix} | ` : '';
568
- return `${prepend}${config?.app?.client} Design System`;
569
- };
570
-
571
560
  /**
572
561
  * Get the tokens for a component
573
562
  * @param component
@@ -1,20 +1,20 @@
1
1
  /**
2
- * Server-only asset storage runtime. Resolves the active adapter from the baked server runtime
3
- * config and lazily builds and memoizes it. Writes use the active adapter. Reads resolve by each
4
- * blob's recorded provider, so `database` blobs (inline bytea) stay readable when a `custom` adapter
5
- * is active. `database` has no adapter: its bytes live directly in `asset_blobs.content`.
2
+ * Server-only asset storage runtime. Resolves the active provider from the baked server runtime
3
+ * config and lazily builds and memoizes it. Writes use the active provider. Reads resolve by each
4
+ * blob's recorded provider, so `database` blobs (inline bytea) stay readable when a `custom` provider
5
+ * is active. `database` has no module: its bytes live directly in `asset_blobs.content`.
6
6
  *
7
7
  * Imported exclusively by registry API route handlers and the registry store.
8
8
  */
9
9
 
10
10
  import { resolveAssetStorageSettings, type ResolvedAssetStorage } from '@handoff/registry/asset-storage/resolve';
11
- import type { AssetStorage } from '@handoff/registry/asset-storage/types';
11
+ import type { StorageProvider } from '@handoff/registry/asset-storage/types';
12
12
  import type { AssetStorageProvider } from '@handoff/registry/db/schema';
13
13
  import { getServerRuntimeConfig } from './docs-api/runtime-config';
14
14
  import { loadServerModule } from './server-module';
15
15
 
16
16
  let activeCache: ResolvedAssetStorage | null = null;
17
- const adapterCache = new Map<string, AssetStorage | null>();
17
+ const providerCache = new Map<string, StorageProvider | null>();
18
18
 
19
19
  /** The active, fully-resolved asset storage settings for new uploads. */
20
20
  export const getActiveAssetStorage = (): ResolvedAssetStorage => {
@@ -32,49 +32,49 @@ export class AssetStorageError extends Error {
32
32
  }
33
33
  }
34
34
 
35
- /** Load a custom adapter module and coerce its default export (adapter object or factory) to an adapter. */
36
- const loadCustomAdapter = async (active: ResolvedAssetStorage): Promise<AssetStorage> => {
35
+ /** Load a custom provider module and coerce its default export (provider object or factory) to a provider. */
36
+ const loadCustomProvider = async (active: ResolvedAssetStorage): Promise<StorageProvider> => {
37
37
  if (!active.module) {
38
- throw new AssetStorageError('A custom asset storage adapter is selected but no module path is configured.');
38
+ throw new AssetStorageError('A custom asset storage provider is selected but no module path is configured.');
39
39
  }
40
- const candidate = (await loadServerModule(active.module, { options: active.options })) as Partial<AssetStorage> | null;
40
+ const candidate = (await loadServerModule(active.module, { options: active.options })) as Partial<StorageProvider> | null;
41
41
  if (!candidate || typeof candidate.put !== 'function' || typeof candidate.get !== 'function' || typeof candidate.delete !== 'function') {
42
- throw new AssetStorageError(`Custom asset storage module "${active.module}" must default-export a defineAssetStorage adapter.`);
42
+ throw new AssetStorageError(`Custom asset storage module "${active.module}" must default-export a defineStorageProvider() result.`);
43
43
  }
44
- return candidate as AssetStorage;
44
+ return candidate as StorageProvider;
45
45
  };
46
46
 
47
47
  /**
48
- * Resolve the {@link AssetStorage} adapter for a recorded provider, or `null` when the provider is the
49
- * inline `database` default (there is no adapter; content lives in the DB row). Reading a blob whose
48
+ * Resolve the {@link StorageProvider} for a recorded provider, or `null` when the provider is the
49
+ * inline `database` default (there is no module; content lives in the DB row). Reading a blob whose
50
50
  * provider is no longer configured throws an actionable error rather than silently failing.
51
51
  */
52
- export const getAssetStorageAdapter = async (provider: AssetStorageProvider): Promise<AssetStorage | null> => {
52
+ export const getStorageProvider = async (provider: AssetStorageProvider): Promise<StorageProvider | null> => {
53
53
  if (provider === 'database') {
54
54
  return null;
55
55
  }
56
- if (adapterCache.has(provider)) {
57
- return adapterCache.get(provider) ?? null;
56
+ if (providerCache.has(provider)) {
57
+ return providerCache.get(provider) ?? null;
58
58
  }
59
59
 
60
60
  const active = getActiveAssetStorage();
61
- if (active.adapterKind !== provider) {
61
+ if (active.provider !== provider) {
62
62
  throw new AssetStorageError(
63
- `No asset storage adapter is configured for provider "${provider}". A blob was stored by that ` +
63
+ `No asset storage module is configured for provider "${provider}". A blob was stored by that ` +
64
64
  'provider but it is no longer selected. Restore its configuration to read it.'
65
65
  );
66
66
  }
67
- const adapter = await loadCustomAdapter(active);
67
+ const storage = await loadCustomProvider(active);
68
68
 
69
- adapterCache.set(provider, adapter);
70
- return adapter;
69
+ providerCache.set(provider, storage);
70
+ return storage;
71
71
  };
72
72
 
73
- /** The adapter for the active provider (used for new uploads), or `null` for the inline database default. */
74
- export const getActiveAssetStorageAdapter = (): Promise<AssetStorage | null> => getAssetStorageAdapter(getActiveAssetStorage().adapterKind);
73
+ /** The active provider (used for new uploads), or `null` for the inline database default. */
74
+ export const getActiveStorageProvider = (): Promise<StorageProvider | null> => getStorageProvider(getActiveAssetStorage().provider);
75
75
 
76
76
  /** Reset memoized state (test seam only). */
77
77
  export const __resetAssetStorageCache = (): void => {
78
78
  activeCache = null;
79
- adapterCache.clear();
79
+ providerCache.clear();
80
80
  };
@@ -1,13 +1,12 @@
1
1
  import { isEnvReference, resolveEnvReferences } from '@handoff/config/from-env';
2
- import { createResendProvider } from '@handoff/registry/email/adapters/resend';
3
- import { createSmtpProvider } from '@handoff/registry/email/adapters/smtp';
2
+ import { createSmtpProvider } from '@handoff/registry/email/providers/smtp';
4
3
  import type { EmailProviderKind, EmailSettings } from '@handoff/registry/email/resolve';
5
4
  import type { EmailProvider } from '@handoff/registry/email/types';
6
5
  import { getServerRuntimeConfig } from '../docs-api/runtime-config';
7
6
  import { loadServerModule } from '../server-module';
8
7
 
9
8
  /** Options a provider needs before email counts as configured. */
10
- const REQUIRED_OPTIONS: Record<EmailProviderKind, readonly string[]> = { resend: ['apiKey'], smtp: ['host'], custom: [] };
9
+ const REQUIRED_OPTIONS: Record<EmailProviderKind, readonly string[]> = { smtp: ['host'], custom: [] };
11
10
 
12
11
  /**
13
12
  * The settings that still need a value, named as the administrator sets them: the config key, or the
@@ -17,6 +16,7 @@ export const missingRegistryEmailSettings = (): string[] => {
17
16
  const settings = getServerRuntimeConfig().email;
18
17
  const options = resolveEnvReferences(settings.options, process.env);
19
18
  const missing = settings.from ? [] : ['runtime.registry.email.from'];
19
+ if (!settings.provider) return [...missing, 'runtime.registry.email.provider'];
20
20
  if (settings.provider === 'custom' && !settings.module) missing.push('runtime.registry.email.module');
21
21
  for (const key of REQUIRED_OPTIONS[settings.provider]) {
22
22
  if (options[key] !== undefined) continue;
@@ -31,7 +31,7 @@ export const registryEmailIsConfigured = (): boolean => missingRegistryEmailSett
31
31
  /** The provider name for messages shown to an administrator. */
32
32
  export const registryEmailProviderName = (): string => {
33
33
  const { provider } = getServerRuntimeConfig().email;
34
- return provider === 'resend' ? 'Resend' : provider === 'smtp' ? 'SMTP' : 'the custom email provider';
34
+ return provider === 'smtp' ? 'SMTP' : 'the custom email provider';
35
35
  };
36
36
 
37
37
  let customProvider: Promise<EmailProvider> | null = null;
@@ -46,7 +46,6 @@ const loadCustomProvider = async (settings: EmailSettings, options: Record<strin
46
46
  };
47
47
 
48
48
  const getEmailProvider = (settings: EmailSettings, options: Record<string, unknown>): Promise<EmailProvider> => {
49
- if (settings.provider === 'resend') return Promise.resolve(createResendProvider(options));
50
49
  if (settings.provider === 'smtp') return Promise.resolve(createSmtpProvider(options));
51
50
  customProvider ??= loadCustomProvider(settings, options).catch((error) => {
52
51
  customProvider = null;
@@ -31,7 +31,7 @@ export const documentationMetadata = (source: Record<string, unknown>) => {
31
31
  return {
32
32
  title,
33
33
  description,
34
- metaTitle: value('metaTitle') || title,
34
+ metaTitle: value('metaTitle'),
35
35
  metaDescription: value('metaDescription') || description,
36
36
  };
37
37
  };
@@ -21,7 +21,7 @@ import {
21
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
- import { getAssetStorageAdapter } from '../asset-storage';
24
+ import { getStorageProvider } from '../asset-storage';
25
25
  // A static import includes package defaults in each registry serverless bundle. A deployed function
26
26
  // cannot read the build machine's `config/docs` directory.
27
27
  import defaultPages from '../../generated/default-pages.json';
@@ -148,8 +148,7 @@ const searchRegistryPages = async (db: RegistryDatabase, request: PageSearchRequ
148
148
  export const createRegistryDocsBackend = async (): Promise<DocsBackend> => {
149
149
  const connection = await getRegistryConnection();
150
150
  const { db } = connection;
151
- // Inject the storage-adapter resolver so object-backed asset content resolves by provider id.
152
- const store = createRegistryStore({ db, resolveAssetAdapter: getAssetStorageAdapter });
151
+ const store = createRegistryStore({ db, resolveStorageProvider: getStorageProvider });
153
152
 
154
153
  return {
155
154
  async listComponents() {
@@ -2,9 +2,9 @@ import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import type { RuntimeMode } from '@handoff/types/config';
4
4
  import { DEFAULT_DATABASE_URL_ENV, DEFAULT_REGISTRY_DRIVER, type RegistryDatabaseDriver } from '@handoff/registry/db/driver';
5
- import { DEFAULT_ASSET_STORAGE_ADAPTER, type AssetStorageSettings } from '@handoff/registry/asset-storage/resolve';
5
+ import { DEFAULT_ASSET_STORAGE_PROVIDER, type AssetStorageSettings } from '@handoff/registry/asset-storage/resolve';
6
6
  import { mergeAiConnections, parseAiConnections, type AiSettings } from '@handoff/ai/connections';
7
- import { DEFAULT_EMAIL_PROVIDER, parseEmailSettings, type EmailSettings } from '@handoff/registry/email/resolve';
7
+ import { parseEmailSettings, type EmailSettings } from '@handoff/registry/email/resolve';
8
8
 
9
9
  /**
10
10
  * Server-side runtime resolution for the docs read API.
@@ -41,7 +41,7 @@ export interface ServerRuntimeConfig {
41
41
  ai: AiSettings;
42
42
  registry: ServerRegistryRuntimeConfig;
43
43
  /**
44
- * Asset storage adapter, module, and options for registry mode. Options hold `{ $env }` references,
44
+ * Asset storage provider, module, and options for registry mode. Options hold `{ $env }` references,
45
45
  * never secret values.
46
46
  */
47
47
  assetStorage: AssetStorageSettings;
@@ -60,13 +60,13 @@ const defaults = (): ServerRuntimeConfig => ({
60
60
  driver: DEFAULT_REGISTRY_DRIVER,
61
61
  databaseUrlEnv: DEFAULT_DATABASE_URL_ENV,
62
62
  },
63
- assetStorage: { adapter: DEFAULT_ASSET_STORAGE_ADAPTER },
64
- email: { provider: DEFAULT_EMAIL_PROVIDER, options: {} },
63
+ assetStorage: { provider: DEFAULT_ASSET_STORAGE_PROVIDER },
64
+ email: { options: {} },
65
65
  });
66
66
 
67
67
  /** Parse the baked asset-storage selection from env (names/selectors only; JSON options tolerated). */
68
68
  const assetStorageFromEnv = (): AssetStorageSettings => {
69
- const adapter = process.env.HANDOFF_ASSET_STORAGE_ADAPTER?.trim();
69
+ const provider = process.env.HANDOFF_ASSET_STORAGE_PROVIDER?.trim();
70
70
  let options: Record<string, unknown> | undefined;
71
71
  const rawOptions = process.env.HANDOFF_ASSET_STORAGE_OPTIONS?.trim();
72
72
  if (rawOptions) {
@@ -79,7 +79,7 @@ const assetStorageFromEnv = (): AssetStorageSettings => {
79
79
  }
80
80
  const maxInline = Number(process.env.HANDOFF_ASSET_STORAGE_MAX_INLINE_BYTES);
81
81
  return {
82
- adapter: adapter === 'custom' ? adapter : DEFAULT_ASSET_STORAGE_ADAPTER,
82
+ provider: provider === 'custom' ? provider : DEFAULT_ASSET_STORAGE_PROVIDER,
83
83
  module: process.env.HANDOFF_ASSET_STORAGE_MODULE?.trim() || undefined,
84
84
  maxInlineBytes: Number.isFinite(maxInline) && maxInline > 0 ? maxInline : undefined,
85
85
  options,
@@ -167,7 +167,7 @@ export const getServerRuntimeConfig = (): ServerRuntimeConfig => {
167
167
  ? parsed.registry.databaseUrlEnv.trim()
168
168
  : DEFAULT_DATABASE_URL_ENV;
169
169
  const assetStorage: AssetStorageSettings =
170
- parsed?.assetStorage && typeof parsed.assetStorage === 'object' ? parsed.assetStorage : { adapter: DEFAULT_ASSET_STORAGE_ADAPTER };
170
+ parsed?.assetStorage && typeof parsed.assetStorage === 'object' ? parsed.assetStorage : { provider: DEFAULT_ASSET_STORAGE_PROVIDER };
171
171
  cached = {
172
172
  mode: parsed?.mode === 'registry' ? 'registry' : 'workspace',
173
173
  mcp: parsed?.mcp !== false,
@@ -1,10 +1,9 @@
1
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { z } from 'zod';
3
1
  import { buildArtifactUrl } from '@handoff/artifacts/url';
4
2
  import type { SlotMetadata } from '@handoff/transformers/preview/component';
5
3
  import type { ComponentListObject, OptionalPreviewRender, TransformComponentTokensResult } from '@handoff/transformers/preview/types';
6
4
  import type { DocsBackend } from '../docs-api/backend';
7
5
  import { basePath, fail, ok, read } from './result';
6
+ import { defineTool, type McpTool } from './tool';
8
7
 
9
8
  /** The component tools: search, read, one preview, and the source. */
10
9
 
@@ -16,10 +15,12 @@ const MAX_SEARCH_LIMIT = 100;
16
15
  const SOURCE_FIELDS = ['code', 'css', 'sass', 'js'] as const;
17
16
  type SourceField = (typeof SOURCE_FIELDS)[number];
18
17
 
19
- export const registerComponentTools = (server: McpServer): void => {
20
- server.registerTool(
21
- 'handoff_search_components',
18
+ const componentId = { type: 'string', description: 'Component id, as returned by handoff_search_components.' };
19
+
20
+ export const componentTools: McpTool[] = [
21
+ defineTool<{ query?: string; group?: string; category?: string; tag?: string; limit?: number }>(
22
22
  {
23
+ name: 'handoff_search_components',
23
24
  title: 'Search components',
24
25
  description:
25
26
  'Find components. `query` matches id, title, group, categories and tags. With no arguments, ' +
@@ -27,11 +28,14 @@ export const registerComponentTools = (server: McpServer): void => {
27
28
  'tags. Read one component with handoff_get_component.',
28
29
  annotations: { readOnlyHint: true, openWorldHint: false },
29
30
  inputSchema: {
30
- query: z.string().optional().describe('Text to find. Case-insensitive substring match.'),
31
- group: z.string().optional().describe('Group name, for example "Form Elements". Exact match, case-insensitive.'),
32
- category: z.string().optional().describe('Category name. Exact match, case-insensitive.'),
33
- tag: z.string().optional().describe('Tag name. Exact match, case-insensitive.'),
34
- limit: z.number().int().min(1).max(MAX_SEARCH_LIMIT).optional().describe(`Max results (default ${DEFAULT_SEARCH_LIMIT}).`),
31
+ type: 'object',
32
+ properties: {
33
+ query: { type: 'string', description: 'Text to find. Case-insensitive substring match.' },
34
+ group: { type: 'string', description: 'Group name, for example "Form Elements". Exact match, case-insensitive.' },
35
+ category: { type: 'string', description: 'Category name. Exact match, case-insensitive.' },
36
+ tag: { type: 'string', description: 'Tag name. Exact match, case-insensitive.' },
37
+ limit: { type: 'integer', minimum: 1, maximum: MAX_SEARCH_LIMIT, description: `Max results (default ${DEFAULT_SEARCH_LIMIT}).` },
38
+ },
35
39
  },
36
40
  },
37
41
  async ({ query, group, category, tag, limit }) =>
@@ -42,11 +46,11 @@ export const registerComponentTools = (server: McpServer): void => {
42
46
  const components = matched.slice(0, limit ?? DEFAULT_SEARCH_LIMIT).map(toComponentSummary);
43
47
  return ok({ total: matched.length, returned: components.length, components });
44
48
  })
45
- );
49
+ ),
46
50
 
47
- server.registerTool(
48
- 'handoff_get_component',
51
+ defineTool<{ id: string }>(
49
52
  {
53
+ name: 'handoff_get_component',
50
54
  title: 'Get component',
51
55
  description:
52
56
  'One component: properties, variants, usage and guidelines. `usage` is the snippet of the first ' +
@@ -56,9 +60,7 @@ export const registerComponentTools = (server: McpServer): void => {
56
60
  'handoff_get_component_preview. `tokens.set`, when present, is the id to pass to ' +
57
61
  'handoff_get_tokens. Do not derive a token set id from the component id.',
58
62
  annotations: { readOnlyHint: true, openWorldHint: false },
59
- inputSchema: {
60
- id: z.string().describe('Component id, as returned by handoff_search_components.'),
61
- },
63
+ inputSchema: { type: 'object', properties: { id: componentId }, required: ['id'] },
62
64
  },
63
65
  async ({ id }) =>
64
66
  read(`component "${id}"`, async (backend) => {
@@ -76,19 +78,23 @@ export const registerComponentTools = (server: McpServer): void => {
76
78
  )
77
79
  );
78
80
  })
79
- );
81
+ ),
80
82
 
81
- server.registerTool(
82
- 'handoff_get_component_preview',
83
+ defineTool<{ id: string; preview: string }>(
83
84
  {
85
+ name: 'handoff_get_component_preview',
84
86
  title: 'Get component preview',
85
87
  description:
86
88
  'One state of a component: `values` (the args), `usage` (the snippet) and `html` (the rendered ' +
87
89
  'markup). `html` is absent when the component is not built.',
88
90
  annotations: { readOnlyHint: true, openWorldHint: false },
89
91
  inputSchema: {
90
- id: z.string().describe('Component id, as returned by handoff_search_components.'),
91
- preview: z.string().describe('Preview id, as listed in `previews[].id` by handoff_get_component.'),
92
+ type: 'object',
93
+ properties: {
94
+ id: componentId,
95
+ preview: { type: 'string', description: 'Preview id, as listed in `previews[].id` by handoff_get_component.' },
96
+ },
97
+ required: ['id', 'preview'],
92
98
  },
93
99
  },
94
100
  async ({ id, preview }) =>
@@ -108,20 +114,18 @@ export const registerComponentTools = (server: McpServer): void => {
108
114
  }
109
115
  return ok(toPreviewResult(record, preview, found, basePath(), await readPreviewDocument(backend, id, preview)));
110
116
  })
111
- );
117
+ ),
112
118
 
113
- server.registerTool(
114
- 'handoff_get_component_source',
119
+ defineTool<{ id: string }>(
115
120
  {
121
+ name: 'handoff_get_component_source',
116
122
  title: 'Get component source',
117
123
  description:
118
124
  'The implementation of a component: `code` (the component or template) and, when present, ' +
119
125
  '`css`, `sass` and `js`. Use it only when handoff_get_component is not enough. The source ' +
120
126
  'fields are absent when the component is not built.',
121
127
  annotations: { readOnlyHint: true, openWorldHint: false },
122
- inputSchema: {
123
- id: z.string().describe('Component id, as returned by handoff_search_components.'),
124
- },
128
+ inputSchema: { type: 'object', properties: { id: componentId }, required: ['id'] },
125
129
  },
126
130
  async ({ id }) =>
127
131
  read(`source of component "${id}"`, async (backend) => {
@@ -131,8 +135,8 @@ export const registerComponentTools = (server: McpServer): void => {
131
135
  }
132
136
  return ok(toComponentSource(record, await readComponentArtifact(backend, id)));
133
137
  })
134
- );
135
- };
138
+ ),
139
+ ];
136
140
 
137
141
  const componentNotFound = (id: string) =>
138
142
  fail(`Component "${id}" was not found. Use handoff_search_components to list the components that exist.`);
@@ -1,16 +1,15 @@
1
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { z } from 'zod';
3
1
  import type { PageDetail } from '../docs-api/records';
4
2
  import { createSearchRequest, DEFAULT_RESULT_LIMIT, MAX_QUERY_LENGTH, MAX_RESULT_LIMIT, MAX_TERMS } from '../docs-api/search';
5
3
  import { pageIdFromUrl } from './page-url';
6
4
  import { fail, ok, read } from './result';
5
+ import { defineTool, type McpTool } from './tool';
7
6
 
8
7
  /** The documentation page tools: search and read. */
9
8
 
10
- export const registerPageTools = (server: McpServer): void => {
11
- server.registerTool(
12
- 'handoff_search_pages',
9
+ export const pageTools: McpTool[] = [
10
+ defineTool<{ query: string; group?: string; limit?: number }>(
13
11
  {
12
+ name: 'handoff_search_pages',
14
13
  title: 'Search pages',
15
14
  description:
16
15
  'Search documentation pages by title, description and Markdown body. Returns ranked `results` ' +
@@ -18,14 +17,18 @@ export const registerPageTools = (server: McpServer): void => {
18
17
  'Read a page with handoff_get_page.',
19
18
  annotations: { readOnlyHint: true, openWorldHint: false },
20
19
  inputSchema: {
21
- query: z
22
- .string()
23
- .describe(
24
- `Search text, at most ${MAX_QUERY_LENGTH} characters and ${MAX_TERMS} terms. Case-insensitive. ` +
25
- 'Terms shorter than two characters are ignored.'
26
- ),
27
- group: z.string().optional().describe('Page group name. Exact match, case-insensitive.'),
28
- limit: z.number().int().min(1).max(MAX_RESULT_LIMIT).optional().describe(`Max results (default ${DEFAULT_RESULT_LIMIT}).`),
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'],
29
32
  },
30
33
  },
31
34
  async ({ query, group, limit }) => {
@@ -35,11 +38,11 @@ export const registerPageTools = (server: McpServer): void => {
35
38
  }
36
39
  return read('pages', async (backend) => ok(await backend.searchPages({ ...parsed.request, group: group?.trim() || undefined })));
37
40
  }
38
- );
41
+ ),
39
42
 
40
- server.registerTool(
41
- 'handoff_get_page',
43
+ defineTool<{ url: string }>(
42
44
  {
45
+ name: 'handoff_get_page',
43
46
  title: 'Get page',
44
47
  description:
45
48
  'One documentation page by its URL from handoff_search_pages, for example /guides/setup, or / ' +
@@ -47,7 +50,11 @@ export const registerPageTools = (server: McpServer): void => {
47
50
  'external-link page, the external site is not fetched.',
48
51
  annotations: { readOnlyHint: true, openWorldHint: false },
49
52
  inputSchema: {
50
- url: z.string().describe('Internal route, without a base path, query string or fragment.'),
53
+ type: 'object',
54
+ properties: {
55
+ url: { type: 'string', description: 'Internal route, without a base path, query string or fragment.' },
56
+ },
57
+ required: ['url'],
51
58
  },
52
59
  },
53
60
  async ({ url }) => {
@@ -63,8 +70,8 @@ export const registerPageTools = (server: McpServer): void => {
63
70
  return ok(toPageResult(page));
64
71
  });
65
72
  }
66
- );
67
- };
73
+ ),
74
+ ];
68
75
 
69
76
  export const toPageResult = (page: PageDetail) => ({
70
77
  id: page.id,