import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { readFile } from 'node:fs/promises';
import { runGxi, escapeSchemeString, ERROR_MARKER } from '../gxi.js';
const RESULT_MARKER = 'GERBIL-MCP-FIND:';
export function registerFindDefinitionTool(server: McpServer): void {
server.registerTool(
'gerbil_find_definition',
{
title: 'Find Symbol Definition',
description:
'Find where a Gerbil symbol is defined. Returns the qualified name, ' +
'module file path, source file path (if available), kind (procedure/macro/value), ' +
'and arity for procedures. Uses runtime introspection and module resolution.',
inputSchema: {
symbol: z
.string()
.describe(
'Symbol name to look up (e.g. "read-json", "map", "defstruct")',
),
module_path: z
.string()
.optional()
.describe(
'Module to import for context (e.g. ":std/text/json"). If omitted, searches current environment.',
),
source_preview: z
.boolean()
.optional()
.describe(
'If true, include a source code preview of the definition (requires source file to be available)',
),
preview_lines: z
.number()
.optional()
.describe(
'Maximum number of source lines to show in preview (default: 30)',
),
},
},
async ({ symbol, module_path, source_preview, preview_lines }) => {
const escapedSym = escapeSchemeString(symbol);
const exprs: string[] = ['(import :gerbil/expander)'];
if (module_path) {
const modPath = module_path.startsWith(':')
? module_path
: `:${module_path}`;
exprs.push(`(import ${modPath})`);
}
exprs.push(buildFindExpr(escapedSym, module_path));
const result = await runGxi(exprs);
if (result.timedOut) {
return {
content: [
{
type: 'text' as const,
text: 'Definition lookup timed out.',
},
],
isError: true,
};
}
if (result.exitCode !== 0 && result.stderr) {
return {
content: [
{
type: 'text' as const,
text: `Error looking up ${symbol}:\n${result.stderr.trim()}`,
},
],
isError: true,
};
}
const stdout = result.stdout;
const errorIdx = stdout.indexOf(ERROR_MARKER);
if (errorIdx !== -1) {
const errorMsg = stdout.slice(errorIdx + ERROR_MARKER.length).trim();
return {
content: [
{
type: 'text' as const,
text: `Error looking up ${symbol}:\n${errorMsg}`,
},
],
isError: true,
};
}
// Parse result lines
const lines = stdout
.split('\n')
.filter((l) => l.startsWith(RESULT_MARKER));
const info: Record<string, string> = {};
for (const line of lines) {
const payload = line.slice(RESULT_MARKER.length);
const tabIdx = payload.indexOf('\t');
if (tabIdx !== -1) {
info[payload.slice(0, tabIdx)] = payload.slice(tabIdx + 1).trim();
}
}
if (Object.keys(info).length === 0) {
return {
content: [
{
type: 'text' as const,
text: `Symbol "${symbol}" not found.`,
},
],
isError: true,
};
}
// Build formatted output
const sections: string[] = [`Symbol: ${symbol}`, ''];
if (info['kind']) {
sections.push(`Kind: ${info['kind']}`);
}
if (info['qualified']) {
sections.push(`Qualified name: ${info['qualified']}`);
}
if (info['arity']) {
sections.push(`Arity: ${info['arity']}`);
}
if (info['type']) {
sections.push(`Type: ${info['type']}`);
}
if (module_path) {
sections.push(
`Module: ${module_path.startsWith(':') ? module_path : `:${module_path}`}`,
);
}
if (info['ssi-path']) {
sections.push(`Module file: ${info['ssi-path']}`);
}
if (info['source-path']) {
sections.push(`Source file: ${info['source-path']}`);
} else if (info['ssi-path']) {
sections.push('Source file: (not available — compiled module)');
}
// Source preview
if (source_preview && info['source-path']) {
const maxLines = preview_lines ?? 30;
try {
const sourceContent = await readFile(
info['source-path'],
'utf-8',
);
const sourceLines = sourceContent.split('\n');
const defLineIdx = findDefinitionLine(sourceLines, symbol);
if (defLineIdx >= 0) {
const preview = extractFormPreview(
sourceLines,
defLineIdx,
maxLines,
);
sections.push('');
sections.push(
`Source preview (${info['source-path']}:${defLineIdx + 1}):`,
);
sections.push('```scheme');
sections.push(preview);
sections.push('```');
}
} catch {
// Silently skip if file is not readable
}
}
return {
content: [{ type: 'text' as const, text: sections.join('\n') }],
};
},
);
}
function buildFindExpr(escapedSym: string, modulePath?: string): string {
const modResolution = modulePath
? buildModuleResolution(modulePath)
: '(void)';
return [
'(with-catch',
' (lambda (e)',
` (display "${ERROR_MARKER}\\n")`,
' (display-exception e (current-output-port)))',
' (lambda ()',
` (let ((sym (string->symbol "${escapedSym}")))`,
' (with-catch',
' (lambda (ex)',
// eval failed → likely macro/syntax
` (display "${RESULT_MARKER}kind\\tmacro/syntax\\n")`,
` (display "${RESULT_MARKER}name\\t${escapedSym}\\n")`,
` ${modResolution})`,
' (lambda ()',
' (let ((val (eval sym)))',
' (cond',
' ((procedure? val)',
` (display "${RESULT_MARKER}kind\\tprocedure\\n")`,
' (let* ((pname (##procedure-name val))',
' (pname-str (symbol->string pname)))',
` (display "${RESULT_MARKER}qualified\\t")`,
' (display pname-str)',
' (newline)',
` (display "${RESULT_MARKER}arity\\t")`,
' (display (##subprocedure-nb-parameters val))',
' (newline)',
' (let ((hash-idx (string-index pname-str #\\#)))',
' (if hash-idx',
' (let* ((mod-part (substring pname-str 0 hash-idx))',
' (mod-sym (string->symbol (string-append ":" mod-part))))',
' (with-catch',
' (lambda (e2) (void))',
' (lambda ()',
' (let ((resolved (core-resolve-library-module-path mod-sym)))',
` (display "${RESULT_MARKER}ssi-path\\t")`,
' (display resolved)',
' (newline)',
' (let ((ss-path (string-append (path-strip-extension resolved) ".ss")))',
' (when (file-exists? ss-path)',
` (display "${RESULT_MARKER}source-path\\t")`,
' (display ss-path)',
' (newline)))))))',
` ${modResolution}))))`,
' (else',
` (display "${RESULT_MARKER}kind\\tvalue\\n")`,
` (display "${RESULT_MARKER}name\\t${escapedSym}\\n")`,
` (display "${RESULT_MARKER}type\\t")`,
' (display (type-of val))',
' (newline)',
` ${modResolution}))))))))`,
].join(' ');
}
const DEF_KEYWORDS = [
'def ', 'def* ', 'def/c ', 'define ', 'defstruct ', 'defclass ',
'definterface ', 'defrules ', 'defrule ', 'defsyntax ', 'defmacro ',
'defmethod ', 'defconst ', 'definline ', 'defvalues ', 'defalias ',
'deftype ',
];
/**
* Find the line index (0-based) where a symbol is defined.
* Searches for `(def<keyword> symbol` or `(def<keyword> (symbol ...`.
*/
function findDefinitionLine(
lines: string[],
symbol: string,
): number {
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trimStart();
if (!line.startsWith('(')) continue;
const rest = line.slice(1);
for (const kw of DEF_KEYWORDS) {
if (rest.startsWith(kw)) {
const after = rest.slice(kw.length);
// Match "symbol" or "(symbol ..."
if (
after.startsWith(symbol) ||
after.startsWith(`(${symbol}`) ||
after.startsWith(`{${symbol}`)
) {
// Verify word boundary
const nextChar = after[symbol.length] ?? '';
const parenNextChar =
after[symbol.length + 1] ?? '';
if (
after.startsWith(symbol) &&
(nextChar === ' ' ||
nextChar === ')' ||
nextChar === '\n' ||
nextChar === '')
) {
return i;
}
if (
after.startsWith(`(${symbol}`) &&
(parenNextChar === ' ' ||
parenNextChar === ')' ||
parenNextChar === '')
) {
return i;
}
if (
after.startsWith(`{${symbol}`) &&
(parenNextChar === ' ' ||
parenNextChar === '}' ||
parenNextChar === '')
) {
return i;
}
}
}
}
}
return -1;
}
/**
* Extract a form preview starting at startIdx, tracking paren depth.
* Returns up to maxLines lines or until the form closes.
*/
function extractFormPreview(
lines: string[],
startIdx: number,
maxLines: number,
): string {
let depth = 0;
let started = false;
const result: string[] = [];
for (let i = startIdx; i < lines.length && result.length < maxLines; i++) {
const line = lines[i];
result.push(line);
for (let j = 0; j < line.length; j++) {
const ch = line[j];
if (ch === ';') break;
if (ch === '"') {
j++;
while (j < line.length && line[j] !== '"') {
if (line[j] === '\\') j++;
j++;
}
continue;
}
if (ch === '(' || ch === '[') {
depth++;
started = true;
} else if (ch === ')' || ch === ']') {
depth--;
if (started && depth <= 0) {
return result.join('\n');
}
}
}
}
if (result.length >= maxLines) {
result.push('...');
}
return result.join('\n');
}
function buildModuleResolution(modulePath: string): string {
const modPath = modulePath.startsWith(':')
? modulePath
: `:${modulePath}`;
return [
'(with-catch (lambda (e3) (void))',
' (lambda ()',
` (let ((resolved (core-resolve-library-module-path (quote ${modPath}))))`,
` (display "${RESULT_MARKER}ssi-path\\t")`,
' (display resolved)',
' (newline)',
' (let ((ss-path (string-append (path-strip-extension resolved) ".ss")))',
' (when (file-exists? ss-path)',
` (display "${RESULT_MARKER}source-path\\t")`,
' (display ss-path)',
' (newline))))))',
].join(' ');
}