From 484453e5f8174bcd7db614e8d6923d14418eb964 Mon Sep 17 00:00:00 2001 From: Paolo Ricciuti Date: Wed, 13 May 2026 20:26:18 +0200 Subject: [PATCH] feat: allow stdio mcp to read the content of the file directly (#198) --- .changeset/giant-monkeys-end.md | 5 + .github/workflows/publish-any-commit.yml | 3 + .../handlers/tools/svelte-autofixer.test.ts | 56 +++++++- .../mcp/handlers/tools/svelte-autofixer.ts | 76 +++++++--- packages/mcp-server/src/mcp/index.ts | 1 + packages/mcp-stdio/src/index.ts | 4 +- pnpm-lock.yaml | 134 ++++++++++++++---- pnpm-workspace.yaml | 10 +- 8 files changed, 223 insertions(+), 66 deletions(-) create mode 100644 .changeset/giant-monkeys-end.md diff --git a/.changeset/giant-monkeys-end.md b/.changeset/giant-monkeys-end.md new file mode 100644 index 0000000..dc619f6 --- /dev/null +++ b/.changeset/giant-monkeys-end.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/mcp': patch +--- + +feat: allow stdio mcp to read the content of the file directly diff --git a/.github/workflows/publish-any-commit.yml b/.github/workflows/publish-any-commit.yml index b599a2b..ac3b57e 100644 --- a/.github/workflows/publish-any-commit.yml +++ b/.github/workflows/publish-any-commit.yml @@ -19,5 +19,8 @@ jobs: - name: Install dependencies run: pnpm install + # Opencode doesn't have a build step + - name: Build Stdio + run: pnpm --filter @sveltejs/mcp run build - run: pnpm dlx pkg-pr-new publish --compact './packages/mcp-stdio' './packages/opencode' --pnpm diff --git a/packages/mcp-server/src/mcp/handlers/tools/svelte-autofixer.test.ts b/packages/mcp-server/src/mcp/handlers/tools/svelte-autofixer.test.ts index 4be50f6..72afda2 100644 --- a/packages/mcp-server/src/mcp/handlers/tools/svelte-autofixer.test.ts +++ b/packages/mcp-server/src/mcp/handlers/tools/svelte-autofixer.test.ts @@ -1,4 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { writeFileSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import { InMemoryTransport } from '@tmcp/transport-in-memory'; import { beforeEach, describe, expect, it } from 'vitest'; import { server } from '../../index.js'; @@ -12,13 +15,18 @@ async function autofixer_tool_call( is_error = false, desired_svelte_version = 5, async = false, + ctx?: { stdio?: boolean }, ) { - const result = await session.callTool('svelte-autofixer', { - code, - desired_svelte_version, - filename: 'App.svelte', - async, - }); + const result = await session.callTool( + 'svelte-autofixer', + { + code, + desired_svelte_version, + filename: 'App.svelte', + async, + }, + ctx, + ); expect(result).toBeDefined(); if (is_error) { @@ -146,4 +154,40 @@ describe('svelte-autofixer tool', () => { 'The desired_svelte_version MUST be either 4 or 5 but received "3"', ); }); + + it('should read file content from path when stdio context is true', async () => { + const tmp_file = join(tmpdir(), `svelte-autofixer-test-${Date.now()}.svelte`); + const file_content = ``; + + writeFileSync(tmp_file, file_content, 'utf-8'); + + try { + // with stdio: true, the file is read from disk and parsed, producing issues + const content = await autofixer_tool_call(tmp_file, false, 5, false, { stdio: true }); + expect(content.issues.length).toBeGreaterThan(0); + expect(content.suggestions.length).toBeGreaterThan(0); + } finally { + unlinkSync(tmp_file); + } + }); + + it('should treat file path as code when stdio context is not set', async () => { + const tmp_file = join(tmpdir(), `svelte-autofixer-test-${Date.now()}.svelte`); + const file_content = ``; + + writeFileSync(tmp_file, file_content, 'utf-8'); + + try { + // without stdio context, the path string is treated as raw code (plain text), no issues + const content = await autofixer_tool_call(tmp_file, false, 5, false); + expect(content.issues).toHaveLength(0); + expect(content.suggestions).toHaveLength(0); + } finally { + unlinkSync(tmp_file); + } + }); }); diff --git a/packages/mcp-server/src/mcp/handlers/tools/svelte-autofixer.ts b/packages/mcp-server/src/mcp/handlers/tools/svelte-autofixer.ts index a455ea6..f3bb120 100644 --- a/packages/mcp-server/src/mcp/handlers/tools/svelte-autofixer.ts +++ b/packages/mcp-server/src/mcp/handlers/tools/svelte-autofixer.ts @@ -1,33 +1,47 @@ import { basename } from 'node:path'; -import type { SvelteMcp } from '../../index.js'; +import { tool } from 'tmcp/utils'; import * as v from 'valibot'; +import { add_autofixers_issues } from '../../autofixers/add-autofixers-issues.js'; import { add_compile_issues } from '../../autofixers/add-compile-issues.js'; import { add_eslint_issues } from '../../autofixers/add-eslint-issues.js'; -import { add_autofixers_issues } from '../../autofixers/add-autofixers-issues.js'; import { icons } from '../../icons/index.js'; -import { tool } from 'tmcp/utils'; +import { type SvelteMcp } from '../../index.js'; -const autofixer_schema = v.object({ - code: v.string(), - desired_svelte_version: v.pipe( - v.union([v.string(), v.number()]), - v.description( - 'The desired svelte version...if possible read this from the package.json of the user project, otherwise use some hint from the wording (if the user asks for runes it wants version 5). Default to 5 in case of doubt.', +let cached_schema: ReturnType | null = null; + +function get_autofixer_schema(stdio: boolean) { + let code = v.string(); + if (stdio) { + // we only add the description if we are running in stdio, this saves a few tokens for the remote server + code = v.pipe( + v.string(), + v.description( + "The code to be processed by the autofixer. It can also be a path to a file containing the code. If the file doesn't exists the string will be treated as the code", + ), + ); + } + return v.object({ + code, + desired_svelte_version: v.pipe( + v.union([v.string(), v.number()]), + v.description( + 'The desired svelte version...if possible read this from the package.json of the user project, otherwise use some hint from the wording (if the user asks for runes it wants version 5). Default to 5 in case of doubt.', + ), ), - ), - async: v.pipe( - v.optional(v.boolean()), - v.description( - 'If true the code is an async component/module and might use await in the markup or top-level awaits in the script tag. If possible check the svelte.config.js/svelte.config.ts to check if the option is enabled otherwise asks the user if they prefer using it or not. You can only use this option if the version is 5.', + async: v.pipe( + v.optional(v.boolean()), + v.description( + 'If true the code is an async component/module and might use await in the markup or top-level awaits in the script tag. If possible check the svelte.config.js/svelte.config.ts to check if the option is enabled otherwise asks the user if they prefer using it or not. You can only use this option if the version is 5.', + ), ), - ), - filename: v.pipe( - v.optional(v.string()), - v.description( - 'The filename of the component if available, it MUST be only the Component name with .svelte or .svelte.ts extension and not the entire path.', + filename: v.pipe( + v.optional(v.string()), + v.description( + 'The filename of the component if available, it MUST be only the Component name with .svelte or .svelte.ts extension and not the entire path.', + ), ), - ), -}); + }); +} const autofixer_output_schema = v.object({ issues: v.array(v.string()), @@ -40,7 +54,7 @@ export async function svelte_autofixer_handler({ desired_svelte_version: desired_svelte_version_unchecked, async, filename: filename_or_path, -}: v.InferInput) { +}: v.InferInput>) { // we validate manually because some clients don't support union in the input schema (looking at you cursor) const parsed_version = v.safeParse( v.union([v.literal(4), v.literal(5), v.literal('4'), v.literal('5')]), @@ -110,7 +124,11 @@ export function svelte_autofixer(server: SvelteMcp) { title: 'Svelte Autofixer', description: 'Given a svelte component or module returns a list of suggestions to fix any issues it has. This tool MUST be used whenever the user is asking to write svelte code before sending the code back to the user', - schema: autofixer_schema, + get schema() { + return ( + cached_schema ?? (cached_schema = get_autofixer_schema(server.ctx.custom?.stdio ?? false)) + ); + }, outputSchema: autofixer_output_schema, annotations: { title: 'Svelte Autofixer', @@ -129,6 +147,18 @@ export function svelte_autofixer(server: SvelteMcp) { if (server.ctx.sessionId && server.ctx.custom?.track) { await server.ctx.custom?.track?.(server.ctx.sessionId, 'svelte-autofixer'); } + + // we only do this if we know we are running in stdio mode (only stdio pass the context as true) + if (server.ctx.custom?.stdio) { + const [exists_sync, read_file] = await Promise.all([ + import('node:fs').then((mod) => mod.existsSync), + import('node:fs/promises').then((mod) => mod.readFile), + ]); + if (exists_sync(code)) { + code = await read_file(code, 'utf-8'); + } + } + try { const content = await svelte_autofixer_handler({ code, diff --git a/packages/mcp-server/src/mcp/index.ts b/packages/mcp-server/src/mcp/index.ts index d4defbb..f6a119e 100644 --- a/packages/mcp-server/src/mcp/index.ts +++ b/packages/mcp-server/src/mcp/index.ts @@ -24,6 +24,7 @@ export const server = new McpServer( }, ).withContext<{ track?: (sessionId: string, event: string, extra?: string) => Promise; + stdio?: boolean; }>(); export type SvelteMcp = typeof server; diff --git a/packages/mcp-stdio/src/index.ts b/packages/mcp-stdio/src/index.ts index 31c7716..8c4cb8d 100644 --- a/packages/mcp-stdio/src/index.ts +++ b/packages/mcp-stdio/src/index.ts @@ -14,7 +14,9 @@ const cli = sade('svelte-mcp'); cli.command('__mcp', '', { default: true }).action(() => { const transport = new StdioTransport(server); - transport.listen(); + transport.listen({ + stdio: true, + }); }); cli diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 752788f..8447949 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,9 +6,18 @@ settings: catalogs: ai: + '@anthropic-ai/sdk': + specifier: ^0.71.0 + version: 0.71.2 + '@mcp-ui/server': + specifier: ^6.0.0 + version: 6.0.0 '@modelcontextprotocol/inspector': specifier: ^0.19.0 version: 0.19.0 + '@opencode-ai/plugin': + specifier: ^1.1.44 + version: 1.1.44 lint: '@eslint/compat': specifier: ^2.0.0 @@ -16,12 +25,27 @@ catalogs: '@eslint/js': specifier: ^9.36.0 version: 9.39.2 + '@types/eslint-scope': + specifier: ^8.3.2 + version: 8.4.0 '@typescript-eslint/parser': specifier: ^8.44.0 version: 8.54.0 + '@typescript-eslint/types': + specifier: ^8.44.0 + version: 8.54.0 + eslint: + specifier: ^9.36.0 + version: 9.39.2 eslint-config-prettier: specifier: ^10.0.1 version: 10.1.8 + eslint-plugin-import: + specifier: ^2.32.0 + version: 2.32.0 + eslint-plugin-pnpm: + specifier: ^1.3.0 + version: 1.5.0 eslint-plugin-svelte: specifier: ^3.12.5 version: 3.14.0 @@ -37,6 +61,9 @@ catalogs: svelte-eslint-parser: specifier: ^1.4.0 version: 1.4.1 + typescript-eslint: + specifier: ^8.44.0 + version: 8.54.0 svelte: '@sveltejs/adapter-vercel': specifier: ^6.0.0 @@ -54,22 +81,64 @@ catalogs: specifier: ^4.0.0 version: 4.3.5 tmcp: + '@tmcp/adapter-valibot': + specifier: ^0.1.5 + version: 0.1.5 '@tmcp/transport-http': - specifier: ^0.8.4 - version: 0.8.4 + specifier: ^0.8.5 + version: 0.8.5 + '@tmcp/transport-in-memory': + specifier: ^0.0.6 + version: 0.0.6 + '@tmcp/transport-stdio': + specifier: ^0.4.2 + version: 0.4.2 tmcp: - specifier: ^1.19.0 - version: 1.19.2 + specifier: ^1.19.3 + version: 1.19.3 tooling: + '@changesets/cli': + specifier: ^2.29.7 + version: 2.29.8 + '@svitejs/changesets-changelog-github-compact': + specifier: ^1.2.0 + version: 1.2.0 + '@types/estree': + specifier: ^1.0.8 + version: 1.0.8 '@types/node': specifier: ^24.3.1 version: 24.10.9 + '@valibot/to-json-schema': + specifier: ^1.5.0 + version: 1.5.0 '@vercel/analytics': specifier: ^2.0.0 version: 2.0.1 + dotenv: + specifier: ^17.2.3 + version: 17.2.3 + node-resolve-ts: + specifier: ^1.0.2 + version: 1.0.2 + publint: + specifier: ^0.3.13 + version: 0.3.17 + sade: + specifier: 1.8.1 + version: 1.8.1 + ts-blank-space: + specifier: ^0.7.0 + version: 0.7.0 + tsdown: + specifier: ^0.20.0 + version: 0.20.1 typescript: specifier: ^5.0.0 version: 5.9.3 + valibot: + specifier: ^1.2.0 + version: 1.2.0 vite: specifier: ^7.0.4 version: 7.3.1 @@ -79,6 +148,9 @@ catalogs: vitest: specifier: ^4.0.0 version: 4.0.18 + zimmerframe: + specifier: ^1.1.4 + version: 1.1.4 importers: @@ -149,13 +221,13 @@ importers: version: link:../../packages/mcp-server '@tmcp/transport-http': specifier: catalog:tmcp - version: 0.8.4(tmcp@1.19.2(typescript@5.9.3)) + version: 0.8.5(tmcp@1.19.3(typescript@5.9.3)) '@vercel/analytics': specifier: catalog:tooling version: 2.0.1(@sveltejs/kit@2.50.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.4)(vite@7.3.1(@types/node@24.10.9)(yaml@2.8.2)))(svelte@5.48.4)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.9)(yaml@2.8.2)))(react@18.3.1)(svelte@5.48.4) tmcp: specifier: catalog:tmcp - version: 1.19.2(typescript@5.9.3) + version: 1.19.3(typescript@5.9.3) devDependencies: '@eslint/compat': specifier: catalog:lint @@ -225,10 +297,10 @@ importers: version: 6.0.0(hono@4.11.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@4.1.8) '@tmcp/adapter-valibot': specifier: catalog:tmcp - version: 0.1.5(tmcp@1.19.2(typescript@5.9.3))(valibot@1.2.0(typescript@5.9.3)) + version: 0.1.5(tmcp@1.19.3(typescript@5.9.3))(valibot@1.2.0(typescript@5.9.3)) '@tmcp/transport-in-memory': specifier: catalog:tmcp - version: 0.0.5(tmcp@1.19.2(typescript@5.9.3)) + version: 0.0.6(tmcp@1.19.3(typescript@5.9.3)) '@typescript-eslint/parser': specifier: catalog:lint version: 8.54.0(eslint@9.39.2)(typescript@5.9.3) @@ -246,7 +318,7 @@ importers: version: 1.4.1(svelte@5.48.4) tmcp: specifier: catalog:tmcp - version: 1.19.2(typescript@5.9.3) + version: 1.19.3(typescript@5.9.3) ts-blank-space: specifier: catalog:tooling version: 0.7.0 @@ -292,14 +364,14 @@ importers: version: 1.8.1 tmcp: specifier: catalog:tmcp - version: 1.19.2(typescript@5.9.3) + version: 1.19.3(typescript@5.9.3) devDependencies: '@sveltejs/mcp-server': specifier: workspace:^ version: link:../mcp-server '@tmcp/transport-stdio': specifier: catalog:tmcp - version: 0.4.1(tmcp@1.19.2(typescript@5.9.3)) + version: 0.4.2(tmcp@1.19.3(typescript@5.9.3)) '@types/node': specifier: catalog:tooling version: 24.10.9 @@ -1725,8 +1797,8 @@ packages: peerDependencies: tmcp: ^1.16.3 - '@tmcp/transport-http@0.8.4': - resolution: {integrity: sha512-n/4oIYjHyX5i6LFC3+qlxtc/IIv1xoqLhPVbdA5VYDyUWU6QRBU3+ffMXQuAPs0Q6Z+ZCzcO30V90yRMAxuriQ==} + '@tmcp/transport-http@0.8.5': + resolution: {integrity: sha512-qQLqiCTtbxtTSswqOn/782df7O57RxI/yLUtCDQ++kHEhbmDUc8glmmtGJ3mrb7yPSPoM5VF2Pc2Q5cA6quzLA==} peerDependencies: '@tmcp/auth': ^0.3.3 || ^0.4.0 tmcp: ^1.18.0 @@ -1734,13 +1806,13 @@ packages: '@tmcp/auth': optional: true - '@tmcp/transport-in-memory@0.0.5': - resolution: {integrity: sha512-m8l4+GdCj3NNwVxClnE8fV0yVn5ihpXhIsoOTG3CxeKoC/4H5+HPXeIIb25uSfFt6rccDfqH7DDPjMGDfPtoXA==} + '@tmcp/transport-in-memory@0.0.6': + resolution: {integrity: sha512-j+xcfQa7ksiIkA/8s3SAsTnM3GeZTd+X8F++Mv/tAT91+UkCzyhemPz0MqW7i1ruxJyIWooOB6JhWCsyF+LvhA==} peerDependencies: tmcp: ^1.17.0 - '@tmcp/transport-stdio@0.4.1': - resolution: {integrity: sha512-464x8HNrvjLLtKZsrFWUL13GnBFFtrNoWxnE0rHbcmQSYRqtS8WseWtQCYstj2Vcg9kRlIUVFGDIljGNP4/N4A==} + '@tmcp/transport-stdio@0.4.2': + resolution: {integrity: sha512-OLVLJzUXAKsCvenkjPf5ygli9ZcbEv3Lcei/ry+DB4T1NzvDc1oU3m41zYtHhAmbES1h6om3T9f/zonBSDFMRQ==} peerDependencies: tmcp: ^1.16.3 @@ -3757,8 +3829,8 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} - tmcp@1.19.2: - resolution: {integrity: sha512-/AEG/jlzflGKqCKm7GNdhz50VtFlN+3vcnKd+iQJXcHdIPrpCRMolEj57SfgcXcfE2ouX7J6Q05gPKsS2NZhKg==} + tmcp@1.19.3: + resolution: {integrity: sha512-plz/TLKNFrdfQN32LjCTN6ULy6pynfGPgHcU7KGCI5dBrxQ9Mub99SmcYuzxEkLjJooQuOD3gosSwZEl1htOtw==} to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} @@ -5410,31 +5482,31 @@ snapshots: transitivePeerDependencies: - encoding - '@tmcp/adapter-valibot@0.1.5(tmcp@1.19.2(typescript@5.9.3))(valibot@1.2.0(typescript@5.9.3))': + '@tmcp/adapter-valibot@0.1.5(tmcp@1.19.3(typescript@5.9.3))(valibot@1.2.0(typescript@5.9.3))': dependencies: '@standard-schema/spec': 1.1.0 '@valibot/to-json-schema': 1.5.0(valibot@1.2.0(typescript@5.9.3)) - tmcp: 1.19.2(typescript@5.9.3) + tmcp: 1.19.3(typescript@5.9.3) valibot: 1.2.0(typescript@5.9.3) - '@tmcp/session-manager@0.2.1(tmcp@1.19.2(typescript@5.9.3))': + '@tmcp/session-manager@0.2.1(tmcp@1.19.3(typescript@5.9.3))': dependencies: - tmcp: 1.19.2(typescript@5.9.3) + tmcp: 1.19.3(typescript@5.9.3) - '@tmcp/transport-http@0.8.4(tmcp@1.19.2(typescript@5.9.3))': + '@tmcp/transport-http@0.8.5(tmcp@1.19.3(typescript@5.9.3))': dependencies: - '@tmcp/session-manager': 0.2.1(tmcp@1.19.2(typescript@5.9.3)) + '@tmcp/session-manager': 0.2.1(tmcp@1.19.3(typescript@5.9.3)) esm-env: 1.2.2 - tmcp: 1.19.2(typescript@5.9.3) + tmcp: 1.19.3(typescript@5.9.3) - '@tmcp/transport-in-memory@0.0.5(tmcp@1.19.2(typescript@5.9.3))': + '@tmcp/transport-in-memory@0.0.6(tmcp@1.19.3(typescript@5.9.3))': dependencies: json-rpc-2.0: 1.7.1 - tmcp: 1.19.2(typescript@5.9.3) + tmcp: 1.19.3(typescript@5.9.3) - '@tmcp/transport-stdio@0.4.1(tmcp@1.19.2(typescript@5.9.3))': + '@tmcp/transport-stdio@0.4.2(tmcp@1.19.3(typescript@5.9.3))': dependencies: - tmcp: 1.19.2(typescript@5.9.3) + tmcp: 1.19.3(typescript@5.9.3) '@tsconfig/node10@1.0.12': {} @@ -7625,7 +7697,7 @@ snapshots: tinyrainbow@3.0.3: {} - tmcp@1.19.2(typescript@5.9.3): + tmcp@1.19.3(typescript@5.9.3): dependencies: '@standard-schema/spec': 1.1.0 json-rpc-2.0: 1.7.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c4d9030..c40d8ad 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -31,11 +31,11 @@ catalogs: svelte: ^5.47.0 svelte-check: ^4.0.0 tmcp: - '@tmcp/adapter-valibot': ^0.1.4 - '@tmcp/transport-http': ^0.8.4 - '@tmcp/transport-in-memory': ^0.0.5 - '@tmcp/transport-stdio': ^0.4.0 - tmcp: ^1.19.0 + '@tmcp/adapter-valibot': ^0.1.5 + '@tmcp/transport-http': ^0.8.5 + '@tmcp/transport-in-memory': ^0.0.6 + '@tmcp/transport-stdio': ^0.4.2 + tmcp: ^1.19.3 tooling: '@changesets/cli': ^2.29.7 '@svitejs/changesets-changelog-github-compact': ^1.2.0