Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/sxzz/stackblitz-mcp. MCP server for reading files from StackBlitz projects stackblitz-mcp.sxzz.dev
Something went wrong. Try again.
8.9 kB · 357 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358#!/usr/bin/env node
import process from 'node:process'import { McpServer, ResourceTemplate,} from '@modelcontextprotocol/sdk/server/mcp.js'import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'import { z } from 'zod'import { fetchProject, getFileContents, getFileList, resolveProjectId,} from './stackblitz.js'import { buildTree, formatTree } from './tree.js'
const server = new McpServer({ name: 'stackblitz-mcp', version: '0.1.0',})
// --- Resources ---
server.registerResource( 'project_tree', new ResourceTemplate('stackblitz://{projectId}/tree', { list: undefined }), { description: 'File tree of a StackBlitz project', mimeType: 'text/plain', }, async (uri, variables) => { const projectId = resolveProjectId(String(variables.projectId)) const { project } = await fetchProject(projectId) const files = getFileList(project) const tree = buildTree(files) return { contents: [ { uri: uri.href, mimeType: 'text/plain', text: formatTree(tree), }, ], } },)
server.registerResource( 'project_file', new ResourceTemplate('stackblitz://{projectId}/files/{+path}', { list: undefined, }), { description: 'Contents of a file in a StackBlitz project' }, async (uri, variables) => { const projectId = resolveProjectId(String(variables.projectId)) const path = String(variables.path) const { project } = await fetchProject(projectId) const file = getFileContents(project, path)
if (!file) { throw new Error(`File not found: ${path}`) }
return { contents: [ { uri: uri.href, mimeType: guessMimeType(path), text: file.contents, }, ], } },)
// --- Tools ---
server.registerTool( 'resolve_project', { description: 'Resolve a StackBlitz project URL or ID and return project metadata', inputSchema: { projectRef: z.string().min(1).describe('StackBlitz project ID or URL'), }, }, async ({ projectRef }) => { const projectId = resolveProjectId(projectRef) const { project } = await fetchProject(projectId) const files = getFileList(project)
return { content: [ { type: 'text' as const, text: JSON.stringify( { projectId, title: project.title, description: project.description, preset: project.preset, visibility: project.visibility, fileCount: files.length, }, null, 2, ), }, { type: 'resource_link' as const, uri: `stackblitz://${projectId}/tree`, name: `${projectId} file tree`, mimeType: 'text/plain', }, ], } },)
server.registerTool( 'list_files', { description: 'List files in a StackBlitz project, optionally filtered by path prefix', inputSchema: { projectRef: z.string().min(1).describe('StackBlitz project ID or URL'), path: z.string().optional().describe('Filter files by path prefix'), }, }, async ({ projectRef, path }) => { const projectId = resolveProjectId(projectRef) const { project } = await fetchProject(projectId) let files = getFileList(project)
if (path) { const prefix = path.endsWith('/') ? path : `${path}/` files = files.filter((f) => f.startsWith(prefix) || f === path) }
const tree = buildTree(files)
return { content: [ { type: 'text' as const, text: formatTree(tree), }, ], } },)
server.registerTool( 'read_file', { description: 'Read the contents of a file from a StackBlitz project', inputSchema: { projectRef: z.string().min(1).describe('StackBlitz project ID or URL'), path: z.string().min(1).describe('File path within the project'), }, }, async ({ projectRef, path }) => { const projectId = resolveProjectId(projectRef) const { project } = await fetchProject(projectId) const file = getFileContents(project, path)
if (!file) { const files = getFileList(project) const basename = path.split('/').pop() || '' const suggestions = files.filter((f) => f.includes(basename)).slice(0, 5)
return { content: [ { type: 'text' as const, text: `File not found: ${path}${ suggestions.length ? `\n\nDid you mean:\n${ suggestions.map((s) => ` - ${s}`).join('\n') }` : '' }`, }, ], isError: true, } }
return { content: [ { type: 'text' as const, text: file.contents, }, { type: 'resource_link' as const, uri: `stackblitz://${projectId}/files/${path}`, name: path, mimeType: guessMimeType(path), }, ], } },)
server.registerTool( 'search_files', { description: 'Search for content within files of a StackBlitz project', inputSchema: { projectRef: z.string().min(1).describe('StackBlitz project ID or URL'), query: z.string().min(1).describe('Search query string'), regex: z.boolean().default(false).describe('Treat query as regex'), caseSensitive: z .boolean() .default(false) .describe('Case-sensitive search'), maxResults: z .number() .int() .positive() .default(50) .describe('Maximum number of results'), }, }, async ({ projectRef, query, regex, caseSensitive, maxResults }) => { const projectId = resolveProjectId(projectRef) const { project } = await fetchProject(projectId)
let pattern: RegExp | undefined if (regex) { try { pattern = new RegExp(query, caseSensitive ? 'g' : 'gi') } catch (error) { return { content: [ { type: 'text' as const, text: `Invalid regex: ${ error instanceof Error ? error.message : String(error) }`, }, ], isError: true, } } } const searchQuery = caseSensitive ? query : query.toLowerCase()
interface Match { file: string line: number text: string }
const matches: Match[] = []
for (const file of Object.values(project.appFiles)) { if (matches.length >= maxResults) break
const lines = file.contents.split('\n') for (const [i, line] of lines.entries()) { if (matches.length >= maxResults) break let found: boolean
if (pattern) { found = pattern.test(line) pattern.lastIndex = 0 } else { found = (caseSensitive ? line : line.toLowerCase()).includes( searchQuery, ) }
if (found) { matches.push({ file: file.fullPath, line: i + 1, text: line.trimEnd(), }) } } }
if (matches.length === 0) { return { content: [ { type: 'text' as const, text: `No matches found for "${query}"` }, ], } }
const grouped = new Map<string, Match[]>() for (const match of matches) { const group = grouped.get(match.file) || [] group.push(match) grouped.set(match.file, group) }
let output = `Found ${matches.length} match${ matches.length === 1 ? '' : 'es' } in ${grouped.size} file${grouped.size === 1 ? '' : 's'}:\n\n`
for (const [file, fileMatches] of grouped) { output += `## ${file}\n` for (const m of fileMatches) { output += ` L${m.line}: ${m.text}\n` } output += '\n' }
return { content: [{ type: 'text' as const, text: output.trimEnd() }], } },)
// --- Utilities ---
const MIME_TYPES: Record<string, string> = { ts: 'text/typescript', tsx: 'text/typescript', js: 'text/javascript', jsx: 'text/javascript', json: 'application/json', md: 'text/markdown', html: 'text/html', css: 'text/css', scss: 'text/scss', less: 'text/less', svg: 'image/svg+xml', yaml: 'text/yaml', yml: 'text/yaml', xml: 'text/xml', txt: 'text/plain', vue: 'text/x-vue', svelte: 'text/x-svelte',}
function guessMimeType(path: string): string { const ext = path.split('.').pop()?.toLowerCase() return MIME_TYPES[ext || ''] || 'text/plain'}
// --- Start ---
async function main(): Promise<void> { const transport = new StdioServerTransport() await server.connect(transport) console.error('StackBlitz MCP server running on stdio')}
main().catch((error: unknown) => { console.error('Fatal error:', error) process.exit(1)})