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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### New Features

- CodeGraph's MCP server now advertises a human-readable title and embedded icon metadata during `initialize` and `tools/list`, so MCP clients that render source or tool branding can show the CodeGraph name and icon instead of generic badges.

## [1.0.1] - 2026-06-13

Expand Down
33 changes: 32 additions & 1 deletion __tests__/mcp-daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,34 @@ function sendInitialize(child: ChildProcessWithoutNullStreams, rootUri: string,
});
}

function expectServerBranding(result: {
serverInfo: { name?: string; title?: string; icons?: Array<{ src?: string; mimeType?: string; sizes?: string[] }> };
_meta?: { icons?: unknown[] };
}) {
expect(result.serverInfo.name).toBe('codegraph');
expect(result.serverInfo.title).toBe('CodeGraph');
expect(result.serverInfo.icons).toHaveLength(1);
const icon = result.serverInfo.icons![0]!;
expect(icon.mimeType).toBe('image/svg+xml');
expect(icon.sizes).toEqual(['32x32']);
expect(icon.src).toMatch(/^data:image\/svg\+xml;base64,/);
const encoded = icon.src!.replace(/^data:image\/svg\+xml;base64,/, '');
expect(Buffer.from(encoded, 'base64').toString('utf8')).toMatch(/^<svg\b/);
expect(result._meta?.icons).toEqual(result.serverInfo.icons);
}

function expectToolBranding(tool: {
icons?: Array<{ src?: string; mimeType?: string; sizes?: string[] }>;
_meta?: { icons?: unknown[] };
}) {
expect(tool.icons).toHaveLength(1);
const icon = tool.icons![0]!;
expect(icon.mimeType).toBe('image/svg+xml');
expect(icon.sizes).toEqual(['32x32']);
expect(icon.src).toMatch(/^data:image\/svg\+xml;base64,/);
expect(tool._meta?.icons).toEqual(tool.icons);
}

/** Find a JSON-RPC response with the given id (result OR error) on stdout. */
function findResponse(stdout: string[], id: number): any | null {
for (const line of stdout) {
Expand Down Expand Up @@ -199,7 +227,7 @@ describe('Shared MCP daemon (issue #411)', () => {
servers.push(first);
sendInitialize(first.child, `file://${tempDir}`, 1);
const firstResp = await waitFor(() => findResponse(first.stdout, 1), 10000);
expect(firstResp.result.serverInfo.name).toBe('codegraph');
expectServerBranding(firstResp.result);

// The launcher is a PROXY (not the daemon itself) — that's the detach fix.
await waitFor(() => first.stderr.some((l) => l.includes('Attached to shared daemon')), 8000);
Expand Down Expand Up @@ -289,6 +317,9 @@ describe('Shared MCP daemon (issue #411)', () => {
const toolsResp = await waitFor(() => findResponse(second.stdout, 2), 10000);
expect(Array.isArray(toolsResp.result.tools)).toBe(true);
expect(toolsResp.result.tools.length).toBeGreaterThan(0);
for (const tool of toolsResp.result.tools) {
expectToolBranding(tool);
}
}, 45000);

it('CODEGRAPH_NO_DAEMON=1 keeps each process independent (no socket/pidfile)', async () => {
Expand Down
19 changes: 18 additions & 1 deletion __tests__/mcp-initialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,22 @@ function sendInitialize(child: ChildProcessWithoutNullStreams, projectPath: stri
child.stdin.write(msg + '\n');
}

function expectServerBranding(result: {
serverInfo: { name?: string; title?: string; icons?: Array<{ src?: string; mimeType?: string; sizes?: string[] }> };
_meta?: { icons?: unknown[] };
}) {
expect(result.serverInfo.name).toBe('codegraph');
expect(result.serverInfo.title).toBe('CodeGraph');
expect(result.serverInfo.icons).toHaveLength(1);
const icon = result.serverInfo.icons![0]!;
expect(icon.mimeType).toBe('image/svg+xml');
expect(icon.sizes).toEqual(['32x32']);
expect(icon.src).toMatch(/^data:image\/svg\+xml;base64,/);
const encoded = icon.src!.replace(/^data:image\/svg\+xml;base64,/, '');
expect(Buffer.from(encoded, 'base64').toString('utf8')).toMatch(/^<svg\b/);
expect(result._meta?.icons).toEqual(result.serverInfo.icons);
}

/**
* Collect stdout lines and stderr text from the child, tagging each piece
* with a monotonic sequence number. Lets us assert ordering between the
Expand Down Expand Up @@ -125,6 +141,7 @@ describe('MCP initialize handshake (issue #172)', () => {
expect(json.id).toBe(0);
expect(json.result.protocolVersion).toBeDefined();
expect(json.result.capabilities.tools).toBeDefined();
expectServerBranding(json.result);
}, 10000);

it('sends initialize response BEFORE tryInitializeDefault finishes', async () => {
Expand Down Expand Up @@ -152,7 +169,7 @@ describe('MCP initialize handshake (issue #172)', () => {
expect(response.seq).toBeLessThan(watcherLog.seq);
const json = JSON.parse(response.text);
expect(json.id).toBe(0);
expect(json.result.serverInfo.name).toBe('codegraph');
expectServerBranding(json.result);
}, 20000);

it('answers resources/list and prompts/list with empty lists, not -32601 (issue #621)', async () => {
Expand Down
26 changes: 25 additions & 1 deletion __tests__/mcp-unindexed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ function initializeParams(projectPath: string) {
};
}

function expectToolBranding(tool: {
icons?: Array<{ src?: string; mimeType?: string; sizes?: string[] }>;
_meta?: { icons?: unknown[] };
}) {
expect(tool.icons).toHaveLength(1);
const icon = tool.icons![0]!;
expect(icon.mimeType).toBe('image/svg+xml');
expect(icon.sizes).toEqual(['32x32']);
expect(icon.src).toMatch(/^data:image\/svg\+xml;base64,/);
expect(tool._meta?.icons).toEqual(tool.icons);
}

describe('Unindexed-workspace session policy', () => {
let tempDir: string;
let child: ChildProcessWithoutNullStreams | null = null;
Expand Down Expand Up @@ -112,7 +124,10 @@ describe('Unindexed-workspace session policy', () => {

const res = await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) });
const instructions = (res.result as { instructions: string }).instructions;
const result = res.result as { serverInfo: { title?: string; icons?: unknown[] }; _meta?: { icons?: unknown[] } };

expect(result.serverInfo.title).toBe('CodeGraph');
expect(result._meta?.icons).toEqual(result.serverInfo.icons);
expect(instructions).toMatch(/inactive/i);
expect(instructions).toMatch(/codegraph init/);
// The full playbook must NOT be sent into a session where every call fails
Expand Down Expand Up @@ -140,12 +155,21 @@ describe('Unindexed-workspace session policy', () => {
expect(instructions).not.toMatch(/inactive/i);

const list = await request(child, { id: 1, method: 'tools/list' });
const tools = (list.result as { tools: Array<{ name: string }> }).tools;
const tools = (list.result as {
tools: Array<{
name: string;
icons?: Array<{ src?: string; mimeType?: string; sizes?: string[] }>;
_meta?: { icons?: unknown[] };
}>;
}).tools;
// A 1-file project triggers the pre-existing tiny-repo tool gating (a
// reduced core set) — the contract under test is "indexed → tools are
// PRESENT", in contrast to the unindexed empty list above.
expect(tools.length).toBeGreaterThanOrEqual(3);
expect(tools.map((t) => t.name)).toContain('codegraph_explore');
for (const tool of tools) {
expectToolBranding(tool);
}
});
});

Expand Down
24 changes: 24 additions & 0 deletions src/mcp/branding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { CodeGraphPackageVersion } from './version';

/**
* Shared MCP branding metadata for server and tool-list surfaces.
*/
const SERVER_ICON_BASE64 =
'PHN2ZyBmaWxsPSJub25lIiBzdHJva2U9IiMxNjE1MGYiIHN0cm9rZS13aWR0aD0iMiIgdmlld0JveD0iMCAwIDMyIDMyIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxsaW5lIHgxPSIxNiIgeDI9IjgiIHkxPSI4IiB5Mj0iMjMiLz48bGluZSB4MT0iMTYiIHgyPSIyNCIgeTE9IjgiIHkyPSIyMyIvPjxsaW5lIHgxPSI4IiB4Mj0iMjQiIHkxPSIyMyIgeTI9IjIzIi8+PGNpcmNsZSBjeD0iMTYiIGN5PSI4IiByPSIzLjQiIGZpbGw9IiMxNjE1MGYiLz48Y2lyY2xlIGN4PSI4IiBjeT0iMjMiIHI9IjMuNCIgZmlsbD0iI2Y3ZjZmMiIvPjxjaXJjbGUgY3g9IjI0IiBjeT0iMjMiIHI9IjMuNCIgZmlsbD0iI2Y3ZjZmMiIvPjwvc3ZnPg==';

export const SERVER_ICON = {
src: `data:image/svg+xml;base64,${SERVER_ICON_BASE64}`,
mimeType: 'image/svg+xml',
sizes: ['32x32'],
} as const;

export const SERVER_INFO = {
name: 'codegraph',
title: 'CodeGraph',
version: CodeGraphPackageVersion,
icons: [SERVER_ICON],
} as const;

export const SERVER_META = {
icons: [SERVER_ICON],
} as const;
4 changes: 2 additions & 2 deletions src/mcp/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { DaemonClientHello, DaemonHello, MAX_HELLO_LINE_BYTES } from './daemon';
import { supervisionLostReason } from './ppid-watchdog';
import { treatStdinFailureAsShutdown } from './stdin-teardown';
import { CodeGraphPackageVersion } from './version';
import { SERVER_INFO, PROTOCOL_VERSION } from './session';
import { buildInitializeResult } from './session';
import { SERVER_INSTRUCTIONS } from './server-instructions';
import { getStaticTools } from './tools';
import { getTelemetry, ClientInfo } from '../telemetry';
Expand Down Expand Up @@ -295,7 +295,7 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise<
version: typeof initParams.clientInfo.version === 'string' ? initParams.clientInfo.version : undefined,
};
}
writeClient({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER_INFO, instructions: SERVER_INSTRUCTIONS } });
writeClient({ jsonrpc: '2.0', id: msg.id, result: buildInitializeResult(SERVER_INSTRUCTIONS) });
routeToDaemon(line); // prime the daemon so it resolves the project (its reply is suppressed below)
} else if (msg.method === 'tools/list') {
writeClient({ jsonrpc: '2.0', id: msg.id, result: { tools: getStaticTools() } });
Expand Down
33 changes: 15 additions & 18 deletions src/mcp/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,23 @@ import { JsonRpcRequest, JsonRpcNotification, JsonRpcTransport, ErrorCodes } fro
import { MCPEngine } from './engine';
import { tools } from './tools';
import { SERVER_INSTRUCTIONS, SERVER_INSTRUCTIONS_UNINDEXED } from './server-instructions';
import { CodeGraphPackageVersion } from './version';
import { SERVER_INFO, SERVER_META } from './branding';
import { findNearestCodeGraphRoot } from '../directory';
import { getTelemetry, ClientInfo } from '../telemetry';

/**
* MCP Server Info — kept on the session because some clients log it. The
* version tracks the real package version (was a hard-coded '0.1.0').
*/
// Exported so the proxy can answer `initialize` locally with the IDENTICAL
// payload the daemon would send — no drift between the two handshake paths.
export const SERVER_INFO = {
name: 'codegraph',
version: CodeGraphPackageVersion,
};

/** MCP Protocol Version (latest the server claims). */
export const PROTOCOL_VERSION = '2024-11-05';

export function buildInitializeResult(instructions: string) {
return {
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: SERVER_INFO,
_meta: SERVER_META,
instructions,
};
}

/**
* How long to wait for the client's `roots/list` response before giving up
* and falling back to the process cwd.
Expand Down Expand Up @@ -202,12 +201,10 @@ export class MCPSession {
const indexed = findNearestCodeGraphRoot(explicitPath ?? process.cwd()) !== null;

// Respond to the handshake BEFORE doing any heavy init — see issue #172.
this.transport.sendResult(request.id, {
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: SERVER_INFO,
instructions: indexed ? SERVER_INSTRUCTIONS : SERVER_INSTRUCTIONS_UNINDEXED,
});
this.transport.sendResult(
request.id,
buildInitializeResult(indexed ? SERVER_INSTRUCTIONS : SERVER_INSTRUCTIONS_UNINDEXED),
);

if (explicitPath) {
// Kick off engine init in the background. If another session in the
Expand Down
20 changes: 18 additions & 2 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, CONFIG_LEAF_LANGUAGES } from '../utils';
import { isGeneratedFile } from '../extraction/generated-detection';
import { scanDynamicDispatch } from './dynamic-boundaries';
import { SERVER_ICON } from './branding';

/**
* An expected, recoverable "codegraph can't serve this" condition — most
Expand Down Expand Up @@ -352,7 +353,12 @@ export function formatStaleFooter(stale: PendingFile[]): string {
*/
export interface ToolDefinition {
name: string;
title?: string;
description: string;
icons?: readonly [typeof SERVER_ICON];
_meta?: {
icons?: readonly [typeof SERVER_ICON];
};
inputSchema: {
type: 'object';
properties: Record<string, PropertySchema>;
Expand Down Expand Up @@ -386,6 +392,16 @@ const projectPathProperty: PropertySchema = {
description: 'Path to a different project with .codegraph/ initialized. If omitted, uses current project. Use this to query other codebases.',
};

function withCodeGraphBranding(
toolList: Array<Omit<ToolDefinition, 'icons' | '_meta'>>
): ToolDefinition[] {
return toolList.map(tool => ({
...tool,
icons: [SERVER_ICON],
_meta: { icons: [SERVER_ICON] },
}));
}

/**
* All CodeGraph MCP tools
*
Expand All @@ -395,7 +411,7 @@ const projectPathProperty: PropertySchema = {
*
* All tools support cross-project queries via the optional `projectPath` parameter.
*/
export const tools: ToolDefinition[] = [
export const tools: ToolDefinition[] = withCodeGraphBranding([
{
name: 'codegraph_search',
description: 'Quick symbol search by name. Returns locations only (no code). Use codegraph_explore instead to get the actual source / understand an area in one call.',
Expand Down Expand Up @@ -597,7 +613,7 @@ export const tools: ToolDefinition[] = [
},
},
},
];
]);

/**
* Allowlist-filtered tool definitions WITHOUT an engine — the static surface the
Expand Down