Adding custom MCP tools & resources

A Speakeasy SDK developer can now augment their MCP server by adding extensions for additional custom tools and resources.

Setting Up MCP Extensions

To do so, a developer must create a new file under the mcp-server directory called server.extensions.ts. The file should expose the following function contract exactly. This function can be used to register custom tools and resources on to generated MCP server.

server.extensions.ts
import { Register } from "./extensions.js";
export function registerMCPExtensions(register: Register): void {
register.tool(...);
register.resource(...);
}

After adding this file and defining your custom tools and resources, make sure to execute speakeasy run.

Building and Registering Custom Tools

Below is an example of a custom MCP tool (opens in a new tab) that fetches files from public GitHub repositories. This custom tool is then registered in the registerMCPExtensions function. One can opt to define their custom /resources in separate files or define everything within the server.extensions.ts file.

The most important thing is to ensure that the tool fits the ToolDefinition type exposed by Speakeasy. Notice this tool has args defined as a Zod schema, tools can also have no arguments defined. Every tool also defines a tool method for execution.

Speakeasy exposes a formatResult utility function from tools.ts that can be used to ensure the result ends up in the proper MCP format when returned. Using this function is optional as long as the return matches the required type.

custom/getGithubFileTool.ts
import { z } from "zod";
import { formatResult, ToolDefinition } from "../tools.js";
type FetchGithubFileRequest = {
org: string;
repo: string;
filepath: string;
};
const FetchGithubFileRequest$inboundSchema: z.ZodType<
FetchGithubFileRequest,
z.ZodTypeDef,
unknown
> = z.object({
org: z.string(),
repo: z.string(),
filepath: z.string()
});
const fetchGithubFileToolArg = {
request: FetchGithubFileRequest$inboundSchema
};
export const tool$fetchGithubFile: ToolDefinition<typeof fetchGithubFileToolArg> = {
name: "admin_get_git_file",
description: "Gets a file from a GitHub repository",
scopes: [],
args: fetchGithubFileToolArg,
tool: async (_client, args, _extra) => {
const { org, repo, filepath } = args.request;
const url = `https://raw.githubusercontent.com/${org}/${repo}/main/${filepath}`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch file: ${response.statusText}`);
}
const content = await response.text();
return formatResult(content, { response });
} catch (err) {
console.error(err);
return {
content: [{ type: "text", text: `Error: "${String(err)}"` }],
isError: true,
};
}
}
};
server.extensions.ts
import { tool$fetchGithubFile } from "./custom/getGithubFileTool.js";
import { Register } from "./extensions.js";
export function registerMCPExtensions(register: Register): void {
register.tool(tool$fetchGithubFile);
}

Building and Registering Custom Resources

An MCP Resource (opens in a new tab) represents any kind of data source that an MCP server can make available to clients. Each resource is identified by a unique URI and can contain either text or binary data. Resources can encompass a variety of things, including:

  • File contents (local or remote)
  • Database records
  • Screenshots and images
  • Static API responses
  • And more

Below is an example of a custom MCP Resource that embeds a local PDF file as a resource into an MCP server.

The custom resource must fit the ResourceDefinition or ResourceTemplateDefinition type exposed by Speakeasy. A resource must define a read function for reading data from the defined URI.

Speakeasy exposes a formatResult utility function from resources.ts that can be used to ensure the result ends up in the proper MCP format when returned. Using this function is optional as long as the return matches the required type.

custom/aboutSpeakeasyResource.ts
import { formatResult, ResourceDefinition } from "../resources.js";
import fs from 'node:fs/promises';
export const resource$aboutSpeakeasy: ResourceDefinition = {
name: "About Speakeasy",
description: "Reads the about Speakeasy PDF",
resource: "file:///Users/ryanalbert/about_speakeasy.pdf",
scopes: [],
read: async (_client, uri, _extra) => {
try {
const pdfContent = await fs.readFile(uri, null);
return formatResult(pdfContent, uri, {
mimeType: "application/pdf"
});
} catch (err) {
console.error(err);
return {
contents: [{
isError: true,
uri: uri.toString(),
mimeType: "application/json",
text: `Failed to read PDF file: ${String(err)}`
}]
};
}
}
}
server.extensions.ts
import { resource$aboutSpeakeasy } from "./custom/aboutSpeakeasyResource.js";
import { Register } from "./extensions.js";
export function registerMCPExtensions(register: Register): void {
register.resource(resource$aboutSpeakeasy);
}

Running MCP Locally

Releasing an npm package every time one wants to test their custom changes can be very time-consuming. Luckily, it is relatively easy to directly run MCP server changes locally.

After each change, execute npm run build to rebuild the MCP server.

In a claude_desktop_config.json, add a config like this to reference a locally built MCP server. Now just restart your chosen client, and you are ready to test and debug!

Info Icon

Ensure you have Node V20 or above installed.

{
"mcpServers": {
"Todos": {
"command": "node",
"args": [
"/path/to/repo/bin/mcp-server.js",
"start"
],
"env": {
"TODOS_API_TOKEN": "..."
}
}
}
}