diff --git a/tests/tools/cdn.test.ts b/tests/tools/cdn.test.ts
new file mode 100644
index 0000000..c1076c9
--- /dev/null
+++ b/tests/tools/cdn.test.ts
@@ -0,0 +1,226 @@
+/**
+ * Tests for the resolve_cdn_cem tool dispatcher.
+ * Covers isCdnTool, handleCdnCall, argument validation,
+ * and response formatting.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { isCdnTool, handleCdnCall, CDN_TOOL_DEFINITIONS } from '../../packages/core/src/tools/cdn.js';
+import type { McpWcConfig } from '../../packages/core/src/config.js';
+
+// ─── Mocks ────────────────────────────────────────────────────────────────────
+
+vi.mock('../../packages/core/src/handlers/cdn.js', () => ({
+ resolveCdnCem: vi.fn(async (pkg: string, version: string, registry: string) => ({
+ cachePath: `/tmp/cdn-cache/${pkg}@${version}.json`,
+ componentCount: 5,
+ formatted: `Resolved ${pkg}@${version} from ${registry}: 5 component(s). Library ID: "shoelace". Cached to .mcp-wc/cdn-cache/shoelace@${version}.json.`,
+ registered: false,
+ libraryId: 'shoelace',
+ })),
+}));
+
+// ─── Fixtures ─────────────────────────────────────────────────────────────────
+
+const FAKE_CONFIG: McpWcConfig = {
+ cemPath: 'custom-elements.json',
+ projectRoot: '/fake/project',
+ componentPrefix: '',
+ healthHistoryDir: '.mcp-wc/health',
+ tsconfigPath: 'tsconfig.json',
+ tokensPath: null,
+ cdnBase: null,
+ watch: false,
+};
+
+// ─── CDN_TOOL_DEFINITIONS ─────────────────────────────────────────────────────
+
+describe('CDN_TOOL_DEFINITIONS', () => {
+ it('exports exactly 1 tool definition', () => {
+ expect(CDN_TOOL_DEFINITIONS).toHaveLength(1);
+ });
+
+ it('defines resolve_cdn_cem', () => {
+ const names = CDN_TOOL_DEFINITIONS.map((t) => t.name);
+ expect(names).toContain('resolve_cdn_cem');
+ });
+
+ it('resolve_cdn_cem schema requires package', () => {
+ const def = CDN_TOOL_DEFINITIONS.find((t) => t.name === 'resolve_cdn_cem')!;
+ expect(def.inputSchema.required).toContain('package');
+ });
+});
+
+// ─── isCdnTool ────────────────────────────────────────────────────────────────
+
+describe('isCdnTool', () => {
+ it('returns true for resolve_cdn_cem', () => {
+ expect(isCdnTool('resolve_cdn_cem')).toBe(true);
+ });
+
+ it('returns false for unknown tool names', () => {
+ expect(isCdnTool('get_component')).toBe(false);
+ expect(isCdnTool('scaffold_component')).toBe(false);
+ expect(isCdnTool('')).toBe(false);
+ });
+
+ it('returns false for near-matches', () => {
+ expect(isCdnTool('resolve_cdn')).toBe(false);
+ expect(isCdnTool('cdn_cem')).toBe(false);
+ });
+});
+
+// ─── handleCdnCall — valid inputs ─────────────────────────────────────────────
+
+describe('handleCdnCall — valid inputs', () => {
+ it('returns a success result for resolve_cdn_cem with only package', async () => {
+ const result = await handleCdnCall(
+ 'resolve_cdn_cem',
+ { package: '@shoelace-style/shoelace' },
+ FAKE_CONFIG,
+ );
+ expect(result.isError).toBeFalsy();
+ });
+
+ it('result content includes formatted output string', async () => {
+ const result = await handleCdnCall(
+ 'resolve_cdn_cem',
+ { package: '@shoelace-style/shoelace' },
+ FAKE_CONFIG,
+ );
+ expect(result.content[0].text).toContain('Resolved');
+ });
+
+ it('accepts optional version', async () => {
+ const result = await handleCdnCall(
+ 'resolve_cdn_cem',
+ { package: '@shoelace-style/shoelace', version: '2.15.0' },
+ FAKE_CONFIG,
+ );
+ expect(result.isError).toBeFalsy();
+ });
+
+ it('accepts optional registry: unpkg', async () => {
+ const result = await handleCdnCall(
+ 'resolve_cdn_cem',
+ { package: '@shoelace-style/shoelace', registry: 'unpkg' },
+ FAKE_CONFIG,
+ );
+ expect(result.isError).toBeFalsy();
+ });
+
+ it('accepts optional register: true', async () => {
+ const result = await handleCdnCall(
+ 'resolve_cdn_cem',
+ { package: '@shoelace-style/shoelace', register: true },
+ FAKE_CONFIG,
+ );
+ expect(result.isError).toBeFalsy();
+ });
+
+ it('accepts optional cemPath', async () => {
+ const result = await handleCdnCall(
+ 'resolve_cdn_cem',
+ { package: '@shoelace-style/shoelace', cemPath: 'dist/custom-elements.json' },
+ FAKE_CONFIG,
+ );
+ expect(result.isError).toBeFalsy();
+ });
+
+ it('defaults version to latest when omitted', async () => {
+ const { resolveCdnCem } = await import('../../packages/core/src/handlers/cdn.js');
+ vi.mocked(resolveCdnCem).mockClear();
+ await handleCdnCall(
+ 'resolve_cdn_cem',
+ { package: '@shoelace-style/shoelace' },
+ FAKE_CONFIG,
+ );
+ expect(vi.mocked(resolveCdnCem)).toHaveBeenCalledWith(
+ '@shoelace-style/shoelace',
+ 'latest',
+ 'jsdelivr',
+ FAKE_CONFIG,
+ false,
+ undefined,
+ );
+ });
+
+ it('defaults registry to jsdelivr when omitted', async () => {
+ const { resolveCdnCem } = await import('../../packages/core/src/handlers/cdn.js');
+ vi.mocked(resolveCdnCem).mockClear();
+ await handleCdnCall(
+ 'resolve_cdn_cem',
+ { package: '@shoelace-style/shoelace' },
+ FAKE_CONFIG,
+ );
+ const [, , registry] = vi.mocked(resolveCdnCem).mock.calls[0];
+ expect(registry).toBe('jsdelivr');
+ });
+});
+
+// ─── handleCdnCall — error cases ──────────────────────────────────────────────
+
+describe('handleCdnCall — error cases', () => {
+ it('returns error for unknown tool name', async () => {
+ const result = await handleCdnCall('nonexistent_tool', {}, FAKE_CONFIG);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Unknown CDN tool');
+ });
+
+ it('returns error for empty tool name', async () => {
+ const result = await handleCdnCall('', {}, FAKE_CONFIG);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Unknown CDN tool');
+ });
+
+ it('returns error when package is missing', async () => {
+ const result = await handleCdnCall('resolve_cdn_cem', {}, FAKE_CONFIG);
+ expect(result.isError).toBe(true);
+ });
+
+ it('returns error for invalid registry value', async () => {
+ const result = await handleCdnCall(
+ 'resolve_cdn_cem',
+ { package: '@shoelace-style/shoelace', registry: 'invalid-cdn' },
+ FAKE_CONFIG,
+ );
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ─── handleCdnCall — handler error propagation ────────────────────────────────
+
+describe('handleCdnCall — handler error propagation', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('returns error when resolveCdnCem handler throws a network error', async () => {
+ const { resolveCdnCem } = await import('../../packages/core/src/handlers/cdn.js');
+ vi.mocked(resolveCdnCem).mockImplementationOnce(async () => {
+ throw new Error('CDN fetch failed: no CEM found for @shoelace-style/shoelace@latest');
+ });
+
+ const result = await handleCdnCall(
+ 'resolve_cdn_cem',
+ { package: '@shoelace-style/shoelace' },
+ FAKE_CONFIG,
+ );
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('CDN fetch failed');
+ });
+
+ it('returns error when resolveCdnCem handler throws a generic error', async () => {
+ const { resolveCdnCem } = await import('../../packages/core/src/handlers/cdn.js');
+ vi.mocked(resolveCdnCem).mockImplementationOnce(async () => {
+ throw new Error('Unexpected error');
+ });
+
+ const result = await handleCdnCall(
+ 'resolve_cdn_cem',
+ { package: '@shoelace-style/shoelace' },
+ FAKE_CONFIG,
+ );
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Unexpected error');
+ });
+});
diff --git a/tests/tools/composition.test.ts b/tests/tools/composition.test.ts
new file mode 100644
index 0000000..8e8ef81
--- /dev/null
+++ b/tests/tools/composition.test.ts
@@ -0,0 +1,235 @@
+/**
+ * Tests for the get_composition_example tool dispatcher.
+ * Covers isCompositionTool, handleCompositionCall, argument validation,
+ * and response formatting.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ isCompositionTool,
+ handleCompositionCall,
+ COMPOSITION_TOOL_DEFINITIONS,
+} from '../../packages/core/src/tools/composition.js';
+import type { Cem } from '../../packages/core/src/handlers/cem.js';
+
+// ─── Mocks ────────────────────────────────────────────────────────────────────
+
+vi.mock('../../packages/core/src/handlers/composition.js', () => ({
+ getCompositionExample: vi.fn((cem: unknown, tagNames: string[]) => ({
+ components: tagNames.map((t) => ({ tagName: t, found: true })),
+ html: tagNames.map((t) => `<${t}>${t}>`).join('\n'),
+ description: `Composition of ${tagNames.join(' + ')}`,
+ })),
+}));
+
+// ─── Fixtures ─────────────────────────────────────────────────────────────────
+
+const FAKE_CEM: Cem = { schemaVersion: '1.0.0', modules: [] };
+
+const RICH_CEM: Cem = {
+ schemaVersion: '1.0.0',
+ modules: [
+ {
+ kind: 'javascript-module',
+ path: 'src/hx-button.ts',
+ declarations: [
+ {
+ kind: 'class',
+ name: 'HxButton',
+ tagName: 'hx-button',
+ members: [],
+ slots: [{ name: '' }, { name: 'prefix' }],
+ },
+ ],
+ },
+ {
+ kind: 'javascript-module',
+ path: 'src/hx-card.ts',
+ declarations: [
+ {
+ kind: 'class',
+ name: 'HxCard',
+ tagName: 'hx-card',
+ members: [],
+ slots: [{ name: '' }, { name: 'header' }, { name: 'footer' }],
+ },
+ ],
+ },
+ ],
+};
+
+// ─── COMPOSITION_TOOL_DEFINITIONS ─────────────────────────────────────────────
+
+describe('COMPOSITION_TOOL_DEFINITIONS', () => {
+ it('exports exactly 1 tool definition', () => {
+ expect(COMPOSITION_TOOL_DEFINITIONS).toHaveLength(1);
+ });
+
+ it('defines get_composition_example', () => {
+ const names = COMPOSITION_TOOL_DEFINITIONS.map((t) => t.name);
+ expect(names).toContain('get_composition_example');
+ });
+
+ it('get_composition_example schema requires tagNames', () => {
+ const def = COMPOSITION_TOOL_DEFINITIONS.find((t) => t.name === 'get_composition_example')!;
+ expect(def.inputSchema.required).toContain('tagNames');
+ });
+});
+
+// ─── isCompositionTool ────────────────────────────────────────────────────────
+
+describe('isCompositionTool', () => {
+ it('returns true for get_composition_example', () => {
+ expect(isCompositionTool('get_composition_example')).toBe(true);
+ });
+
+ it('returns false for unknown tool names', () => {
+ expect(isCompositionTool('scaffold_component')).toBe(false);
+ expect(isCompositionTool('get_component')).toBe(false);
+ expect(isCompositionTool('')).toBe(false);
+ });
+
+ it('returns false for near-matches', () => {
+ expect(isCompositionTool('composition')).toBe(false);
+ expect(isCompositionTool('get_composition')).toBe(false);
+ });
+});
+
+// ─── handleCompositionCall — valid inputs ─────────────────────────────────────
+
+describe('handleCompositionCall — valid inputs', () => {
+ it('returns a success result for a single tagName', () => {
+ const result = handleCompositionCall(
+ 'get_composition_example',
+ { tagNames: ['hx-button'] },
+ FAKE_CEM,
+ );
+ expect(result.isError).toBeFalsy();
+ });
+
+ it('returns JSON-parseable content', () => {
+ const result = handleCompositionCall(
+ 'get_composition_example',
+ { tagNames: ['hx-button'] },
+ FAKE_CEM,
+ );
+ expect(() => JSON.parse(result.content[0].text)).not.toThrow();
+ });
+
+ it('accepts 2 tagNames', () => {
+ const result = handleCompositionCall(
+ 'get_composition_example',
+ { tagNames: ['hx-card', 'hx-button'] },
+ RICH_CEM,
+ );
+ expect(result.isError).toBeFalsy();
+ });
+
+ it('accepts 3 tagNames', () => {
+ const result = handleCompositionCall(
+ 'get_composition_example',
+ { tagNames: ['hx-card', 'hx-button', 'hx-badge'] },
+ RICH_CEM,
+ );
+ expect(result.isError).toBeFalsy();
+ });
+
+ it('accepts 4 tagNames (maximum)', () => {
+ const result = handleCompositionCall(
+ 'get_composition_example',
+ { tagNames: ['hx-card', 'hx-button', 'hx-badge', 'hx-icon'] },
+ RICH_CEM,
+ );
+ expect(result.isError).toBeFalsy();
+ });
+
+ it('result includes html field', () => {
+ const result = handleCompositionCall(
+ 'get_composition_example',
+ { tagNames: ['hx-button'] },
+ FAKE_CEM,
+ );
+ const parsed = JSON.parse(result.content[0].text);
+ expect(parsed.html).toBeDefined();
+ });
+
+ it('result includes description field', () => {
+ const result = handleCompositionCall(
+ 'get_composition_example',
+ { tagNames: ['hx-button', 'hx-card'] },
+ RICH_CEM,
+ );
+ const parsed = JSON.parse(result.content[0].text);
+ expect(parsed.description).toBeDefined();
+ });
+});
+
+// ─── handleCompositionCall — error cases ──────────────────────────────────────
+
+describe('handleCompositionCall — error cases', () => {
+ it('returns error for unknown tool name', () => {
+ const result = handleCompositionCall('nonexistent_tool', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Unknown composition tool');
+ });
+
+ it('returns error for empty tool name', () => {
+ const result = handleCompositionCall('', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Unknown composition tool');
+ });
+
+ it('returns error when tagNames is missing', () => {
+ const result = handleCompositionCall('get_composition_example', {}, FAKE_CEM);
+ expect(result.isError).toBe(true);
+ });
+
+ it('returns error when tagNames is empty array', () => {
+ const result = handleCompositionCall(
+ 'get_composition_example',
+ { tagNames: [] },
+ FAKE_CEM,
+ );
+ expect(result.isError).toBe(true);
+ });
+
+ it('returns error when tagNames exceeds 4 items', () => {
+ const result = handleCompositionCall(
+ 'get_composition_example',
+ { tagNames: ['a-one', 'a-two', 'a-three', 'a-four', 'a-five'] },
+ FAKE_CEM,
+ );
+ expect(result.isError).toBe(true);
+ });
+
+ it('returns error when tagNames is not an array', () => {
+ const result = handleCompositionCall(
+ 'get_composition_example',
+ { tagNames: 'hx-button' },
+ FAKE_CEM,
+ );
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ─── handleCompositionCall — handler error propagation ────────────────────────
+
+describe('handleCompositionCall — handler error propagation', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('returns error when getCompositionExample handler throws', async () => {
+ const { getCompositionExample } = await import('../../packages/core/src/handlers/composition.js');
+ vi.mocked(getCompositionExample).mockImplementationOnce(() => {
+ throw new Error('Component not found in CEM');
+ });
+
+ const result = handleCompositionCall(
+ 'get_composition_example',
+ { tagNames: ['unknown-element'] },
+ FAKE_CEM,
+ );
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Component not found in CEM');
+ });
+});
diff --git a/tests/tools/framework.test.ts b/tests/tools/framework.test.ts
new file mode 100644
index 0000000..0051fc6
--- /dev/null
+++ b/tests/tools/framework.test.ts
@@ -0,0 +1,159 @@
+/**
+ * Tests for the detect_framework tool dispatcher.
+ * Covers isFrameworkTool, handleFrameworkCall, argument validation,
+ * and response formatting.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ isFrameworkTool,
+ handleFrameworkCall,
+ FRAMEWORK_TOOL_DEFINITIONS,
+} from '../../packages/core/src/tools/framework.js';
+import type { McpWcConfig } from '../../packages/core/src/config.js';
+
+// ─── Mocks ────────────────────────────────────────────────────────────────────
+
+vi.mock('../../packages/core/src/handlers/framework.js', () => ({
+ detectFramework: vi.fn(async () => ({
+ framework: 'lit',
+ version: '3.2.0',
+ cemGenerator: '@custom-elements-manifest/analyzer',
+ regenerationNotes: 'Run: npx cem analyze --globs "src/**/*.ts"',
+ formatted:
+ '## Framework Detection\n\n**Framework:** lit\n**Version:** 3.2.0\n**CEM Generator:** @custom-elements-manifest/analyzer\n\n### Regeneration Notes\nRun: npx cem analyze --globs "src/**/*.ts"',
+ })),
+}));
+
+// ─── Fixtures ─────────────────────────────────────────────────────────────────
+
+const FAKE_CONFIG: McpWcConfig = {
+ cemPath: 'custom-elements.json',
+ projectRoot: '/fake/project',
+ componentPrefix: 'hx-',
+ healthHistoryDir: '.mcp-wc/health',
+ tsconfigPath: 'tsconfig.json',
+ tokensPath: null,
+ cdnBase: null,
+ watch: false,
+};
+
+// ─── FRAMEWORK_TOOL_DEFINITIONS ───────────────────────────────────────────────
+
+describe('FRAMEWORK_TOOL_DEFINITIONS', () => {
+ it('exports exactly 1 tool definition', () => {
+ expect(FRAMEWORK_TOOL_DEFINITIONS).toHaveLength(1);
+ });
+
+ it('defines detect_framework', () => {
+ const names = FRAMEWORK_TOOL_DEFINITIONS.map((t) => t.name);
+ expect(names).toContain('detect_framework');
+ });
+
+ it('detect_framework schema has no required fields', () => {
+ const def = FRAMEWORK_TOOL_DEFINITIONS.find((t) => t.name === 'detect_framework')!;
+ expect(def.inputSchema.required).toBeUndefined();
+ });
+});
+
+// ─── isFrameworkTool ──────────────────────────────────────────────────────────
+
+describe('isFrameworkTool', () => {
+ it('returns true for detect_framework', () => {
+ expect(isFrameworkTool('detect_framework')).toBe(true);
+ });
+
+ it('returns false for unknown tool names', () => {
+ expect(isFrameworkTool('scaffold_component')).toBe(false);
+ expect(isFrameworkTool('get_component')).toBe(false);
+ expect(isFrameworkTool('')).toBe(false);
+ });
+
+ it('returns false for near-matches', () => {
+ expect(isFrameworkTool('framework')).toBe(false);
+ expect(isFrameworkTool('detect_frameworks')).toBe(false);
+ });
+});
+
+// ─── handleFrameworkCall — valid inputs ───────────────────────────────────────
+
+describe('handleFrameworkCall — valid inputs', () => {
+ it('returns a success result for detect_framework with empty args', async () => {
+ const result = await handleFrameworkCall('detect_framework', {}, FAKE_CONFIG);
+ expect(result.isError).toBeFalsy();
+ });
+
+ it('returns text content with framework info', async () => {
+ const result = await handleFrameworkCall('detect_framework', {}, FAKE_CONFIG);
+ expect(result.content[0].text).toContain('Framework Detection');
+ });
+
+ it('result contains framework name', async () => {
+ const result = await handleFrameworkCall('detect_framework', {}, FAKE_CONFIG);
+ expect(result.content[0].text).toContain('lit');
+ });
+
+ it('result contains version info', async () => {
+ const result = await handleFrameworkCall('detect_framework', {}, FAKE_CONFIG);
+ expect(result.content[0].text).toContain('3.2.0');
+ });
+
+ it('result contains regeneration notes', async () => {
+ const result = await handleFrameworkCall('detect_framework', {}, FAKE_CONFIG);
+ expect(result.content[0].text).toContain('Regeneration Notes');
+ });
+
+ it('ignores any extra args passed in (no schema fields)', async () => {
+ const result = await handleFrameworkCall(
+ 'detect_framework',
+ { unknownProp: 'ignored' },
+ FAKE_CONFIG,
+ );
+ expect(result.isError).toBeFalsy();
+ });
+});
+
+// ─── handleFrameworkCall — error cases ────────────────────────────────────────
+
+describe('handleFrameworkCall — error cases', () => {
+ it('returns error for unknown tool name', async () => {
+ const result = await handleFrameworkCall('nonexistent_tool', {}, FAKE_CONFIG);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Unknown framework tool');
+ });
+
+ it('returns error for empty tool name', async () => {
+ const result = await handleFrameworkCall('', {}, FAKE_CONFIG);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Unknown framework tool');
+ });
+});
+
+// ─── handleFrameworkCall — handler error propagation ─────────────────────────
+
+describe('handleFrameworkCall — handler error propagation', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('returns error when detectFramework handler throws', async () => {
+ const { detectFramework } = await import('../../packages/core/src/handlers/framework.js');
+ vi.mocked(detectFramework).mockImplementationOnce(async () => {
+ throw new Error('package.json not found in project root');
+ });
+
+ const result = await handleFrameworkCall('detect_framework', {}, FAKE_CONFIG);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('package.json not found');
+ });
+
+ it('returns error when detectFramework handler throws generic error', async () => {
+ const { detectFramework } = await import('../../packages/core/src/handlers/framework.js');
+ vi.mocked(detectFramework).mockImplementationOnce(async () => {
+ throw new Error('Permission denied');
+ });
+
+ const result = await handleFrameworkCall('detect_framework', {}, FAKE_CONFIG);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Permission denied');
+ });
+});
diff --git a/tests/tools/story.test.ts b/tests/tools/story.test.ts
new file mode 100644
index 0000000..cab805a
--- /dev/null
+++ b/tests/tools/story.test.ts
@@ -0,0 +1,218 @@
+/**
+ * Tests for the generate_story tool dispatcher.
+ * Covers isStoryTool, handleStoryCall, argument validation,
+ * and response formatting.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ isStoryTool,
+ handleStoryCall,
+ STORY_TOOL_DEFINITIONS,
+} from '../../packages/core/src/tools/story.js';
+import type { Cem } from '../../packages/core/src/handlers/cem.js';
+
+// ─── Mocks ────────────────────────────────────────────────────────────────────
+
+vi.mock('../../packages/core/src/handlers/story.js', () => ({
+ generateStory: vi.fn((decl: { tagName?: string; name?: string }) => {
+ const tag = decl.tagName ?? 'unknown-element';
+ return `import type { Meta, StoryObj } from '@storybook/web-components';\n\nconst meta: Meta = {\n title: 'Components/${tag}',\n component: '${tag}',\n};\n\nexport default meta;\n`;
+ }),
+}));
+
+// ─── Fixtures ─────────────────────────────────────────────────────────────────
+
+const EMPTY_CEM: Cem = { schemaVersion: '1.0.0', modules: [] };
+
+const BUTTON_CEM: Cem = {
+ schemaVersion: '1.0.0',
+ modules: [
+ {
+ kind: 'javascript-module',
+ path: 'src/hx-button.ts',
+ declarations: [
+ {
+ kind: 'class',
+ name: 'HxButton',
+ tagName: 'hx-button',
+ members: [
+ { kind: 'field', name: 'variant', type: { text: 'string' }, default: '"primary"' },
+ { kind: 'field', name: 'disabled', type: { text: 'boolean' }, default: 'false' },
+ ],
+ attributes: [
+ { name: 'variant', type: { text: "'primary' | 'secondary' | 'danger'" } },
+ { name: 'disabled', type: { text: 'boolean' } },
+ ],
+ slots: [{ name: '' }],
+ },
+ ],
+ },
+ ],
+};
+
+const MULTI_CEM: Cem = {
+ schemaVersion: '1.0.0',
+ modules: [
+ {
+ kind: 'javascript-module',
+ path: 'src/hx-button.ts',
+ declarations: [
+ {
+ kind: 'class',
+ name: 'HxButton',
+ tagName: 'hx-button',
+ members: [],
+ },
+ ],
+ },
+ {
+ kind: 'javascript-module',
+ path: 'src/hx-card.ts',
+ declarations: [
+ {
+ kind: 'class',
+ name: 'HxCard',
+ tagName: 'hx-card',
+ members: [],
+ },
+ ],
+ },
+ ],
+};
+
+// ─── STORY_TOOL_DEFINITIONS ───────────────────────────────────────────────────
+
+describe('STORY_TOOL_DEFINITIONS', () => {
+ it('exports exactly 1 tool definition', () => {
+ expect(STORY_TOOL_DEFINITIONS).toHaveLength(1);
+ });
+
+ it('defines generate_story', () => {
+ const names = STORY_TOOL_DEFINITIONS.map((t) => t.name);
+ expect(names).toContain('generate_story');
+ });
+
+ it('generate_story schema requires tagName', () => {
+ const def = STORY_TOOL_DEFINITIONS.find((t) => t.name === 'generate_story')!;
+ expect(def.inputSchema.required).toContain('tagName');
+ });
+});
+
+// ─── isStoryTool ──────────────────────────────────────────────────────────────
+
+describe('isStoryTool', () => {
+ it('returns true for generate_story', () => {
+ expect(isStoryTool('generate_story')).toBe(true);
+ });
+
+ it('returns false for unknown tool names', () => {
+ expect(isStoryTool('scaffold_component')).toBe(false);
+ expect(isStoryTool('get_component')).toBe(false);
+ expect(isStoryTool('')).toBe(false);
+ });
+
+ it('returns false for near-matches', () => {
+ expect(isStoryTool('story')).toBe(false);
+ expect(isStoryTool('generate_stories')).toBe(false);
+ });
+});
+
+// ─── handleStoryCall — valid inputs ───────────────────────────────────────────
+
+describe('handleStoryCall — valid inputs', () => {
+ it('returns a success result for a known component', async () => {
+ const result = await handleStoryCall('generate_story', { tagName: 'hx-button' }, BUTTON_CEM);
+ expect(result.isError).toBeFalsy();
+ });
+
+ it('result contains Storybook Meta import', async () => {
+ const result = await handleStoryCall('generate_story', { tagName: 'hx-button' }, BUTTON_CEM);
+ expect(result.content[0].text).toContain("'@storybook/web-components'");
+ });
+
+ it('result contains the component tag name', async () => {
+ const result = await handleStoryCall('generate_story', { tagName: 'hx-button' }, BUTTON_CEM);
+ expect(result.content[0].text).toContain('hx-button');
+ });
+
+ it('works for a second component in a multi-module CEM', async () => {
+ const result = await handleStoryCall('generate_story', { tagName: 'hx-card' }, MULTI_CEM);
+ expect(result.isError).toBeFalsy();
+ expect(result.content[0].text).toContain('hx-card');
+ });
+
+ it('returns story source as plain text (not JSON)', async () => {
+ const result = await handleStoryCall('generate_story', { tagName: 'hx-button' }, BUTTON_CEM);
+ expect(() => JSON.parse(result.content[0].text)).toThrow();
+ });
+});
+
+// ─── handleStoryCall — error cases ────────────────────────────────────────────
+
+describe('handleStoryCall — error cases', () => {
+ it('returns error for unknown tool name', async () => {
+ const result = await handleStoryCall('nonexistent_tool', {}, EMPTY_CEM);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Unknown story tool');
+ });
+
+ it('returns error for empty tool name', async () => {
+ const result = await handleStoryCall('', {}, EMPTY_CEM);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Unknown story tool');
+ });
+
+ it('returns error when tagName is missing', async () => {
+ const result = await handleStoryCall('generate_story', {}, BUTTON_CEM);
+ expect(result.isError).toBe(true);
+ });
+
+ it('returns error when tagName not found in CEM', async () => {
+ const result = await handleStoryCall(
+ 'generate_story',
+ { tagName: 'nonexistent-element' },
+ BUTTON_CEM,
+ );
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('nonexistent-element');
+ expect(result.content[0].text).toContain('not found in CEM');
+ });
+
+ it('returns error with known components list when tagName not found', async () => {
+ const result = await handleStoryCall(
+ 'generate_story',
+ { tagName: 'missing-component' },
+ BUTTON_CEM,
+ );
+ expect(result.content[0].text).toContain('hx-button');
+ });
+
+ it('returns error with (none) when CEM has no components', async () => {
+ const result = await handleStoryCall(
+ 'generate_story',
+ { tagName: 'hx-button' },
+ EMPTY_CEM,
+ );
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('(none)');
+ });
+});
+
+// ─── handleStoryCall — handler error propagation ──────────────────────────────
+
+describe('handleStoryCall — handler error propagation', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('returns error when generateStory handler throws', async () => {
+ const { generateStory } = await import('../../packages/core/src/handlers/story.js');
+ vi.mocked(generateStory).mockImplementationOnce(() => {
+ throw new Error('Failed to generate story template');
+ });
+
+ const result = await handleStoryCall('generate_story', { tagName: 'hx-button' }, BUTTON_CEM);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Failed to generate story template');
+ });
+});
diff --git a/tests/tools/tokens.test.ts b/tests/tools/tokens.test.ts
new file mode 100644
index 0000000..aa2346e
--- /dev/null
+++ b/tests/tools/tokens.test.ts
@@ -0,0 +1,210 @@
+/**
+ * Tests for the get_design_tokens and find_token tool dispatchers.
+ * Covers isTokenTool, handleTokenCall, argument validation,
+ * and response formatting.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ isTokenTool,
+ handleTokenCall,
+ TOKEN_TOOL_DEFINITIONS,
+} from '../../packages/core/src/tools/tokens.js';
+import type { McpWcConfig } from '../../packages/core/src/config.js';
+
+// ─── Mocks ────────────────────────────────────────────────────────────────────
+
+vi.mock('../../packages/core/src/handlers/tokens.js', () => ({
+ getDesignTokens: vi.fn(async (_config: unknown, category?: string) => ({
+ tokens: [
+ { name: '--color-primary', value: '#0066cc', category: 'color' },
+ { name: '--color-secondary', value: '#666', category: 'color' },
+ { name: '--spacing-md', value: '1rem', category: 'spacing' },
+ ].filter((t) => !category || t.category === category),
+ count: category ? 2 : 3,
+ categories: ['color', 'spacing'],
+ })),
+ findToken: vi.fn(async (_config: unknown, query: string) => ({
+ tokens: [
+ { name: '--color-primary', value: '#0066cc', category: 'color' },
+ ].filter((t) => t.name.includes(query) || t.value.includes(query)),
+ count: 1,
+ query,
+ })),
+}));
+
+// ─── Fixtures ─────────────────────────────────────────────────────────────────
+
+const FAKE_CONFIG: McpWcConfig = {
+ cemPath: 'custom-elements.json',
+ projectRoot: '/fake/project',
+ componentPrefix: '',
+ healthHistoryDir: '.mcp-wc/health',
+ tsconfigPath: 'tsconfig.json',
+ tokensPath: '/fake/project/tokens.json',
+ cdnBase: null,
+ watch: false,
+};
+
+const CONFIG_NO_TOKENS: McpWcConfig = {
+ ...FAKE_CONFIG,
+ tokensPath: null,
+};
+
+// ─── TOKEN_TOOL_DEFINITIONS ───────────────────────────────────────────────────
+
+describe('TOKEN_TOOL_DEFINITIONS', () => {
+ it('exports exactly 2 tool definitions', () => {
+ expect(TOKEN_TOOL_DEFINITIONS).toHaveLength(2);
+ });
+
+ it('defines get_design_tokens and find_token', () => {
+ const names = TOKEN_TOOL_DEFINITIONS.map((t) => t.name);
+ expect(names).toContain('get_design_tokens');
+ expect(names).toContain('find_token');
+ });
+
+ it('find_token schema requires query', () => {
+ const def = TOKEN_TOOL_DEFINITIONS.find((t) => t.name === 'find_token')!;
+ expect(def.inputSchema.required).toContain('query');
+ });
+
+ it('get_design_tokens schema has no required fields', () => {
+ const def = TOKEN_TOOL_DEFINITIONS.find((t) => t.name === 'get_design_tokens')!;
+ expect(def.inputSchema.required).toBeUndefined();
+ });
+});
+
+// ─── isTokenTool ──────────────────────────────────────────────────────────────
+
+describe('isTokenTool', () => {
+ it('returns true for get_design_tokens', () => {
+ expect(isTokenTool('get_design_tokens')).toBe(true);
+ });
+
+ it('returns true for find_token', () => {
+ expect(isTokenTool('find_token')).toBe(true);
+ });
+
+ it('returns false for unknown tool names', () => {
+ expect(isTokenTool('scaffold_component')).toBe(false);
+ expect(isTokenTool('get_component')).toBe(false);
+ expect(isTokenTool('')).toBe(false);
+ });
+
+ it('returns false for near-matches', () => {
+ expect(isTokenTool('design_tokens')).toBe(false);
+ expect(isTokenTool('get_tokens')).toBe(false);
+ });
+});
+
+// ─── handleTokenCall — get_design_tokens ──────────────────────────────────────
+
+describe('handleTokenCall — get_design_tokens', () => {
+ it('returns success result with no args', async () => {
+ const result = await handleTokenCall('get_design_tokens', {}, FAKE_CONFIG);
+ expect(result.isError).toBeFalsy();
+ });
+
+ it('returns JSON-parseable content', async () => {
+ const result = await handleTokenCall('get_design_tokens', {}, FAKE_CONFIG);
+ expect(() => JSON.parse(result.content[0].text)).not.toThrow();
+ });
+
+ it('accepts optional category filter', async () => {
+ const result = await handleTokenCall(
+ 'get_design_tokens',
+ { category: 'color' },
+ FAKE_CONFIG,
+ );
+ expect(result.isError).toBeFalsy();
+ });
+
+ it('result contains tokens array', async () => {
+ const result = await handleTokenCall('get_design_tokens', {}, FAKE_CONFIG);
+ const parsed = JSON.parse(result.content[0].text);
+ expect(parsed.tokens).toBeDefined();
+ });
+
+ it('result contains categories list', async () => {
+ const result = await handleTokenCall('get_design_tokens', {}, FAKE_CONFIG);
+ const parsed = JSON.parse(result.content[0].text);
+ expect(parsed.categories).toBeDefined();
+ });
+});
+
+// ─── handleTokenCall — find_token ─────────────────────────────────────────────
+
+describe('handleTokenCall — find_token', () => {
+ it('returns success result with valid query', async () => {
+ const result = await handleTokenCall('find_token', { query: 'color' }, FAKE_CONFIG);
+ expect(result.isError).toBeFalsy();
+ });
+
+ it('returns JSON-parseable content', async () => {
+ const result = await handleTokenCall('find_token', { query: 'primary' }, FAKE_CONFIG);
+ expect(() => JSON.parse(result.content[0].text)).not.toThrow();
+ });
+
+ it('result contains query field', async () => {
+ const result = await handleTokenCall('find_token', { query: 'primary' }, FAKE_CONFIG);
+ const parsed = JSON.parse(result.content[0].text);
+ expect(parsed.query).toBe('primary');
+ });
+
+ it('result contains tokens array', async () => {
+ const result = await handleTokenCall('find_token', { query: 'color' }, FAKE_CONFIG);
+ const parsed = JSON.parse(result.content[0].text);
+ expect(parsed.tokens).toBeDefined();
+ });
+});
+
+// ─── handleTokenCall — error cases ────────────────────────────────────────────
+
+describe('handleTokenCall — error cases', () => {
+ it('returns error for unknown tool name', async () => {
+ const result = await handleTokenCall('nonexistent_tool', {}, FAKE_CONFIG);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Unknown token tool');
+ });
+
+ it('returns error for empty tool name', async () => {
+ const result = await handleTokenCall('', {}, FAKE_CONFIG);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Unknown token tool');
+ });
+
+ it('returns error when find_token query is missing', async () => {
+ const result = await handleTokenCall('find_token', {}, FAKE_CONFIG);
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ─── handleTokenCall — handler error propagation ──────────────────────────────
+
+describe('handleTokenCall — handler error propagation', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('returns error when getDesignTokens handler throws (no tokensPath)', async () => {
+ const { getDesignTokens } = await import('../../packages/core/src/handlers/tokens.js');
+ vi.mocked(getDesignTokens).mockImplementationOnce(async () => {
+ throw new Error('tokensPath is not configured');
+ });
+
+ const result = await handleTokenCall('get_design_tokens', {}, CONFIG_NO_TOKENS);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('tokensPath is not configured');
+ });
+
+ it('returns error when findToken handler throws', async () => {
+ const { findToken } = await import('../../packages/core/src/handlers/tokens.js');
+ vi.mocked(findToken).mockImplementationOnce(async () => {
+ throw new Error('Tokens file not found');
+ });
+
+ const result = await handleTokenCall('find_token', { query: 'primary' }, FAKE_CONFIG);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Tokens file not found');
+ });
+});
diff --git a/tests/tools/typegenerate.test.ts b/tests/tools/typegenerate.test.ts
new file mode 100644
index 0000000..01502b2
--- /dev/null
+++ b/tests/tools/typegenerate.test.ts
@@ -0,0 +1,181 @@
+/**
+ * Tests for the generate_types tool dispatcher.
+ * Covers isTypegenerateTool, handleTypegenerateCall, argument validation,
+ * and response formatting.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ isTypegenerateTool,
+ handleTypegenerateCall,
+ TYPEGENERATE_TOOL_DEFINITIONS,
+} from '../../packages/core/src/tools/typegenerate.js';
+import type { Cem } from '../../packages/core/src/handlers/cem.js';
+
+// ─── Mocks ────────────────────────────────────────────────────────────────────
+
+vi.mock('../../packages/core/src/handlers/typegenerate.js', () => ({
+ generateTypes: vi.fn((cem: { modules: unknown[] }) => {
+ const count = cem.modules.length;
+ return {
+ componentCount: count,
+ content: count === 0
+ ? '// No components found\n'
+ : 'declare global {\n interface HTMLElementTagNameMap {\n "hx-button": HxButton;\n }\n}\nexport interface HxButton {\n variant?: string;\n disabled?: boolean;\n}\n',
+ };
+ }),
+}));
+
+// ─── Fixtures ─────────────────────────────────────────────────────────────────
+
+const EMPTY_CEM: Cem = { schemaVersion: '1.0.0', modules: [] };
+
+const BUTTON_CEM: Cem = {
+ schemaVersion: '1.0.0',
+ modules: [
+ {
+ kind: 'javascript-module',
+ path: 'src/hx-button.ts',
+ declarations: [
+ {
+ kind: 'class',
+ name: 'HxButton',
+ tagName: 'hx-button',
+ members: [
+ { kind: 'field', name: 'variant', type: { text: 'string' } },
+ { kind: 'field', name: 'disabled', type: { text: 'boolean' } },
+ ],
+ attributes: [
+ { name: 'variant', type: { text: 'string' } },
+ ],
+ },
+ ],
+ },
+ ],
+};
+
+const MULTI_CEM: Cem = {
+ schemaVersion: '1.0.0',
+ modules: [
+ {
+ kind: 'javascript-module',
+ path: 'src/hx-button.ts',
+ declarations: [
+ { kind: 'class', name: 'HxButton', tagName: 'hx-button', members: [] },
+ ],
+ },
+ {
+ kind: 'javascript-module',
+ path: 'src/hx-card.ts',
+ declarations: [
+ { kind: 'class', name: 'HxCard', tagName: 'hx-card', members: [] },
+ ],
+ },
+ ],
+};
+
+// ─── TYPEGENERATE_TOOL_DEFINITIONS ────────────────────────────────────────────
+
+describe('TYPEGENERATE_TOOL_DEFINITIONS', () => {
+ it('exports exactly 1 tool definition', () => {
+ expect(TYPEGENERATE_TOOL_DEFINITIONS).toHaveLength(1);
+ });
+
+ it('defines generate_types', () => {
+ const names = TYPEGENERATE_TOOL_DEFINITIONS.map((t) => t.name);
+ expect(names).toContain('generate_types');
+ });
+
+ it('generate_types schema has no required fields', () => {
+ const def = TYPEGENERATE_TOOL_DEFINITIONS.find((t) => t.name === 'generate_types')!;
+ expect(def.inputSchema.required).toBeUndefined();
+ });
+});
+
+// ─── isTypegenerateTool ───────────────────────────────────────────────────────
+
+describe('isTypegenerateTool', () => {
+ it('returns true for generate_types', () => {
+ expect(isTypegenerateTool('generate_types')).toBe(true);
+ });
+
+ it('returns false for unknown tool names', () => {
+ expect(isTypegenerateTool('scaffold_component')).toBe(false);
+ expect(isTypegenerateTool('get_component')).toBe(false);
+ expect(isTypegenerateTool('')).toBe(false);
+ });
+
+ it('returns false for near-matches', () => {
+ expect(isTypegenerateTool('generate')).toBe(false);
+ expect(isTypegenerateTool('generate_type')).toBe(false);
+ });
+});
+
+// ─── handleTypegenerateCall — valid inputs ────────────────────────────────────
+
+describe('handleTypegenerateCall — valid inputs', () => {
+ it('returns a success result for generate_types with empty args', () => {
+ const result = handleTypegenerateCall('generate_types', {}, BUTTON_CEM);
+ expect(result.isError).toBeFalsy();
+ });
+
+ it('output includes component count comment', () => {
+ const result = handleTypegenerateCall('generate_types', {}, BUTTON_CEM);
+ expect(result.content[0].text).toContain('component(s) generated');
+ });
+
+ it('output contains TypeScript declarations', () => {
+ const result = handleTypegenerateCall('generate_types', {}, BUTTON_CEM);
+ expect(result.content[0].text).toContain('HTMLElementTagNameMap');
+ });
+
+ it('works with a multi-module CEM', () => {
+ const result = handleTypegenerateCall('generate_types', {}, MULTI_CEM);
+ expect(result.isError).toBeFalsy();
+ });
+
+ it('works with an empty CEM', () => {
+ const result = handleTypegenerateCall('generate_types', {}, EMPTY_CEM);
+ expect(result.isError).toBeFalsy();
+ expect(result.content[0].text).toContain('// 0 component(s) generated');
+ });
+
+ it('accepts optional libraryId argument without error', () => {
+ const result = handleTypegenerateCall('generate_types', { libraryId: 'shoelace' }, BUTTON_CEM);
+ expect(result.isError).toBeFalsy();
+ });
+});
+
+// ─── handleTypegenerateCall — error cases ─────────────────────────────────────
+
+describe('handleTypegenerateCall — error cases', () => {
+ it('returns error for unknown tool name', () => {
+ const result = handleTypegenerateCall('nonexistent_tool', {}, EMPTY_CEM);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Unknown typegenerate tool');
+ });
+
+ it('returns error for empty tool name', () => {
+ const result = handleTypegenerateCall('', {}, EMPTY_CEM);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Unknown typegenerate tool');
+ });
+});
+
+// ─── handleTypegenerateCall — handler error propagation ───────────────────────
+
+describe('handleTypegenerateCall — handler error propagation', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('returns error when generateTypes handler throws', async () => {
+ const { generateTypes } = await import('../../packages/core/src/handlers/typegenerate.js');
+ vi.mocked(generateTypes).mockImplementationOnce(() => {
+ throw new Error('CEM schema version not supported');
+ });
+
+ const result = handleTypegenerateCall('generate_types', {}, BUTTON_CEM);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('CEM schema version not supported');
+ });
+});
diff --git a/tests/tools/typescript.test.ts b/tests/tools/typescript.test.ts
new file mode 100644
index 0000000..7726ae5
--- /dev/null
+++ b/tests/tools/typescript.test.ts
@@ -0,0 +1,242 @@
+/**
+ * Tests for the get_file_diagnostics and get_project_diagnostics tool dispatchers.
+ * Covers isTypeScriptTool, handleTypeScriptCall, argument validation,
+ * and response formatting.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ isTypeScriptTool,
+ handleTypeScriptCall,
+ TYPESCRIPT_TOOL_DEFINITIONS,
+} from '../../packages/core/src/tools/typescript.js';
+import type { McpWcConfig } from '../../packages/core/src/config.js';
+
+// ─── Mocks ────────────────────────────────────────────────────────────────────
+
+vi.mock('../../packages/core/src/handlers/typescript.js', () => ({
+ getFileDiagnostics: vi.fn((_config: unknown, filePath: string) => ({
+ filePath,
+ diagnostics: [],
+ errorCount: 0,
+ warningCount: 0,
+ })),
+ getProjectDiagnostics: vi.fn((_config: unknown) => ({
+ errorCount: 0,
+ warningCount: 2,
+ files: 15,
+ diagnostics: [],
+ })),
+}));
+
+// ─── Fixtures ─────────────────────────────────────────────────────────────────
+
+const FAKE_CONFIG: McpWcConfig = {
+ cemPath: 'custom-elements.json',
+ projectRoot: '/fake/project',
+ componentPrefix: '',
+ healthHistoryDir: '.mcp-wc/health',
+ tsconfigPath: 'tsconfig.json',
+ tokensPath: null,
+ cdnBase: null,
+ watch: false,
+};
+
+// ─── TYPESCRIPT_TOOL_DEFINITIONS ──────────────────────────────────────────────
+
+describe('TYPESCRIPT_TOOL_DEFINITIONS', () => {
+ it('exports exactly 2 tool definitions', () => {
+ expect(TYPESCRIPT_TOOL_DEFINITIONS).toHaveLength(2);
+ });
+
+ it('defines get_file_diagnostics and get_project_diagnostics', () => {
+ const names = TYPESCRIPT_TOOL_DEFINITIONS.map((t) => t.name);
+ expect(names).toContain('get_file_diagnostics');
+ expect(names).toContain('get_project_diagnostics');
+ });
+
+ it('get_file_diagnostics schema requires filePath', () => {
+ const def = TYPESCRIPT_TOOL_DEFINITIONS.find((t) => t.name === 'get_file_diagnostics')!;
+ expect(def.inputSchema.required).toContain('filePath');
+ });
+
+ it('get_project_diagnostics schema has no required fields', () => {
+ const def = TYPESCRIPT_TOOL_DEFINITIONS.find((t) => t.name === 'get_project_diagnostics')!;
+ expect(def.inputSchema.required).toBeUndefined();
+ });
+});
+
+// ─── isTypeScriptTool ─────────────────────────────────────────────────────────
+
+describe('isTypeScriptTool', () => {
+ it('returns true for get_file_diagnostics', () => {
+ expect(isTypeScriptTool('get_file_diagnostics')).toBe(true);
+ });
+
+ it('returns true for get_project_diagnostics', () => {
+ expect(isTypeScriptTool('get_project_diagnostics')).toBe(true);
+ });
+
+ it('returns false for unknown tool names', () => {
+ expect(isTypeScriptTool('scaffold_component')).toBe(false);
+ expect(isTypeScriptTool('get_component')).toBe(false);
+ expect(isTypeScriptTool('')).toBe(false);
+ });
+
+ it('returns false for near-matches', () => {
+ expect(isTypeScriptTool('file_diagnostics')).toBe(false);
+ expect(isTypeScriptTool('get_diagnostics')).toBe(false);
+ });
+});
+
+// ─── handleTypeScriptCall — get_file_diagnostics ──────────────────────────────
+
+describe('handleTypeScriptCall — get_file_diagnostics', () => {
+ it('returns success result for a valid file path', () => {
+ const result = handleTypeScriptCall(
+ 'get_file_diagnostics',
+ { filePath: 'src/hx-button.ts' },
+ FAKE_CONFIG,
+ );
+ expect(result.isError).toBeFalsy();
+ });
+
+ it('returns JSON-parseable content', () => {
+ const result = handleTypeScriptCall(
+ 'get_file_diagnostics',
+ { filePath: 'src/hx-button.ts' },
+ FAKE_CONFIG,
+ );
+ expect(() => JSON.parse(result.content[0].text)).not.toThrow();
+ });
+
+ it('result includes filePath field', () => {
+ const result = handleTypeScriptCall(
+ 'get_file_diagnostics',
+ { filePath: 'src/hx-button.ts' },
+ FAKE_CONFIG,
+ );
+ const parsed = JSON.parse(result.content[0].text);
+ expect(parsed.filePath).toBe('src/hx-button.ts');
+ });
+
+ it('result includes diagnostics array', () => {
+ const result = handleTypeScriptCall(
+ 'get_file_diagnostics',
+ { filePath: 'src/hx-button.ts' },
+ FAKE_CONFIG,
+ );
+ const parsed = JSON.parse(result.content[0].text);
+ expect(parsed.diagnostics).toBeDefined();
+ expect(Array.isArray(parsed.diagnostics)).toBe(true);
+ });
+
+ it('result includes errorCount', () => {
+ const result = handleTypeScriptCall(
+ 'get_file_diagnostics',
+ { filePath: 'src/hx-button.ts' },
+ FAKE_CONFIG,
+ );
+ const parsed = JSON.parse(result.content[0].text);
+ expect(parsed.errorCount).toBeDefined();
+ });
+});
+
+// ─── handleTypeScriptCall — get_project_diagnostics ───────────────────────────
+
+describe('handleTypeScriptCall — get_project_diagnostics', () => {
+ it('returns success result with empty args', () => {
+ const result = handleTypeScriptCall('get_project_diagnostics', {}, FAKE_CONFIG);
+ expect(result.isError).toBeFalsy();
+ });
+
+ it('returns JSON-parseable content', () => {
+ const result = handleTypeScriptCall('get_project_diagnostics', {}, FAKE_CONFIG);
+ expect(() => JSON.parse(result.content[0].text)).not.toThrow();
+ });
+
+ it('result includes errorCount and warningCount', () => {
+ const result = handleTypeScriptCall('get_project_diagnostics', {}, FAKE_CONFIG);
+ const parsed = JSON.parse(result.content[0].text);
+ expect(parsed.errorCount).toBeDefined();
+ expect(parsed.warningCount).toBeDefined();
+ });
+
+ it('result includes files count', () => {
+ const result = handleTypeScriptCall('get_project_diagnostics', {}, FAKE_CONFIG);
+ const parsed = JSON.parse(result.content[0].text);
+ expect(parsed.files).toBeDefined();
+ });
+});
+
+// ─── handleTypeScriptCall — error cases ───────────────────────────────────────
+
+describe('handleTypeScriptCall — error cases', () => {
+ it('returns error for unknown tool name', () => {
+ const result = handleTypeScriptCall('nonexistent_tool', {}, FAKE_CONFIG);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Unknown TypeScript tool');
+ });
+
+ it('returns error for empty tool name', () => {
+ const result = handleTypeScriptCall('', {}, FAKE_CONFIG);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Unknown TypeScript tool');
+ });
+
+ it('returns error when filePath is missing for get_file_diagnostics', () => {
+ const result = handleTypeScriptCall('get_file_diagnostics', {}, FAKE_CONFIG);
+ expect(result.isError).toBe(true);
+ });
+
+ it('returns error for absolute filePath (path traversal)', () => {
+ const result = handleTypeScriptCall(
+ 'get_file_diagnostics',
+ { filePath: '/etc/passwd' },
+ FAKE_CONFIG,
+ );
+ expect(result.isError).toBe(true);
+ });
+
+ it('returns error for path traversal attempt in filePath', () => {
+ const result = handleTypeScriptCall(
+ 'get_file_diagnostics',
+ { filePath: '../../etc/passwd' },
+ FAKE_CONFIG,
+ );
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ─── handleTypeScriptCall — handler error propagation ─────────────────────────
+
+describe('handleTypeScriptCall — handler error propagation', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('returns error when getFileDiagnostics handler throws', async () => {
+ const { getFileDiagnostics } = await import('../../packages/core/src/handlers/typescript.js');
+ vi.mocked(getFileDiagnostics).mockImplementationOnce(() => {
+ throw new Error('tsconfig.json not found');
+ });
+
+ const result = handleTypeScriptCall(
+ 'get_file_diagnostics',
+ { filePath: 'src/hx-button.ts' },
+ FAKE_CONFIG,
+ );
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('tsconfig.json not found');
+ });
+
+ it('returns error when getProjectDiagnostics handler throws', async () => {
+ const { getProjectDiagnostics } = await import('../../packages/core/src/handlers/typescript.js');
+ vi.mocked(getProjectDiagnostics).mockImplementationOnce(() => {
+ throw new Error('Project root does not exist');
+ });
+
+ const result = handleTypeScriptCall('get_project_diagnostics', {}, FAKE_CONFIG);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Project root does not exist');
+ });
+});
diff --git a/tests/tools/validate.test.ts b/tests/tools/validate.test.ts
new file mode 100644
index 0000000..7da73d2
--- /dev/null
+++ b/tests/tools/validate.test.ts
@@ -0,0 +1,230 @@
+/**
+ * Tests for the validate_usage tool dispatcher.
+ * Covers isValidateTool, handleValidateCall, argument validation,
+ * and response formatting.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ isValidateTool,
+ handleValidateCall,
+ VALIDATE_TOOL_DEFINITIONS,
+} from '../../packages/core/src/tools/validate.js';
+import type { Cem } from '../../packages/core/src/handlers/cem.js';
+
+// ─── Mocks ────────────────────────────────────────────────────────────────────
+
+vi.mock('../../packages/core/src/handlers/validate.js', () => ({
+ validateUsage: vi.fn(
+ (tagName: string, html: string, _cem: unknown) => ({
+ tagName,
+ html,
+ valid: true,
+ issues: [],
+ issueCount: 0,
+ formatted: `## Validation: ${tagName}\n\n**Result:** PASS\n\nNo issues found.`,
+ }),
+ ),
+}));
+
+// ─── Fixtures ─────────────────────────────────────────────────────────────────
+
+const EMPTY_CEM: Cem = { schemaVersion: '1.0.0', modules: [] };
+
+const BUTTON_CEM: Cem = {
+ schemaVersion: '1.0.0',
+ modules: [
+ {
+ kind: 'javascript-module',
+ path: 'src/hx-button.ts',
+ declarations: [
+ {
+ kind: 'class',
+ name: 'HxButton',
+ tagName: 'hx-button',
+ members: [],
+ attributes: [
+ { name: 'variant', type: { text: "'primary' | 'secondary' | 'danger'" } },
+ { name: 'disabled', type: { text: 'boolean' } },
+ ],
+ slots: [{ name: '' }],
+ },
+ ],
+ },
+ ],
+};
+
+// ─── VALIDATE_TOOL_DEFINITIONS ────────────────────────────────────────────────
+
+describe('VALIDATE_TOOL_DEFINITIONS', () => {
+ it('exports exactly 1 tool definition', () => {
+ expect(VALIDATE_TOOL_DEFINITIONS).toHaveLength(1);
+ });
+
+ it('defines validate_usage', () => {
+ const names = VALIDATE_TOOL_DEFINITIONS.map((t) => t.name);
+ expect(names).toContain('validate_usage');
+ });
+
+ it('validate_usage schema requires tagName and html', () => {
+ const def = VALIDATE_TOOL_DEFINITIONS.find((t) => t.name === 'validate_usage')!;
+ expect(def.inputSchema.required).toContain('tagName');
+ expect(def.inputSchema.required).toContain('html');
+ });
+});
+
+// ─── isValidateTool ───────────────────────────────────────────────────────────
+
+describe('isValidateTool', () => {
+ it('returns true for validate_usage', () => {
+ expect(isValidateTool('validate_usage')).toBe(true);
+ });
+
+ it('returns false for unknown tool names', () => {
+ expect(isValidateTool('scaffold_component')).toBe(false);
+ expect(isValidateTool('get_component')).toBe(false);
+ expect(isValidateTool('')).toBe(false);
+ });
+
+ it('returns false for near-matches', () => {
+ expect(isValidateTool('validate')).toBe(false);
+ expect(isValidateTool('usage')).toBe(false);
+ });
+});
+
+// ─── handleValidateCall — valid inputs ────────────────────────────────────────
+
+describe('handleValidateCall — valid inputs', () => {
+ it('returns success result for valid HTML snippet', () => {
+ const result = handleValidateCall(
+ 'validate_usage',
+ { tagName: 'hx-button', html: 'Click' },
+ BUTTON_CEM,
+ );
+ expect(result.isError).toBeFalsy();
+ });
+
+ it('result content includes formatted output', () => {
+ const result = handleValidateCall(
+ 'validate_usage',
+ { tagName: 'hx-button', html: 'Click' },
+ BUTTON_CEM,
+ );
+ expect(result.content[0].text).toContain('Validation');
+ });
+
+ it('result includes PASS/FAIL result', () => {
+ const result = handleValidateCall(
+ 'validate_usage',
+ { tagName: 'hx-button', html: 'Click' },
+ BUTTON_CEM,
+ );
+ expect(result.content[0].text).toMatch(/PASS|FAIL/);
+ });
+
+ it('works with empty CEM (no declaration to check against)', () => {
+ const result = handleValidateCall(
+ 'validate_usage',
+ { tagName: 'hx-button', html: 'Click' },
+ EMPTY_CEM,
+ );
+ expect(result.isError).toBeFalsy();
+ });
+
+ it('accepts self-closing HTML snippet', () => {
+ const result = handleValidateCall(
+ 'validate_usage',
+ { tagName: 'hx-button', html: '' },
+ BUTTON_CEM,
+ );
+ expect(result.isError).toBeFalsy();
+ });
+
+ it('accepts multi-attribute HTML snippet', () => {
+ const result = handleValidateCall(
+ 'validate_usage',
+ {
+ tagName: 'hx-button',
+ html: 'Submit',
+ },
+ BUTTON_CEM,
+ );
+ expect(result.isError).toBeFalsy();
+ });
+
+ it('accepts html up to 50000 characters', () => {
+ const longHtml = '' + 'x'.repeat(49_980) + '';
+ const result = handleValidateCall(
+ 'validate_usage',
+ { tagName: 'hx-button', html: longHtml },
+ BUTTON_CEM,
+ );
+ expect(result.isError).toBeFalsy();
+ });
+});
+
+// ─── handleValidateCall — error cases ─────────────────────────────────────────
+
+describe('handleValidateCall — error cases', () => {
+ it('returns error for unknown tool name', () => {
+ const result = handleValidateCall('nonexistent_tool', {}, EMPTY_CEM);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Unknown validate tool');
+ });
+
+ it('returns error for empty tool name', () => {
+ const result = handleValidateCall('', {}, EMPTY_CEM);
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('Unknown validate tool');
+ });
+
+ it('returns error when tagName is missing', () => {
+ const result = handleValidateCall(
+ 'validate_usage',
+ { html: 'Click' },
+ BUTTON_CEM,
+ );
+ expect(result.isError).toBe(true);
+ });
+
+ it('returns error when html is missing', () => {
+ const result = handleValidateCall(
+ 'validate_usage',
+ { tagName: 'hx-button' },
+ BUTTON_CEM,
+ );
+ expect(result.isError).toBe(true);
+ });
+
+ it('returns error when html exceeds 50000 characters', () => {
+ const tooLongHtml = '' + 'x'.repeat(50_000) + '';
+ const result = handleValidateCall(
+ 'validate_usage',
+ { tagName: 'hx-button', html: tooLongHtml },
+ BUTTON_CEM,
+ );
+ expect(result.isError).toBe(true);
+ });
+});
+
+// ─── handleValidateCall — handler error propagation ───────────────────────────
+
+describe('handleValidateCall — handler error propagation', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('returns error when validateUsage handler throws', async () => {
+ const { validateUsage } = await import('../../packages/core/src/handlers/validate.js');
+ vi.mocked(validateUsage).mockImplementationOnce(() => {
+ throw new Error('HTML parse error: unexpected token');
+ });
+
+ const result = handleValidateCall(
+ 'validate_usage',
+ { tagName: 'hx-button', html: 'Click' },
+ BUTTON_CEM,
+ );
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('HTML parse error');
+ });
+});