Julia Documentation MCP Server
by jonathanfischer97
Verified
- src
#!/usr/bin/env node
// Add immediate logging before any imports or class definitions
console.error('Process environment at startup:', {
argv: process.argv,
env: process.env,
cwd: process.cwd()
});
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { exec } from "child_process";
import { promisify } from "util";
import { readFileSync } from 'fs';
import { join } from 'path';
import { existsSync } from 'fs';
import { homedir } from 'os';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
// Get the equivalent of __dirname for ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Convert exec to use promises instead of callbacks
const execAsync = promisify(exec);
// Read version from package.json
const packageJson = JSON.parse(
readFileSync(join(__dirname, '../package.json'), 'utf8')
);
// Cache implementation to store results and reduce Julia process spawns
class Cache<T> {
private cache = new Map<string, { value: T; timestamp: number }>();
private ttl: number;
constructor(ttlSeconds: number = 300) { // Default 5 minute TTL
this.ttl = ttlSeconds * 1000; // Convert to milliseconds
}
get(key: string): T | undefined {
const item = this.cache.get(key);
if (!item) return undefined;
// Check if cache entry has expired
if (Date.now() - item.timestamp > this.ttl) {
this.cache.delete(key);
return undefined;
}
return item.value;
}
set(key: string, value: T): void {
this.cache.set(key, { value, timestamp: Date.now() });
}
}
class JuliaDocServer {
private server: McpServer;
private cache: Cache<string>;
private juliaPath: string;
private projectPath: string | null;
constructor() {
this.server = new McpServer({
name: "juliadoc",
version: packageJson.version,
});
this.cache = new Cache<string>(300);
this.juliaPath = this.findJuliaPath();
// Log all environment variables for debugging
console.error('Environment variables:', {
JULIA_PROJECT: process.env.JULIA_PROJECT,
JULIA_DEPOT_PATH: process.env.JULIA_DEPOT_PATH,
JULIA_LOAD_PATH: process.env.JULIA_LOAD_PATH,
PATH: process.env.PATH
});
// Store project path from environment
this.projectPath = process.env.JULIA_PROJECT || null;
console.error('Using Julia project path:', this.projectPath);
this.verifyJuliaInstallation();
this.setupTools();
}
private findJuliaPath(): string {
// First check if explicitly set via env var
if (process.env.JULIA_PATH) {
return process.env.JULIA_PATH;
}
const home = homedir();
const commonPaths = [
// Juliaup installation (most common)
`${home}/.juliaup/bin/julia`,
// Homebrew installation
'/opt/homebrew/bin/julia',
// Manual installation
'/Applications/Julia-1.9.app/Contents/Resources/julia/bin/julia',
// System-wide installation
'/usr/local/bin/julia'
];
// Check common installation paths
for (const path of commonPaths) {
if (existsSync(path)) {
console.error(`Found Julia at: ${path}`);
return path;
}
}
// Fall back to PATH-based julia
console.error('Falling back to Julia from system PATH');
return 'julia';
}
private async verifyJuliaInstallation(): Promise<void> {
try {
const { stdout } = await execAsync(`${this.juliaPath} --version`);
console.error(`Found Julia: ${stdout.trim()}`);
} catch (error) {
console.error('Failed to find Julia installation:', error);
throw new Error(
'Julia not found. Please ensure Julia is installed and either:\n' +
'1. Add Julia to your PATH, or\n' +
'2. Set JULIA_PATH environment variable to point to your Julia executable'
);
}
}
private async runJuliaCommand(code: string, packageName?: string): Promise<string> {
try {
let fullCode = '';
// Add package loading if specified
if (packageName) {
fullCode += `using ${packageName}; `;
}
// Add the main code
fullCode += code;
// Properly escape the code for the -e flag
// Replace single quotes with '\'' for shell escaping
const escapedCode = fullCode.replace(/'/g, "'\\''");
// Properly quote the project path if specified
const projectFlag = this.projectPath ?
`--project='${this.projectPath.replace(/'/g, "'\\''")}'` :
'';
// Construct the full command
const command = `${this.juliaPath} ${projectFlag} -e '${escapedCode}'`;
console.error(`Executing command: ${command}`);
// Execute with explicit environment
const { stdout, stderr } = await execAsync(command, {
env: {
...process.env,
// Ensure JULIA_PROJECT is set in the environment
JULIA_PROJECT: this.projectPath || '',
// Preserve other important Julia env vars
JULIA_DEPOT_PATH: process.env.JULIA_DEPOT_PATH,
JULIA_LOAD_PATH: process.env.JULIA_LOAD_PATH,
PATH: process.env.PATH,
}
});
console.error('Command output:', stdout, stderr);
if (stderr) {
// Run diagnostics when we hit an error to help debug
const diagnosticCommand = `${this.juliaPath} ${projectFlag} -e 'println("Active project: ", Base.active_project()); println("DEPOT_PATH: ", DEPOT_PATH); using Pkg; println("Installed packages:"); Pkg.status()'`;
console.error('Running diagnostics:', diagnosticCommand);
const { stdout: diagnosticOutput } = await execAsync(diagnosticCommand);
if (stderr.includes("Package") && stderr.includes("not found in current path")) {
throw new Error(
`Package ${packageName} not found in the current project environment.\n` +
`Environment Info:\n${diagnosticOutput}\n` +
`If using a custom project (JULIA_PROJECT), make sure to add the package first:\n` +
`julia> using Pkg; Pkg.add("${packageName}")`
);
} else if (stderr.includes("could not load package")) {
throw new Error(`Package not found: ${stderr}\n${diagnosticOutput}`);
} else if (stderr.includes("not found")) {
throw new Error(`Symbol not found: ${stderr}\n${diagnosticOutput}`);
} else if (stderr.includes("UndefVarError")) {
throw new Error(`Package not loaded. Try installing the package first.\n${diagnosticOutput}`);
}
throw new Error(`${stderr}\n${diagnosticOutput}`);
}
if (!stdout.trim()) {
throw new Error("No documentation found");
}
return stdout.trim();
} catch (error) {
console.error(`Julia command error:`, error);
if (error instanceof Error) {
if (error.message.includes("ENOENT")) {
throw new Error("Julia executable not found. Please ensure Julia is installed and in your PATH.");
}
throw new Error(`Julia error: ${error.message}`);
}
throw error;
}
}
private setupTools(): void {
// Tool 1: Get documentation with flexible detail levels
this.server.tool(
"get-doc",
"Get Julia documentation for a package, module, type, function, or method",
{
path: z.string().describe("Path to Julia object (e.g., 'Base.sort', 'StatsBase.transform')"),
detail_level: z.enum(["concise", "full", "all"]).optional()
.describe("Level of documentation detail: concise (just signatures), full (standard docs), or all (including internals)"),
include_unexported: z.boolean().optional()
.describe("Whether to include unexported symbols")
},
async ({ path, detail_level = "full", include_unexported = false }) => {
try {
// Extract package name if it's a qualified path
const packageMatch = path.match(/^([A-Za-z][A-Za-z0-9_]*)\./);
const packageName = packageMatch ? packageMatch[1] : null;
// Skip package loading for Base
if (packageName && packageName !== 'Base') {
// Build appropriate Julia command based on detail level
let command = "";
switch (detail_level) {
case "concise":
command = `
using InteractiveUtils
m = @which ${path}
println("Type signature: ", m.sig)
`;
break;
case "all":
command = `
using InteractiveUtils
# Get main documentation
println(@doc ${path})
println("\\n", "-"^40, "\\n")
# Get all methods if it's a function
ms = methods(${path})
if !isempty(ms)
println("Method signatures:")
for m in ms
println(" - ", m.sig)
end
end
# Show internal fields if it's a type
if isa(${path}, Type)
println("\\nFields:")
for field in fieldnames(${path})
println(" - ", field, "::", fieldtype(${path}, field))
end
end
`;
break;
default: // "full"
command = `println(@doc ${path})`;
}
// Override command if including unexported symbols
if (include_unexported) {
command = `
using InteractiveUtils
names(${path}, all=true) |>
filter(n -> !startswith(string(n), "#")) |>
sort |>
foreach(n -> println("\\n", "-"^40, "\\n", @doc getfield(${path}, n)))
`;
}
return {
content: [{
type: "text",
text: await this.runJuliaCommand(command, packageName)
}]
};
} else {
// Same command building logic for Base/non-package objects
let command = "";
switch (detail_level) {
case "concise":
command = `
using InteractiveUtils
m = @which ${path}
println("Type signature: ", m.sig)
`;
break;
case "all":
command = `
using InteractiveUtils
# Get main documentation
println(@doc ${path})
println("\\n", "-"^40, "\\n")
# Get all methods if it's a function
ms = methods(${path})
if !isempty(ms)
println("Method signatures:")
for m in ms
println(" - ", m.sig)
end
end
# Show internal fields if it's a type
if isa(${path}, Type)
println("\\nFields:")
for field in fieldnames(${path})
println(" - ", field, "::", fieldtype(${path}, field))
end
end
`;
break;
default: // "full"
command = `println(@doc ${path})`;
}
return {
content: [{
type: "text",
text: await this.runJuliaCommand(command)
}]
};
}
} catch (error) {
return {
content: [{
type: "text",
text: error instanceof Error ? error.message : String(error)
}]
};
}
}
);
// Tool 2: List package contents
this.server.tool(
"list-package",
"List available symbols in a Julia package or module",
{
path: z.string().describe("Package or module name"),
include_unexported: z.boolean().optional()
.describe("Whether to include unexported symbols")
},
async ({ path, include_unexported = false }) => {
try {
const command = `
using ${path} # Load the package first
using InteractiveUtils
module_obj = ${path}
names(module_obj, all=${include_unexported}) |>
filter(n -> !startswith(string(n), "#")) |>
sort |>
map(n -> (n, string(typeof(getfield(module_obj, n))))) |>
foreach(t -> println(t[2], " ", t[1]))
`;
const result = await this.runJuliaCommand(command);
return {
content: [{ type: "text", text: result }],
};
} catch (error) {
console.error(`Error listing package contents for ${path}:`, error);
return {
content: [
{
type: "text",
text: error instanceof Error ? error.message : String(error),
},
],
};
}
}
);
// Tool 3: Explore project structure
this.server.tool(
"explore-project",
"Explore a Julia project's structure and dependencies",
{
path: z.string().describe("Path to Julia project")
},
async ({ path }) => {
try {
// Read and display Project.toml contents
const command = `
using Pkg
project = Pkg.Types.read_project(joinpath("${path}", "Project.toml"))
println("Project: ", project.name, " v", project.version)
println("\\nDependencies:")
for (dep, ver) in project.dependencies
println(" - ", dep, " = ", ver)
end
`;
const result = await this.runJuliaCommand(command);
return {
content: [{ type: "text", text: result }],
};
} catch (error) {
console.error(`Error exploring project at ${path}:`, error);
return {
content: [
{
type: "text",
text: error instanceof Error ? error.message : String(error),
},
],
};
}
},
);
// Tool 4: Get source code with context
this.server.tool(
"get-source",
"Get Julia source code for a function, type, or method",
{
path: z.string().describe("Path to Julia object (e.g., 'Base.sort', 'StatsBase.transform')"),
},
async ({ path }) => {
console.error(`Received get-source request for path: ${path}`);
const cacheKey = `source:${path}`;
const cached = this.cache.get(cacheKey);
if (cached) {
console.error(`Returning cached source for ${path}`);
return {
content: [{ type: "text", text: cached }],
};
}
try {
// Extract package name if it's a qualified path
const packageMatch = path.match(/^([A-Za-z][A-Za-z0-9_]*)\./);
const packageName = packageMatch ? packageMatch[1] : null;
// Build the command, loading package if needed
const command = `
${packageName && packageName !== 'Base' ? `using ${packageName}` : ''}
using InteractiveUtils
# Helper function to find where a method definition ends
function find_method_end(lines, start_line)
# Get base indentation of the method definition
base_indent = length(match(r"^\\s*", lines[start_line]).match)
nesting_level = 0
# Look forward for the matching end
for i in start_line:length(lines)
line = lines[i]
if !isempty(strip(line))
# Count nested blocks by looking for increased indentation followed by certain keywords
if match(r"^\\s*(function|if|for|while|let|try|begin|module|struct|macro)\\b", line) !== nothing
nesting_level += 1
end
# Check for end keywords
if endswith(strip(line), "end")
nesting_level -= 1
# If we're back to the original nesting level, this is our end
if nesting_level == 0
return i
end
end
end
end
# If we didn't find the end, return the last line
return length(lines)
end
# Function to display method information with context
function show_method_info(m)
println("Type signature: ", m.sig)
println("-" ^ 40)
try
file, line = functionloc(m)
println("Source location: ", file, ":", line)
println("-" ^ 40)
# Read the source file
if isfile(file)
# Read the entire file content
content = read(file, String)
lines = split(content, "\\n")
# Show some context before the method
start_line = max(1, line - 5)
# Find the actual end of this method
end_line = find_method_end(lines, line)
# Add a few lines of context after
end_line = min(length(lines), end_line + 5)
# Print lines with line numbers
for i = start_line:end_line
linenum = string(i)
linetext = i <= length(lines) ? lines[i] : ""
if i == line
println("➜ ", linenum, ": ", linetext) # Highlight the definition line
else
println(" ", linenum, ": ", linetext)
end
end
else
println("Could not find source file")
end
catch e
println("Error retrieving source: ", e)
println(sprint(showerror, e, catch_backtrace()))
end
println()
end
# Get all methods and display their source
ms = methods(${path})
if isempty(ms)
println("No methods found for ${path}")
else
println("Found ", length(ms), " method(s):")
println()
for (i, m) in enumerate(ms)
println("Method ", i, ":")
show_method_info(m)
end
end
`;
const source = await this.runJuliaCommand(command);
this.cache.set(cacheKey, source);
return {
content: [{ type: "text", text: source }],
};
} catch (error) {
console.error(`Error getting source for ${path}:`, error);
return {
content: [
{
type: "text",
text: error instanceof Error ? error.message : String(error),
},
],
};
}
},
);
}
// Start the MCP server
async start(): Promise<void> {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error("Julia Documentation MCP Server running on stdio");
}
// Clean up resources when shutting down
cleanup(): void {
// Clear cache
this.cache = new Cache<string>(0);
// Ensure all pending Julia processes are terminated
process.exit(0);
}
}
// Initialize and start the server
async function main() {
const server = new JuliaDocServer();
// Handle shutdown signals
process.on("SIGINT", () => server.cleanup());
process.on("SIGTERM", () => server.cleanup());
try {
await server.start();
} catch (error) {
console.error("Fatal error:", error);
process.exit(1);
}
}
// Start the server and handle any startup errors
main().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
});