@macroforge/mcp-server
Version:
MCP server for Macroforge documentation and code analysis
645 lines (631 loc) • 25.4 kB
JavaScript
/**
* @module tools
*
* MCP Tool Registry and Request Handlers
*
* This module registers and implements all MCP tools exposed by the Macroforge server.
* It serves as the main business logic layer, handling tool requests from MCP clients.
*
* ## Registered Tools
*
* | Tool | Purpose |
* |------|---------|
* | `list-sections` | List all documentation sections with metadata |
* | `get-documentation` | Retrieve full content for specific sections |
* | `macroforge-autofixer` | Validate TypeScript code with @derive decorators |
* | `expand-code` | Expand macros and show generated code |
* | `get-macro-info` | Get documentation for macros and decorators |
*
* ## Architecture
*
* The module uses:
* - `docs-loader.js` for documentation loading and search
* - `macroforge` (optional) for native code validation and expansion
*
* Native bindings are loaded dynamically and gracefully degrade if unavailable.
*
* @see {@link registerTools} for the main entry point
*/
// @ts-types="@modelcontextprotocol/sdk/types"
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js';
import { getSection, loadSections, searchSections } from './docs-loader.js';
/** Cached documentation sections loaded at server startup */
let sections = [];
/**
* Registers all Macroforge MCP tools with the server instance.
*
* This function:
* 1. Loads documentation sections from disk
* 2. Registers a `tools/list` handler returning all available tools
* 3. Registers a `tools/call` handler routing requests to appropriate handlers
*
* The tools registered provide documentation access and code analysis capabilities
* for AI assistants working with Macroforge.
*
* @param server - The MCP Server instance to register tools with
*
* @example
* ```typescript
* import { Server } from '@modelcontextprotocol/sdk/server/index.js';
* import { registerTools } from './tools/index.ts';
*
* const server = new Server({ name: 'macroforge', version: '1.0.0' }, { capabilities: { tools: {} } });
* registerTools(server);
* ```
*/
export function registerTools(server) {
// Load documentation sections
sections = loadSections();
// Register tool listing
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'list-sections',
description: `Lists all Macroforge documentation sections.
Returns sections with:
- title: Section name
- use_cases: When this doc is useful (comma-separated keywords)
- path: File path
- category: Category name
WORKFLOW:
1. Call list-sections FIRST for any Macroforge-related task
2. Analyze use_cases to find relevant sections
3. Call get-documentation with ALL relevant section names
Example use_cases: "setup, install", "serialization, json", "validation, email"`,
inputSchema: {
type: 'object',
properties: {}
}
},
{
name: 'get-documentation',
description: `Retrieves full documentation content for Macroforge sections.
Supports flexible search by:
- Title (e.g., "Debug", "Vite Plugin")
- ID (e.g., "debug", "vite-plugin")
- Partial matches
Can accept a single section name or an array of sections.
After calling list-sections, analyze the use_cases and fetch ALL relevant sections at once.`,
inputSchema: {
type: 'object',
properties: {
section: {
anyOf: [
{ type: 'string' },
{ type: 'array', items: { type: 'string' } }
],
description: 'Section name(s) to retrieve. Supports single string or array of strings.'
}
},
required: ['section']
}
},
{
name: 'macroforge-autofixer',
description: `Validates TypeScript code with @derive decorators using Macroforge's native validation.
Returns structured JSON diagnostics with:
- level: error | warning | info
- message: What's wrong
- location: Byte offset range in the input code (when available)
- summary: Count of errors, warnings, and info messages
This tool MUST be used before sending Macroforge code to the user.
If require_another_tool_call_after_fixing is true, fix the issues and validate again.
Detects:
- Invalid/unknown macro names
- Malformed @derive decorators
- @serde validator issues (email, url, length, etc.)
- Macro expansion failures
- Syntax errors in generated code`,
inputSchema: {
type: 'object',
properties: {
code: {
type: 'string',
description: 'TypeScript code with @derive decorators to validate'
},
filename: {
type: 'string',
description: 'Filename for the code (default: input.ts)'
}
},
required: ['code']
}
},
{
name: 'expand-code',
description: `Expands Macroforge macros in TypeScript code and returns the transformed result.
Shows:
- The fully expanded TypeScript code with all generated methods
- Any diagnostics (errors, warnings, info) with byte offset locations (when available)
Useful for:
- Seeing what code the macros generate
- Understanding how @derive decorators transform your classes
- Debugging macro expansion issues`,
inputSchema: {
type: 'object',
properties: {
code: {
type: 'string',
description: 'TypeScript code with @derive decorators to expand'
},
filename: {
type: 'string',
description: 'Filename for the code (default: input.ts)'
}
},
required: ['code']
}
},
{
name: 'get-macro-info',
description: `Get documentation for Macroforge macros and decorators.
Returns information about:
- Macro descriptions (e.g., Debug, Serialize, Clone)
- Decorator documentation (e.g., @serde, @debug field decorators)
- Available macro options and configuration
Use without parameters to get the full manifest of all available macros and decorators.
Use with a name parameter to get info for a specific macro or decorator.`,
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Optional: specific macro or decorator name to look up'
}
}
}
}
]
};
});
// Register tool call handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case 'list-sections':
return handleListSections();
case 'get-documentation':
return handleGetDocumentation(args);
case 'macroforge-autofixer':
return handleAutofixer(args);
case 'expand-code':
return handleExpandCode(args);
case 'get-macro-info':
return handleGetMacroInfo(args);
default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
}
});
}
/**
* Handles the `list-sections` tool call.
*
* Returns a formatted list of all available documentation sections, filtering out
* sub-chunks to show only top-level sections. Each section displays its title,
* use cases, file path, and category.
*
* Sub-chunks (sections with a `parent_id`) are excluded from this list as they
* are accessed through their parent section via `get-documentation`.
*
* @returns MCP response with formatted text listing all sections
*/
function handleListSections() {
// Filter out sub-chunks (sections with parent_id) - only show top-level sections
const topLevelSections = sections.filter((s) => !s.parent_id);
const formatted = topLevelSections
.map((s) => `* title: [${s.title}], use_cases: [${s.use_cases}], path: [${s.path}], category: [${s.category_title}]`)
.join('\n');
return {
content: [
{
type: 'text',
text: `Available Macroforge documentation sections:\n\n${formatted}`
}
]
};
}
/**
* Handles the `get-documentation` tool call.
*
* Retrieves full documentation content for one or more sections. Supports flexible
* lookups by ID, title, or partial match.
*
* ## Chunked Section Handling
*
* For large documentation sections that are split into chunks:
* 1. Returns the first chunk's content as the main response
* 2. Appends a list of additional chunk IDs that can be requested separately
*
* This allows clients to progressively load large documentation without
* overwhelming context windows.
*
* @param args - Tool arguments
* @param args.section - Section name(s) to retrieve (string or array of strings)
* @returns MCP response with formatted markdown documentation content
*/
function handleGetDocumentation(args) {
const sectionNames = Array.isArray(args.section) ? args.section : [args.section];
const results = [];
for (const name of sectionNames) {
const section = getSection(sections, name);
if (section) {
// Check if this is a chunked section
if (section.is_chunked && section.chunk_ids && section.chunk_ids.length > 0) {
// Get the first chunk
const firstChunkId = section.chunk_ids[0];
const firstChunk = sections.find((s) => s.id === firstChunkId);
if (firstChunk) {
let result = `# ${section.title}\n\n${firstChunk.content}`;
// Add list of other available chunks
if (section.chunk_ids.length > 1) {
const otherChunks = section.chunk_ids.slice(1);
const chunkList = otherChunks
.map((id) => {
const chunk = sections.find((s) => s.id === id);
return chunk
? `- \`${id}\`: ${chunk.title.replace(`${section.title}: `, '')}`
: null;
})
.filter(Boolean)
.join('\n');
result +=
`\n\n---\n\n**This section has additional chunks available:**\n${chunkList}\n\nRequest specific chunks with \`get-documentation\` for more details.`;
}
results.push(result);
}
else {
results.push(`# ${section.title}\n\nChunked content not found.`);
}
}
else {
// Regular section - return content directly
results.push(`# ${section.title}\n\n${section.content}`);
}
}
else {
// Try fuzzy search
const matches = searchSections(sections, name);
if (matches.length > 0) {
const match = matches[0];
// Handle chunked sections in fuzzy match too
if (match.is_chunked && match.chunk_ids && match.chunk_ids.length > 0) {
const firstChunk = sections.find((s) => s.id === match.chunk_ids[0]);
if (firstChunk) {
let result = `# ${match.title}\n\n${firstChunk.content}`;
if (match.chunk_ids.length > 1) {
const otherChunks = match.chunk_ids.slice(1);
const chunkList = otherChunks
.map((id) => {
const chunk = sections.find((s) => s.id === id);
return chunk
? `- \`${id}\`: ${chunk.title.replace(`${match.title}: `, '')}`
: null;
})
.filter(Boolean)
.join('\n');
result +=
`\n\n---\n\n**This section has additional chunks available:**\n${chunkList}\n\nRequest specific chunks with \`get-documentation\` for more details.`;
}
results.push(result);
}
else {
results.push(`# ${match.title}\n\n${match.content}`);
}
}
else {
results.push(`# ${match.title}\n\n${match.content}`);
}
}
else {
results.push(`Documentation for "${name}" not found.`);
}
}
}
return {
content: [
{
type: 'text',
text: results.join('\n\n---\n\n')
}
]
};
}
/**
* Handles the `macroforge-autofixer` tool call.
*
* Validates TypeScript code containing @derive decorators using Macroforge's
* native Rust-based analyzer. Returns structured JSON diagnostics that clients
* can use to provide error messages and fix suggestions.
*
* ## Diagnostic Levels
*
* - `error` - Invalid code that will fail to compile
* - `warning` - Code that works but may have issues
* - `info` - Informational messages about the code
*
* ## Response Format
*
* Returns JSON with:
* - `diagnostics` - Array of issues with message and byte offset location (when available)
* - `summary` - Counts of errors, warnings, and info messages
* - `require_another_tool_call_after_fixing` - True if errors exist (should revalidate)
*
* @param args - Tool arguments
* @param args.code - TypeScript source code to validate
* @param args.filename - Optional filename for error messages (default: "input.ts")
* @returns MCP response with JSON-formatted diagnostics
*/
async function handleAutofixer(args) {
const filename = args.filename || 'input.ts';
try {
const macroforge = await importMacroforge();
if (!macroforge) {
return {
content: [
{
type: 'text',
text: JSON.stringify({
diagnostics: [{
level: 'error',
message: 'Native Macroforge bindings not available. Install macroforge.'
}],
summary: { errors: 1, warnings: 0, info: 0 },
require_another_tool_call_after_fixing: false
}, null, 2)
}
]
};
}
const result = macroforge.expandSync(args.code, filename, {});
const diagnostics = result.diagnostics || [];
const output = {
diagnostics: diagnostics.map((d) => ({
level: normalizeLevel(d.level),
message: d.message,
location: d.start !== undefined
? { start: d.start, end: d.end ?? d.start }
: undefined
})),
summary: {
errors: diagnostics.filter((d) => d.level === 'error').length,
warnings: diagnostics.filter((d) => d.level === 'warning').length,
info: diagnostics.filter((d) => d.level === 'info').length
},
require_another_tool_call_after_fixing: diagnostics.some((d) => d.level === 'error')
};
return {
content: [
{
type: 'text',
text: JSON.stringify(output, null, 2)
}
]
};
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: 'text',
text: JSON.stringify({
diagnostics: [{
level: 'error',
message: `Error during analysis: ${message}`
}],
summary: { errors: 1, warnings: 0, info: 0 },
require_another_tool_call_after_fixing: false
}, null, 2)
}
]
};
}
}
/**
* Handles the `expand-code` tool call.
*
* Expands Macroforge macros in TypeScript code and returns the transformed result.
* This is useful for understanding what code the macros generate and debugging
* macro expansion issues.
*
* ## Output Format
*
* Returns human-readable markdown with:
* - The fully expanded TypeScript code in a code block
* - Any diagnostics (errors, warnings, info) with byte offset locations when available
*
* @param args - Tool arguments
* @param args.code - TypeScript source code with @derive decorators to expand
* @param args.filename - Optional filename for error messages (default: "input.ts")
* @returns MCP response with formatted expanded code and diagnostics
*/
async function handleExpandCode(args) {
const filename = args.filename || 'input.ts';
try {
const macroforge = await importMacroforge();
if (!macroforge) {
return {
content: [
{
type: 'text',
text: 'Native Macroforge bindings not available. Install macroforge to enable code expansion.'
}
]
};
}
const result = macroforge.expandSync(args.code, filename, {});
const diagnostics = result.diagnostics || [];
// Format human-readable text
let text = `## Expanded Code\n\n\`\`\`typescript\n${result.code}\n\`\`\``;
if (diagnostics.length > 0) {
text += '\n\n## Diagnostics\n\n';
for (const d of diagnostics) {
const loc = d.start !== undefined ? ` (offset ${d.start}-${d.end ?? d.start})` : '';
text += `- **[${normalizeLevel(d.level)}]**${loc} ${d.message}\n`;
}
}
return {
content: [{ type: 'text', text }]
};
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: 'text',
text: `Error expanding code: ${message}`
}
]
};
}
}
/**
* Handles the `get-macro-info` tool call.
*
* Retrieves documentation for Macroforge macros and field decorators from the
* native manifest. Can return info for a specific macro/decorator or the full
* manifest of all available macros and decorators.
*
* ## Usage Modes
*
* - **Without name**: Returns full manifest with all macros and decorators
* - **With name**: Returns detailed info for the specific macro or decorator
*
* ## Manifest Contents
*
* - **Macros**: @derive decorators like Debug, Serialize, Clone
* - **Decorators**: Field decorators like @serde.skip, @serde.rename
*
* @param args - Tool arguments
* @param args.name - Optional macro or decorator name to look up
* @returns MCP response with formatted macro/decorator documentation
*/
async function handleGetMacroInfo(args) {
try {
const macroforge = await importMacroforge();
if (!macroforge || !macroforge.__macroforgeGetManifest) {
return {
content: [
{
type: 'text',
text: 'Native Macroforge bindings not available. Install macroforge to access macro documentation.'
}
]
};
}
const manifest = macroforge.__macroforgeGetManifest();
if (args.name) {
// Look up specific macro or decorator
const nameLower = args.name.toLowerCase();
const macro = manifest.macros.find((m) => m.name.toLowerCase() === nameLower);
const decorator = manifest.decorators.find((d) => d.export.toLowerCase() === nameLower);
if (!macro && !decorator) {
return {
content: [
{
type: 'text',
text: `No macro or decorator found with name "${args.name}".
Available macros: ${manifest.macros.map((m) => m.name).join(', ')}
Available decorators: ${manifest.decorators.map((d) => d.export).join(', ')}`
}
]
};
}
let result = '';
if (macro) {
result += `## Macro: @derive(${macro.name})\n\n`;
result += `**Description:** ${macro.description || 'No description available'}\n`;
result += `**Kind:** ${macro.kind}\n`;
result += `**Package:** ${macro.package}\n`;
}
if (decorator) {
if (result)
result += '\n---\n\n';
result += `## Decorator: @${decorator.export}\n\n`;
result += `**Documentation:** ${decorator.docs || 'No documentation available'}\n`;
result += `**Kind:** ${decorator.kind}\n`;
result += `**Module:** ${decorator.module}\n`;
}
return {
content: [{ type: 'text', text: result }]
};
}
// Return full manifest
let result = '# Macroforge Macro Manifest\n\n';
result += '## Available Macros\n\n';
for (const macro of manifest.macros) {
result += `### @derive(${macro.name})\n`;
result += `${macro.description || 'No description'}\n\n`;
}
if (manifest.decorators.length > 0) {
result += '## Available Field Decorators\n\n';
for (const decorator of manifest.decorators) {
result += `### @${decorator.export}\n`;
result += `${decorator.docs || 'No documentation'}\n\n`;
}
}
return {
content: [{ type: 'text', text: result }]
};
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: 'text',
text: `Error getting macro info: ${message}`
}
]
};
}
}
// ============================================================================
// Helper functions
// ============================================================================
/**
* Dynamically imports the native Macroforge bindings.
*
* The `macroforge` package is an optional peer dependency that provides
* native Rust-based code analysis and expansion. This function attempts to
* load it at runtime and gracefully returns null if unavailable.
*
* Using dynamic import allows the MCP server to run and serve documentation
* even when the native bindings are not installed.
*
* @returns The Macroforge module if available, or null if not installed
*
* @example
* ```typescript
* const macroforge = await importMacroforge();
* if (macroforge) {
* const result = macroforge.expandSync(code, 'input.ts', {});
* }
* ```
*/
async function importMacroforge() {
try {
// Dynamic import so a missing optional dependency fails at runtime, not startup
const mod = await import('@macroforge/core');
return mod;
}
catch {
// Package not installed - return null to indicate unavailability
return null;
}
}
/**
* Normalizes a diagnostic level string to lowercase.
*
* The engine already emits lowercase levels ('error', 'warning', 'info'),
* so this is purely defensive normalization in case a future or third-party
* binding returns differently cased values.
*
* @param level - Diagnostic level from the native bindings (e.g., 'error')
* @returns Lowercase level string (e.g., 'error')
*/
function normalizeLevel(level) {
return level.toLowerCase();
}