handoff-app 2.0.0-11 → 2.0.0-12

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.
@@ -510,7 +510,7 @@ containers, custom Node servers, and other non-Vercel hosts.
510
510
  * Resolve the set of npm packages the packaged registry app must be able to `require`/`import` at
511
511
  * runtime, given the configured database driver. `next`/`react`/`react-dom` run the server and React
512
512
  * runtime; `drizzle-orm` backs both the request-time DB client and the migration runner; the MCP SDK
513
- * (and its `zod` peer) is only needed when `/api/mcp/` is enabled; the driver package is
513
+ * is only needed when `/api/mcp/` is enabled; the driver package is
514
514
  * driver-specific (the Neon serverless driver also needs `ws` for its Node WebSocket transport); the
515
515
  * SMTP email provider needs `nodemailer`.
516
516
  */
@@ -520,7 +520,7 @@ const getRequiredRegistryRuntimeModules = (handoff) => {
520
520
  // The assistant runs the MCP tools in-process, so it needs the MCP SDK whether or not the HTTP
521
521
  // endpoint is served.
522
522
  if ((0, config_1.isMcpEnabled)(handoff.config) || ai.enabled) {
523
- base.push('@modelcontextprotocol/sdk', 'zod');
523
+ base.push('@modelcontextprotocol/sdk');
524
524
  }
525
525
  if (ai.enabled) {
526
526
  base.push('ai', '@ai-sdk/openai-compatible');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-app",
3
- "version": "2.0.0-11",
3
+ "version": "2.0.0-12",
4
4
  "description": "Automated documentation toolchain for building client side documentation from figma",
5
5
  "author ": {
6
6
  "name": "Convertiv",
@@ -167,8 +167,7 @@
167
167
  "vaul": "^1.1.2",
168
168
  "vite": "^6.3.5",
169
169
  "ws": "^8.18.3",
170
- "yargs": "^17.7.2",
171
- "zod": "^4.4.3"
170
+ "yargs": "^17.7.2"
172
171
  },
173
172
  "peerDependencies": {
174
173
  "typescript": ">=5.6 <7"
@@ -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,
@@ -1,7 +1,10 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { registerComponentTools } from './components';
3
- import { registerPageTools } from './pages';
4
- import { registerTokenTools } from './tokens';
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';
5
8
 
6
9
  /**
7
10
  * The Handoff MCP server: read-only access to the design system's components, tokens and pages.
@@ -21,10 +24,19 @@ import { registerTokenTools } from './tokens';
21
24
  /** Version of the MCP surface itself, reported in `initialize`. Bump when a tool contract changes. */
22
25
  const MCP_SERVER_VERSION = '2.0.0';
23
26
 
24
- export const createMcpServer = (): McpServer => {
25
- const server = new McpServer(
27
+ const tools = [...componentTools, ...tokenTools, ...pageTools];
28
+
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
+ );
34
+
35
+ export const createMcpServer = (): Server => {
36
+ const server = new Server(
26
37
  { name: 'handoff', version: MCP_SERVER_VERSION },
27
38
  {
39
+ capabilities: { tools: {} },
28
40
  instructions:
29
41
  'Design-system knowledge for this project. Before writing UI code, find an existing component ' +
30
42
  'with handoff_search_components and read it with handoff_get_component. Take colors, typography ' +
@@ -33,8 +45,20 @@ export const createMcpServer = (): McpServer => {
33
45
  }
34
46
  );
35
47
 
36
- registerComponentTools(server);
37
- registerTokenTools(server);
38
- registerPageTools(server);
48
+ server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: tools.map((tool) => tool.definition) }));
49
+
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.`);
55
+ }
56
+ const args = tool.validate(params.arguments ?? {});
57
+ if (!args.valid) {
58
+ return fail(`Invalid arguments for tool "${params.name}": ${args.errorMessage}.`);
59
+ }
60
+ return tool.run(args.data);
61
+ });
62
+
39
63
  return server;
40
64
  };
@@ -1,15 +1,16 @@
1
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { z } from 'zod';
3
1
  import type { TokenArtifactResource } from '@handoff/store';
4
2
  import type { DocsBackend, TokenSetDetail } from '../docs-api/backend';
5
3
  import { fail, ok, read } from './result';
4
+ import { defineTool, type McpTool } from './tool';
6
5
 
7
6
  /** The token tool, and the reshaping that turns stored token sets into variables an agent can emit. */
8
7
 
9
- export const registerTokenTools = (server: McpServer): void => {
10
- server.registerTool(
11
- 'handoff_get_tokens',
8
+ type TokenFormat = 'css' | 'scss' | 'styleDictionary' | 'types';
9
+
10
+ export const tokenTools: McpTool[] = [
11
+ defineTool<{ set?: string; kind?: 'foundation' | 'component'; format?: TokenFormat }>(
12
12
  {
13
+ name: 'handoff_get_tokens',
13
14
  title: 'Get design tokens',
14
15
  description:
15
16
  'Design tokens. With no arguments: the list of token sets, plus all foundation tokens (colors, ' +
@@ -20,15 +21,20 @@ export const registerTokenTools = (server: McpServer): void => {
20
21
  'stylesheet is available.',
21
22
  annotations: { readOnlyHint: true, openWorldHint: false },
22
23
  inputSchema: {
23
- set: z.string().optional().describe('Token set id from the list, for example "foundation/colors".'),
24
- kind: z
25
- .enum(['foundation', 'component'])
26
- .optional()
27
- .describe('Without `set`: list only sets of this kind. `component` leaves out the foundation tokens.'),
28
- format: z
29
- .enum(['css', 'scss', 'styleDictionary', 'types'])
30
- .optional()
31
- .describe('With `set`: return the generated file in this format.'),
24
+ type: 'object',
25
+ properties: {
26
+ set: { type: 'string', description: 'Token set id from the list, for example "foundation/colors".' },
27
+ kind: {
28
+ type: 'string',
29
+ enum: ['foundation', 'component'],
30
+ description: 'Without `set`: list only sets of this kind. `component` leaves out the foundation tokens.',
31
+ },
32
+ format: {
33
+ type: 'string',
34
+ enum: ['css', 'scss', 'styleDictionary', 'types'] satisfies TokenFormat[],
35
+ description: 'With `set`: return the generated file in this format.',
36
+ },
37
+ },
32
38
  },
33
39
  },
34
40
  async ({ set, kind, format }) =>
@@ -57,8 +63,8 @@ export const registerTokenTools = (server: McpServer): void => {
57
63
  }
58
64
  return ok({ id: detail.id, kind: detail.kind, format, path: artifact.path, content: artifact.content });
59
65
  })
60
- );
61
- };
66
+ ),
67
+ ];
62
68
 
63
69
  /**
64
70
  * A token set's payload, under a key naming which of the four forms it is, so the agent never has to
@@ -0,0 +1,19 @@
1
+ import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js';
2
+
3
+ /**
4
+ * One MCP tool: its listing, with a plain JSON Schema for its input, and its handler.
5
+ *
6
+ * Plain JSON Schema keeps Zod out of the app. The Next build type-checks this code against the
7
+ * consumer's `node_modules`, where a second Zod copy makes the SDK's Zod types incompatible with ours.
8
+ */
9
+ export interface McpTool {
10
+ definition: Tool;
11
+ /** Runs with arguments already validated against `definition.inputSchema`. */
12
+ run: (args: unknown) => Promise<CallToolResult>;
13
+ }
14
+
15
+ /** `Args` must match `definition.inputSchema`; the server validates against the schema before `run`. */
16
+ export const defineTool = <Args>(definition: Tool, run: (args: Args) => Promise<CallToolResult>): McpTool => ({
17
+ definition,
18
+ run: run as McpTool['run'],
19
+ });
@@ -582,7 +582,7 @@ containers, custom Node servers, and other non-Vercel hosts.
582
582
  * Resolve the set of npm packages the packaged registry app must be able to `require`/`import` at
583
583
  * runtime, given the configured database driver. `next`/`react`/`react-dom` run the server and React
584
584
  * runtime; `drizzle-orm` backs both the request-time DB client and the migration runner; the MCP SDK
585
- * (and its `zod` peer) is only needed when `/api/mcp/` is enabled; the driver package is
585
+ * is only needed when `/api/mcp/` is enabled; the driver package is
586
586
  * driver-specific (the Neon serverless driver also needs `ws` for its Node WebSocket transport); the
587
587
  * SMTP email provider needs `nodemailer`.
588
588
  */
@@ -592,7 +592,7 @@ const getRequiredRegistryRuntimeModules = (handoff: Handoff): string[] => {
592
592
  // The assistant runs the MCP tools in-process, so it needs the MCP SDK whether or not the HTTP
593
593
  // endpoint is served.
594
594
  if (isMcpEnabled(handoff.config) || ai.enabled) {
595
- base.push('@modelcontextprotocol/sdk', 'zod');
595
+ base.push('@modelcontextprotocol/sdk');
596
596
  }
597
597
  if (ai.enabled) {
598
598
  base.push('ai', '@ai-sdk/openai-compatible');