diff --git a/.changeset/smooth-bats-beam.md b/.changeset/smooth-bats-beam.md new file mode 100644 index 00000000..776d1915 --- /dev/null +++ b/.changeset/smooth-bats-beam.md @@ -0,0 +1,5 @@ +--- +'sv': patch +--- + +feat(cli): add new add-on `mcp` to configure your project diff --git a/documentation/docs/20-commands/20-sv-add.md b/documentation/docs/20-commands/20-sv-add.md index 604ac6a5..e90e5616 100644 --- a/documentation/docs/20-commands/20-sv-add.md +++ b/documentation/docs/20-commands/20-sv-add.md @@ -31,6 +31,7 @@ You can select multiple space-separated add-ons from [the list below](#Official- - [`drizzle`](drizzle) - [`eslint`](eslint) - [`lucia`](lucia) +- [`mcp`](mcp) - [`mdsvex`](mdsvex) - [`paraglide`](paraglide) - [`playwright`](playwright) diff --git a/documentation/docs/30-add-ons/17-mcp.md b/documentation/docs/30-add-ons/17-mcp.md new file mode 100644 index 00000000..333af34c --- /dev/null +++ b/documentation/docs/30-add-ons/17-mcp.md @@ -0,0 +1,33 @@ +--- +title: mcp +--- + +[Svelte MCP](/docs/mcp/overview) can help your LLM write better Svelte code. + +## Usage + +```sh +npx sv add mcp +``` + +## What you get + +- A good mcp configuration for your project depending on your IDE + +## Options + +### ide + +The IDE you want to use like `'claude-code'`, `'cursor'`, `'gemini'`, `'opencode'`, `'vscode'`, `'other'`. + +```sh +npx sv add mcp=ide:cursor,vscode +``` + +### setup + +The setup you want to use. + +```sh +npx sv add mcp=setup:local +``` diff --git a/packages/addons/_config/official.ts b/packages/addons/_config/official.ts index 8dec45af..5e6fab0a 100644 --- a/packages/addons/_config/official.ts +++ b/packages/addons/_config/official.ts @@ -6,6 +6,7 @@ import eslint from '../eslint/index.ts'; import lucia from '../lucia/index.ts'; import mdsvex from '../mdsvex/index.ts'; import paraglide from '../paraglide/index.ts'; +import mcp from '../mcp/index.ts'; import playwright from '../playwright/index.ts'; import prettier from '../prettier/index.ts'; import storybook from '../storybook/index.ts'; @@ -26,6 +27,7 @@ type OfficialAddons = { mdsvex: Addon; paraglide: Addon; storybook: Addon; + mcp: Addon; }; // The order of addons here determines the order they are displayed inside the CLI @@ -42,7 +44,8 @@ export const officialAddons: OfficialAddons = { lucia, mdsvex, paraglide, - storybook + storybook, + mcp }; export function getAddonDetails(id: string): AddonWithoutExplicitArgs { diff --git a/packages/addons/_tests/_setup/suite.ts b/packages/addons/_tests/_setup/suite.ts index cac33308..ff0d4192 100644 --- a/packages/addons/_tests/_setup/suite.ts +++ b/packages/addons/_tests/_setup/suite.ts @@ -35,6 +35,10 @@ export function setupTest( kinds: Array['kind']>; filter?: (addonTestCase: AddonTestCase) => boolean; browser?: boolean; + preInstallAddon?: (o: { + addonTestCase: AddonTestCase; + cwd: string; + }) => Promise | void; } ) { const test = vitest.test.extend({} as any); @@ -85,13 +89,17 @@ export function setupTest( }) ); - for (const { variant, kind } of testCases) { + for (const addonTestCase of testCases) { + const { variant, kind } = addonTestCase; const cwd = create({ testId: `${kind.type}-${variant}`, variant }); // test metadata const metaPath = path.resolve(cwd, 'meta.json'); fs.writeFileSync(metaPath, JSON.stringify({ variant, kind }, null, '\t'), 'utf8'); + if (options?.preInstallAddon) { + await options.preInstallAddon({ addonTestCase, cwd }); + } const { pnpmBuildDependencies } = await installAddon({ cwd, addons, diff --git a/packages/addons/_tests/mcp/test.ts b/packages/addons/_tests/mcp/test.ts new file mode 100644 index 00000000..ad4e5155 --- /dev/null +++ b/packages/addons/_tests/mcp/test.ts @@ -0,0 +1,56 @@ +import { expect } from '@playwright/test'; +import { setupTest } from '../_setup/suite.ts'; +import mcp from '../../mcp/index.ts'; +import fs from 'node:fs'; +import path from 'node:path'; + +const { test, testCases } = setupTest( + { mcp }, + { + kinds: [ + { + type: 'default', + options: { + mcp: { ide: ['claude-code', 'cursor', 'gemini', 'opencode', 'vscode'], setup: 'local' } + } + } + ], + browser: false, + // test only one as it's not depending on project variants + filter: (addonTestCase) => addonTestCase.variant === 'kit-ts', + preInstallAddon: ({ cwd }) => { + // prepare an existing file + fs.mkdirSync(path.resolve(cwd, `.cursor`)); + fs.writeFileSync( + path.resolve(cwd, `.cursor/mcp.json`), + JSON.stringify( + { + mcpServers: { + svelte: { some: 'thing' }, + anotherMCP: {} + } + }, + null, + 2 + ), + { encoding: 'utf8' } + ); + } + } +); + +test.concurrent.for(testCases)('mcp $variant', (testCase, ctx) => { + const cwd = ctx.cwd(testCase); + + const cursorPath = path.resolve(cwd, `.cursor/mcp.json`); + const cursorMcpContent = fs.readFileSync(cursorPath, 'utf8'); + + // should keep other MCPs + expect(cursorMcpContent).toContain(`anotherMCP`); + // should have the svelte level + expect(cursorMcpContent).toContain(`svelte`); + // should have local conf + expect(cursorMcpContent).toContain(`@sveltejs/mcp`); + // should remove old svelte config + expect(cursorMcpContent).not.toContain(`thing`); +}); diff --git a/packages/addons/mcp/index.ts b/packages/addons/mcp/index.ts new file mode 100644 index 00000000..faf6c524 --- /dev/null +++ b/packages/addons/mcp/index.ts @@ -0,0 +1,123 @@ +import { defineAddon, defineAddonOptions } from '@sveltejs/cli-core'; +import { parseJson } from '@sveltejs/cli-core/parsers'; + +const options = defineAddonOptions() + .add('ide', { + question: 'Which client would you like to use?', + type: 'multiselect', + default: [], + options: [ + { value: 'claude-code', label: 'claude code' }, + { value: 'cursor', label: 'Cursor' }, + { value: 'gemini', label: 'Gemini' }, + { value: 'opencode', label: 'opencode' }, + { value: 'vscode', label: 'VSCode' }, + { value: 'other', label: 'Other' } + ], + required: true + }) + .add('setup', { + question: 'What setup you want to use?', + type: 'select', + default: 'remote', + options: [ + { value: 'local', label: 'Local', hint: 'will use stdio' }, + { value: 'remote', label: 'Remote', hint: 'will use a remote endpoint' } + ], + required: true + }) + .build(); + +export default defineAddon({ + id: 'mcp', + shortDescription: 'Svelte MCP', + homepage: 'https://svelte.dev/docs/mcp', + options, + run: ({ sv, options }) => { + const getLocalConfig = (o?: { type?: 'stdio' | 'local'; env?: boolean }) => { + return { + ...(o?.type ? { type: o.type } : {}), + command: 'npx', + args: ['-y', '@sveltejs/mcp'], + ...(o?.env ? { env: {} } : {}) + }; + }; + const getRemoteConfig = (o?: { type?: 'http' | 'remote' }) => { + return { + ...(o?.type ? { type: o.type } : {}), + url: 'https://mcp.svelte.dev/mcp' + }; + }; + + const configurator: Record< + (typeof options.ide)[number], + | { + schema?: string; + mcpServersKey?: string; + filePath: string; + typeLocal?: 'stdio' | 'local'; + typeRemote?: 'http' | 'remote'; + env?: boolean; + } + | { other: true } + > = { + 'claude-code': { + filePath: '.mcp.json', + typeLocal: 'stdio', + typeRemote: 'http', + env: true + }, + cursor: { + filePath: '.cursor/mcp.json' + }, + gemini: { + filePath: '.gemini/settings.json' + }, + opencode: { + schema: 'https://opencode.ai/config.json', + mcpServersKey: 'mcp', + filePath: 'opencode.json', + typeLocal: 'local', + typeRemote: 'remote' + }, + vscode: { + mcpServersKey: 'servers', + filePath: '.vscode/mcp.json' + }, + other: { + other: true + } + }; + + for (const ide of options.ide) { + const value = configurator[ide]; + if ('other' in value) continue; + + const { mcpServersKey, filePath, typeLocal, typeRemote, env, schema } = value; + sv.file(filePath, (content) => { + const { data, generateCode } = parseJson(content); + if (schema) { + data['$schema'] = schema; + } + const key = mcpServersKey || 'mcpServers'; + data[key] ??= {}; + data[key].svelte = + options.setup === 'local' + ? getLocalConfig({ type: typeLocal, env }) + : getRemoteConfig({ type: typeRemote }); + return generateCode(); + }); + } + }, + nextSteps({ highlighter, options }) { + const steps = []; + + if (options.ide.includes('other')) { + steps.push( + `For other clients: ${highlighter.website(`https://svelte.dev/docs/mcp/${options.setup}-setup#Other-clients`)}` + ); + } + + return steps; + } +}); diff --git a/packages/cli/commands/add/index.ts b/packages/cli/commands/add/index.ts index 89d33609..e7fbe3c3 100644 --- a/packages/cli/commands/add/index.ts +++ b/packages/cli/commands/add/index.ts @@ -589,10 +589,11 @@ export async function runAddCommand( const nextSteps = selectedAddons .map(({ addon }) => { if (!addon.nextSteps) return; - let addonMessage = `${pc.green(addon.id)}:\n`; - const options = official[addon.id]; const addonNextSteps = addon.nextSteps({ ...workspace, options, highlighter }); + if (addonNextSteps.length === 0) return; + + let addonMessage = `${pc.green(addon.id)}:\n`; addonMessage += ` - ${addonNextSteps.join('\n - ')}`; return addonMessage; })