Ken Muse

Adding an MCP Server to a VS Code Extension


In my previous post, I used a Bash-based Model Context Protocol (MCP) server to solve a frustrating problem: Copilot kept making inconsistent choices about my blog’s tags and categories. By exposing that metadata through MCP tools instead of letting the AI browse and interpret arbitrary files, I got deterministic – consistent and repeatable – results. If you’re not familiar with MCP, my earlier article covers the fundamentals.

The Bash server worked, but it had some rough edges. Parsing YAML frontmatter with yq and jq was fragile – edge cases in multiline values or special characters could break things silently. Shell scripts are difficult to unit test, and the server lived as a separate .vscode/mcp.json configuration file outside the extension I was already building for blog management. Every time the blog’s structure evolved, I had to update and redeploy two separate things independently.

I wanted something better: an MCP server that ships inside the VS Code extension itself – written in TypeScript, testable with standard frameworks, and automatically available the moment you install the extension.

Why embed MCP in the extension?

Moving from a standalone Bash script to an embedded TypeScript MCP server addressed several practical concerns:

  • Maintainability
    TypeScript is the same language as the rest of the extension. One codebase, one set of dependencies, one build process. The YAML parsing uses a proper library (yaml) instead of shell tools, handling edge cases correctly.
  • Testability
    Standard test runners work out of the box. You can write unit tests for slug generation, frontmatter parsing, and taxonomy operations – something that’s painful to do with a Bash script.
  • Versioning and deployment
    The MCP server is compiled and bundled into the extension’s VSIX package (the standard format for distributing VS Code extensions). When the extension updates, the MCP server updates with it. No separate configuration file to manage, no extra installation steps.
  • Scoped availability
    The MCP server only exists when the extension is active. It’s not a general-purpose tool sitting on the network – it’s specifically designed for this blog’s Hugo-based workflow. That’s intentional. The tools rely on the structure of my content directory, my frontmatter format, and my taxonomy conventions. Making it part of the extension means it’s available exactly when and where it’s needed, and nowhere else.

The choice of stdio transport reinforces that scoping. With stdio, VS Code launches the MCP server as a child process and communicates over standard input/output. There’s no port to manage, no network exposure, and the server’s lifecycle is tied directly to the extension. An HTTP-based MCP server would need to find an available port, handle potential conflicts, and would be accessible to anything on the network – none of which makes sense for a tool this specific.

How VS Code discovers extension MCP servers

VS Code provides an API that lets extensions register MCP servers programmatically. This is a two-step process: a static declaration in package.json followed by a runtime provider implementation.

The static declaration tells VS Code that your extension can provide MCP server definitions. You add a mcpServerDefinitionProviders entry under contributes:

  1   {
  2       "contributes": {
  3           "mcpServerDefinitionProviders": [
  4               {
  5                   "id": "blogtools.mcpServer",
  6                   "label": "Blog MCP"
  7               }
  8           ]
  9       }
 10   }

Essentially, the provider will be dynamically contributing an entry similar to the mcp.json from the extension. The id uniquely identifies your provider within the extension, and the label is what users see when managing MCP servers through the VS Code UI. This declaration is what makes your MCP server discoverable – VS Code knows to ask your extension for server definitions when it activates.

The runtime side uses vscode.lm.registerMcpServerDefinitionProvider() to supply the actual server configuration. The provider’s provideMcpServerDefinitions method returns an array of server definitions, and the optional resolveMcpServerDefinition method lets you perform additional setup (like authentication) before the server starts.

Here’s a simplified version of the implementation:

  1   import * as vscode from 'vscode';
  2   
  3   // Path to the compiled MCP server module within the extension
  4   const MCP_SERVER_MODULE = ['dist', 'mcp', 'metadataMcpServer.mjs'];
  5   
  6   export function registerMcpServerDefinitionProvider(
  7     context: vscode.ExtensionContext
  8   ) {
  9     const provider = vscode.lm.registerMcpServerDefinitionProvider(
 10       `blogtools.mcpServer`,
 11       {
 12         // The provider implementation that returns MCP server definitions
 13         provideMcpServerDefinitions: async () => {
 14   
 15           // Get the workspace folder to determine the content directory path
 16           const workspaceFolders = vscode.workspace.workspaceFolders;
 17           if (!workspaceFolders || workspaceFolders.length === 0) {
 18             return [];
 19           }
 20   
 21           // My MCP server needs to read the Hugo content directory, so
 22           // define the full path to that directory based on the workspace
 23           // root.
 24           const contentDir = vscode.Uri.joinPath(
 25             workspaceFolders[0].uri, 'content'
 26           ).fsPath;
 27   
 28           // Build the path to the compiled MCP server module inside 
 29           // the extension by combining the root path to the extension
 30           // with the relative path to the server module.
 31           const mcpServerPath = vscode.Uri.joinPath(
 32             context.extensionUri, ...MCP_SERVER_MODULE
 33           ).fsPath;
 34   
 35           // And then return the definition for the MCP server
 36           return [
 37             new vscode.McpStdioServerDefinition(
 38               // The display name
 39               "Blog MCP",
 40               // The binary to invoke
 41               'node',
 42               // Arguments to the binary -- in this case, 
 43               // the path to the MCP JS module
 44               [mcpServerPath],
 45               // Environment variable with the content directory path
 46               // so the server can read tags/categories
 47               { CONTENT_DIR: contentDir },
 48   
 49               // Version string for the server. I'm using the same
 50               // version as the extension itself, but you could choose
 51               // to version the server separately if you wanted.
 52               context.extension.packageJSON.version
 53           )];
 54         },
 55   
 56         // This method can read the definition above to update the configuration
 57         // before the server starts. For example, it could ask the user for a
 58         // key if needed or prompt for authentication/configuration. It can also
 59         // return undefined to cancel the request and not start the server.
 60         resolveMcpServerDefinition: async (server: vscode.McpServerDefinition) => {
 61           return server;
 62         }
 63       }
 64     );
 65   
 66     context.subscriptions.push(provider);
 67   }

A few things worth noting here. The McpStdioServerDefinition constructor takes a label, a command (node), command-line arguments, environment variables, and a version string. The CONTENT_DIR environment variable tells the MCP server where to find the Hugo content directory – this is how the server knows where to look for tags, categories, and blog posts without hardcoding any paths. Yes, I could have passed that as a command-line argument, but I wanted you to see that you have flexibility in how you provide a configuration to the MCP server.

The provider ID passed to registerMcpServerDefinitionProvider must match the id declared in your package.json. VS Code uses this to connect the static declaration with the runtime implementation. You call this registration function during extension activation, and the provider’s disposable gets added to context.subscriptions for automatic cleanup.

Building the MCP server

The MCP server itself is a standalone Node.js module that uses the official @modelcontextprotocol/sdk. It creates an McpServer instance and connects it to a StdioServerTransport:

  1   import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
  2   import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
  3   
  4   const server = new McpServer({
  5     // The name and version metadata that the server will provide to agents.
  6     name: 'Blog MCP',
  7     // I load the package.json file to get the version, but you can hardcode.
  8     version: pkg.version
  9   });
 10   
 11   // Register tools ...
 12   
 13   const transport = new StdioServerTransport();
 14   await server.connect(transport);

The server registers several tools that give the AI precise control over blog metadata:

  • list_tags, list_categories, and list_series return all available taxonomy items with their names and descriptions. These are the tools that solved the original inconsistency problem – the AI gets the exact same structured list every time instead of browsing the file system and guessing at the structure.
  • get_post_taxonomy retrieves the tags, categories, and/or series for a specific blog post. Instead of the LLM reading the raw markdown and trying to parse it, it can call this tool to get correct details.
  • update_post_taxonomy updates the settings in the frontmatter of a blog post. The AI doesn’t have to guess at the format or how to organize/encode the YAML. The tool takes care of everything.

Each tool follows a consistent pattern: a name, a description, an optional input schema defined with Zod (a TypeScript validation library), and an async handler. Here’s what list_categories looks like:

  1   import { listTaxonomies } from './taxonomy.js';
  2   import { CONTENT_DIR } from '../constants.js';
  3   
  4   export const tool = {
  5     name: 'list_categories',
  6     description: 'List the available categories and their descriptions.',
  7     async handler() {
  8       const taxonomies = listTaxonomies(CONTENT_DIR, ['categories']);
  9       return {
 10         content: [{
 11           type: 'text' as const,
 12           text: JSON.stringify(taxonomies, null, 2)
 13         }]
 14       };
 15     }
 16   };

The listTaxonomies function extracts the name, title, and description from the category files in my content directory. Because the function reads the actual source of truth – the same files Hugo uses to build the site – the AI always sees an accurate representation of what’s available.

Tools can accept inputs from the LLM to provide some context for the request. The SDK uses Zod schemas to provide the schema for the inputs, providing both validation and documentation. The update_post_taxonomy tool, for example, defines its inputs like this:

 1   const inputSchema = z.object({
 2     filePath: z.string().describe(
 3       'The path to the blog post markdown file'
 4     ),
 5     taxonomyType: z.array(z.enum(['tags', 'categories', 'series']))
 6       .describe('One or more taxonomies to retrieve'),
 7     values: z.union([z.string(), z.array(z.string())])
 8       .describe('Values to add, remove, or replace with')
 9   });

The schema descriptions appear in the tool confirmation dialog in VS Code, helping both the AI and the user understand what each parameter does.

Bringing it together

When you install an extension that contains an MCP server, the MCP server becomes available in Copilot’s agent mode automatically. There’s nothing to configure – no .vscode/mcp.json to create, no server to start manually. VS Code discovers the server automatically through the provider registration, launches a process for the MCP server when needed, and connects over stdio.

The result in this case is that Copilot can call list_tags to see what tags exist and get_post_taxonomy to check what’s already applied to a post – all with deterministic, structured data. The AI makes decisions based on consistent inputs and outputs rather than sampling random files. Because the MCP server ships inside the extension, updating it is as simple as releasing a new version of the extension.

Of course, there’s more that you can do with your MCP server. For example, an MCP server can interact with the LLM to ask questions or perform complex analyses. It could suggest alternative titles based on the current title of a post, or it could quickly summarize the content of a post. You could index these summaries to make it easy to suggest related content for linking. The MCP server could also analyze the most popular content based on website traffic to help you decide what to write about next. There’s no end to the possibilities!

If you’re building a VS Code extension that could benefit from giving AI agents structured access to your domain-specific data, consider adding an MCP server. It’s a surprisingly easy way to improve your development experience and enhance the AI capabilities available to you in your projects.