Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,12 @@ Alternatively, create an `cclsp.json` configuration file manually:
"extensions": ["js", "ts", "jsx", "tsx"],
"command": ["npx", "--", "typescript-language-server", "--stdio"],
"rootDir": "."
},
{
"extensions": ["kt", "kts", "java"],
"command": ["kotlin-lsp", "--stdio"],
"rootDir": ".",
"definition_mode": "position"
}
]
}
Expand All @@ -297,6 +303,9 @@ Alternatively, create an `cclsp.json` configuration file manually:
- `rootDir`: Working directory for the LSP server (optional, defaults to ".")
- `restartInterval`: Auto-restart interval in minutes (optional)
- `initializationOptions`: LSP server initialization options (optional)
- `definition_mode`: Strategy for `find_definition` (optional, defaults to `"symbol"`)
- `"symbol"`: find by `symbol_name` and optional `symbol_kind`, then resolve definition
- `"position"`: resolve directly from `line` and `character`

The `initializationOptions` field allows you to customize how each LSP server initializes. This is particularly useful for servers like `pylsp` (Python) that have extensive plugin configurations, or servers like `devsense-php-ls` that require specific settings.

Expand Down Expand Up @@ -380,13 +389,21 @@ The server exposes these MCP tools:

### `find_definition`

Find the definition of a symbol by name and kind in a file. Returns definitions for all matching symbols.
Find the definition of a symbol.

The required parameters depend on the server's `definition_mode`:
- `symbol` mode:
Uses `symbol_name` and optional `symbol_kind`. This is the default and is useful when an LLM knows the symbol name but may not know the exact cursor position.
- `position` mode:
Uses `line` and `character`. This is useful for languages like Kotlin/Java where true go-to-definition should start from a usage site.

**Parameters:**

- `file_path`: The path to the file
- `symbol_name`: The name of the symbol
- `symbol_kind`: The kind of symbol (function, class, variable, method, etc.) (optional)
- `line`: The line number (1-indexed), required in `position` mode
- `character`: The character position in the line (1-indexed), required in `position` mode
- `symbol_name`: The name of the symbol, required in `symbol` mode
- `symbol_kind`: The kind of symbol (function, class, variable, method, etc.) (optional, `symbol` mode)

### `find_references`

Expand Down Expand Up @@ -446,7 +463,7 @@ Manually restart LSP servers. Can restart servers for specific file extensions o

### Finding Function Definitions

When Claude needs to understand how a function works:
When Claude needs to understand how a function works in `symbol` mode:

```
Claude: Let me find the definition of the `processRequest` function
Expand All @@ -455,6 +472,24 @@ Claude: Let me find the definition of the `processRequest` function
Result: Found definition at src/handlers/request.ts:127:1
```

For Kotlin/Java projects configured with `"definition_mode": "position"`:

```json
{
"extensions": ["kt", "kts", "java"],
"command": ["kotlin-lsp", "--stdio"],
"rootDir": ".",
"definition_mode": "position"
}
```

```
Claude: Let me jump to the definition from this usage site
> Using cclsp.find_definition with file_path="src/main/kotlin/App.kt", line=146, character=52

Result: Found definition at src/main/kotlin/McpServerConfiguration.kt:17:5
```

### Finding All References

When refactoring or understanding code impact:
Expand Down
34 changes: 34 additions & 0 deletions src/lsp-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1209,6 +1209,40 @@ describe('LSPClient', () => {

expect(result).toEqual([]);
});

it('should deduplicate identical workspace symbols', async () => {
const client = new LSPClient(TEST_CONFIG_PATH);

const duplicateSymbol = {
name: 'testFunction',
kind: 12,
location: {
uri: pathToUri(MOCK_TEST_TS),
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 20 },
},
},
};

const mockTransport = createMockTransport({
sendRequest: jest.fn().mockResolvedValue([duplicateSymbol, duplicateSymbol]),
});

const mockServerState = {
initializationPromise: Promise.resolve(),
process: { stdin: { write: jest.fn() } },
transport: mockTransport,
initialized: true,
adapter: undefined,
};

(client as any).serverManager.getRunningServers().set('test-key', mockServerState);

const result = await client.workspaceSymbol('test');

expect(result).toEqual([duplicateSymbol]);
});
});

describe('findImplementation', () => {
Expand Down
5 changes: 5 additions & 0 deletions src/lsp-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
CallHierarchyItem,
CallHierarchyOutgoingCall,
Config,
DefinitionMode,
Diagnostic,
LSPServerConfig,
Location,
Expand All @@ -43,6 +44,10 @@ export class LSPClient {
this.config = loadConfig(configPath);
}

getDefinitionMode(filePath: string): DefinitionMode {
return this.getServerForFile(filePath)?.definition_mode ?? 'symbol';
}

private getServerForFile(filePath: string): LSPServerConfig | null {
const extension = filePath.split('.').pop();
if (!extension) return null;
Expand Down
15 changes: 14 additions & 1 deletion src/lsp/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -708,7 +708,20 @@ export async function workspaceSymbol(
const result = await serverState.transport.sendRequest(method, { query }, timeout);

if (Array.isArray(result)) {
return result as SymbolInformation[];
const symbols = result as SymbolInformation[];
const seen = new Set<string>();

return symbols.filter((symbol) => {
const start = symbol.location.range.start;
const key = `${symbol.name}|${symbol.kind}|${symbol.location.uri}|${start.line}|${start.character}`;

if (seen.has(key)) {
return false;
}

seen.add(key);
return true;
});
}

return [];
Expand Down
1 change: 1 addition & 0 deletions src/lsp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type {
CallHierarchyOutgoingCall,
CodeDescription,
Config,
DefinitionMode,
DefinitionResult,
Diagnostic,
DiagnosticRelatedInformation,
Expand Down
73 changes: 73 additions & 0 deletions src/mcp-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const SRC_TEST = join(tmpdir(), 'src', 'test.ts');
const SRC_OTHER = join(tmpdir(), 'src', 'other.ts');

type MockLSPClient = {
getDefinitionMode: ReturnType<typeof jest.fn>;
findSymbolsByName: ReturnType<typeof jest.fn>;
findDefinition: ReturnType<typeof jest.fn>;
findReferences: ReturnType<typeof jest.fn>;
Expand All @@ -23,6 +24,7 @@ type MockLSPClient = {

function createMockClient(): MockLSPClient {
return {
getDefinitionMode: jest.fn(() => 'symbol'),
findSymbolsByName: jest.fn(),
findDefinition: jest.fn(),
findReferences: jest.fn(),
Expand Down Expand Up @@ -52,6 +54,66 @@ describe('MCP Tool Handlers', () => {
});

describe('find_definition', () => {
it('should find definition by position in position mode', async () => {
mockClient.getDefinitionMode.mockReturnValue('position');
mockClient.findDefinition.mockResolvedValue([
{
uri: pathToUri(SRC_IMPL),
range: {
start: { line: 10, character: 5 },
end: { line: 10, character: 17 },
},
},
]);

const result = await findDefinitionTool.handler(
{ file_path: 'test.ts', line: 5, character: 10 },
asClient(mockClient)
);

expect(result.content[0]?.text).toContain('Results for definition lookup at test.ts:5:10');
expect(result.content[0]?.text).toContain(`${uriToPath(pathToUri(SRC_IMPL))}:11:6`);
expect(mockClient.findDefinition).toHaveBeenCalledWith(resolve('test.ts'), {
line: 4,
character: 9,
});
expect(mockClient.findSymbolsByName).not.toHaveBeenCalled();
});

it('should render non-file definition URIs without throwing', async () => {
mockClient.getDefinitionMode.mockReturnValue('position');
mockClient.findDefinition.mockResolvedValue([
{
uri: 'jrt:/java.base/java/lang/String.class',
range: {
start: { line: 12, character: 3 },
end: { line: 12, character: 9 },
},
},
]);

const result = await findDefinitionTool.handler(
{ file_path: 'test.ts', line: 5, character: 10 },
asClient(mockClient)
);

expect(result.content[0]?.text).toContain('Results for definition lookup at test.ts:5:10');
expect(result.content[0]?.text).toContain('jrt:/java.base/java/lang/String.class:13:4');
});

it('should require coordinates in position mode', async () => {
mockClient.getDefinitionMode.mockReturnValue('position');

const result = await findDefinitionTool.handler(
{ file_path: 'test.ts' },
asClient(mockClient)
);

expect(result.content[0]?.text).toContain(
'find_definition in position mode requires "line" and "character".'
);
});

it('should find definition via symbol name lookup', async () => {
mockClient.findSymbolsByName.mockResolvedValue({
matches: [
Expand Down Expand Up @@ -213,6 +275,17 @@ describe('MCP Tool Handlers', () => {

expect(result.content[0]?.text).toContain('no definitions could be retrieved');
});

it('should require symbol_name in symbol mode', async () => {
const result = await findDefinitionTool.handler(
{ file_path: 'test.ts' },
asClient(mockClient)
);

expect(result.content[0]?.text).toContain(
'find_definition in symbol mode requires "symbol_name".'
);
});
});

describe('find_references', () => {
Expand Down
10 changes: 9 additions & 1 deletion src/tools/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,21 @@ export function resolvePath(filePath: string): string {
export function formatLocations(locations: Location[]): string {
return locations
.map((loc) => {
const filePath = uriToPath(loc.uri);
const filePath = formatLocationUri(loc.uri);
const { start } = loc.range;
return `${filePath}:${start.line + 1}:${start.character + 1}`;
})
.join('\n');
}

export function formatLocationUri(uri: string): string {
try {
return uriToPath(uri);
} catch {
return uri;
}
}

export function textResult(text: string): ToolResult {
return {
content: [{ type: 'text', text }],
Expand Down
56 changes: 52 additions & 4 deletions src/tools/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,22 @@ import type { ToolDefinition } from './registry.js';
export const findDefinitionTool: ToolDefinition = {
name: 'find_definition',
description:
'Find the definition of a symbol by name and kind in a file. Returns definitions for all matching symbols.',
'Find the definition of a symbol. In position mode it uses line and character; in symbol mode it uses symbol_name and symbol_kind.',
inputSchema: {
type: 'object',
properties: {
file_path: {
type: 'string',
description: 'The path to the file',
},
line: {
type: 'number',
description: 'The line number (1-indexed)',
},
character: {
type: 'number',
description: 'The character position in the line (1-indexed)',
},
symbol_name: {
type: 'string',
description: 'The name of the symbol',
Expand All @@ -23,15 +31,55 @@ export const findDefinitionTool: ToolDefinition = {
description: 'The kind of symbol (function, class, variable, method, etc.)',
},
},
required: ['file_path', 'symbol_name'],
required: ['file_path'],
},
handler: async (args, client) => {
const { file_path, symbol_name, symbol_kind } = args as {
const { file_path, line, character, symbol_name, symbol_kind } = args as {
file_path: string;
symbol_name: string;
line?: number;
character?: number;
symbol_name?: string;
symbol_kind?: string;
};
const absolutePath = resolvePath(file_path);
const definitionMode = client.getDefinitionMode(absolutePath);

if (definitionMode === 'position') {
if (typeof line !== 'number' || typeof character !== 'number') {
return textResult(
'find_definition in position mode requires "line" and "character".'
);
}

try {
const locations = await client.findDefinition(absolutePath, {
line: line - 1,
character: character - 1,
});
logger.debug(`[find_definition] findDefinition returned ${locations.length} locations\n`);

if (locations.length === 0) {
return textResult(
`No definition found at ${file_path}:${line}:${character}. Please verify the position and ensure the language server is properly configured.`
);
}

return textResult(
`Results for definition lookup at ${file_path}:${line}:${character}:\n${formatLocations(locations)}`
);
} catch (error) {
logger.error(`[find_definition] Error finding definition: ${error}\n`);
return textResult(
`Error finding definition: ${error instanceof Error ? error.message : String(error)}`
);
}
}

if (typeof symbol_name !== 'string' || symbol_name.length === 0) {
return textResult(
'find_definition in symbol mode requires "symbol_name".'
);
}

const result = await client.findSymbolsByName(absolutePath, symbol_name, symbol_kind);
const { matches: symbolMatches, warning } = result;
Expand Down
3 changes: 3 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
export type DefinitionMode = 'position' | 'symbol';

export interface LSPServerConfig {
extensions: string[];
command: string[];
rootDir?: string;
restartInterval?: number; // in minutes, optional auto-restart interval
initializationOptions?: unknown; // LSP initialization options
definition_mode?: DefinitionMode;
}

export interface Config {
Expand Down