From b817e60756d5cfb1ab49bc7f42bad1eac1c190bf Mon Sep 17 00:00:00 2001 From: Mikhail Fedosov Date: Sun, 14 Jun 2026 14:57:29 +0400 Subject: [PATCH 1/2] feat(mcp): advertise CodeGraph server branding --- CHANGELOG.md | 3 +++ __tests__/mcp-daemon.test.ts | 33 +++++++++++++++++++++++++++++++- __tests__/mcp-initialize.test.ts | 19 +++++++++++++++++- __tests__/mcp-unindexed.test.ts | 26 ++++++++++++++++++++++++- src/mcp/branding.ts | 24 +++++++++++++++++++++++ src/mcp/proxy.ts | 4 ++-- src/mcp/session.ts | 33 +++++++++++++++----------------- src/mcp/tools.ts | 20 +++++++++++++++++-- 8 files changed, 137 insertions(+), 25 deletions(-) create mode 100644 src/mcp/branding.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9806578b3..571c11a49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/__tests__/mcp-daemon.test.ts b/__tests__/mcp-daemon.test.ts index c00d528f6..a20c65fa3 100644 --- a/__tests__/mcp-daemon.test.ts +++ b/__tests__/mcp-daemon.test.ts @@ -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).toBe('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(/^; + _meta?: { icons?: unknown[] }; +}) { + expect(tool.icons).toHaveLength(1); + const icon = tool.icons![0]!; + expect(icon.mimeType).toBe('image/svg+xml'); + expect(icon.sizes).toBe('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) { @@ -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); @@ -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 () => { diff --git a/__tests__/mcp-initialize.test.ts b/__tests__/mcp-initialize.test.ts index 0a320773d..2a400eb25 100644 --- a/__tests__/mcp-initialize.test.ts +++ b/__tests__/mcp-initialize.test.ts @@ -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).toBe('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(/^ { 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 () => { @@ -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 () => { diff --git a/__tests__/mcp-unindexed.test.ts b/__tests__/mcp-unindexed.test.ts index 2b0019d6d..dcf7e3121 100644 --- a/__tests__/mcp-unindexed.test.ts +++ b/__tests__/mcp-unindexed.test.ts @@ -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).toBe('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; @@ -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 @@ -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); + } }); }); diff --git a/src/mcp/branding.ts b/src/mcp/branding.ts new file mode 100644 index 000000000..82e719448 --- /dev/null +++ b/src/mcp/branding.ts @@ -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; diff --git a/src/mcp/proxy.ts b/src/mcp/proxy.ts index b7b538dd4..3cfe5b5a3 100644 --- a/src/mcp/proxy.ts +++ b/src/mcp/proxy.ts @@ -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'; @@ -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() } }); diff --git a/src/mcp/session.ts b/src/mcp/session.ts index a197087f3..9c183bc91 100644 --- a/src/mcp/session.ts +++ b/src/mcp/session.ts @@ -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. @@ -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 diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 83f2e0c45..7332ed44c 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -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 @@ -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; @@ -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> +): ToolDefinition[] { + return toolList.map(tool => ({ + ...tool, + icons: [SERVER_ICON], + _meta: { icons: [SERVER_ICON] }, + })); +} + /** * All CodeGraph MCP tools * @@ -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.', @@ -597,7 +613,7 @@ export const tools: ToolDefinition[] = [ }, }, }, -]; +]); /** * Allowlist-filtered tool definitions WITHOUT an engine — the static surface the From df059d2766b14d40ecab24e702093d317b3480f0 Mon Sep 17 00:00:00 2001 From: Mikhail Fedosov Date: Sun, 14 Jun 2026 16:35:29 +0400 Subject: [PATCH 2/2] fix(mcp): send icon sizes as array --- __tests__/mcp-daemon.test.ts | 8 ++++---- __tests__/mcp-initialize.test.ts | 4 ++-- __tests__/mcp-unindexed.test.ts | 6 +++--- src/mcp/branding.ts | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/__tests__/mcp-daemon.test.ts b/__tests__/mcp-daemon.test.ts index a20c65fa3..2dc1e5fa9 100644 --- a/__tests__/mcp-daemon.test.ts +++ b/__tests__/mcp-daemon.test.ts @@ -103,7 +103,7 @@ function sendInitialize(child: ChildProcessWithoutNullStreams, rootUri: string, } function expectServerBranding(result: { - serverInfo: { name?: string; title?: string; icons?: Array<{ src?: string; mimeType?: string; sizes?: string }> }; + serverInfo: { name?: string; title?: string; icons?: Array<{ src?: string; mimeType?: string; sizes?: string[] }> }; _meta?: { icons?: unknown[] }; }) { expect(result.serverInfo.name).toBe('codegraph'); @@ -111,7 +111,7 @@ function expectServerBranding(result: { expect(result.serverInfo.icons).toHaveLength(1); const icon = result.serverInfo.icons![0]!; expect(icon.mimeType).toBe('image/svg+xml'); - expect(icon.sizes).toBe('32x32'); + 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(/^; + 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).toBe('32x32'); + expect(icon.sizes).toEqual(['32x32']); expect(icon.src).toMatch(/^data:image\/svg\+xml;base64,/); expect(tool._meta?.icons).toEqual(tool.icons); } diff --git a/__tests__/mcp-initialize.test.ts b/__tests__/mcp-initialize.test.ts index 2a400eb25..3957e53b1 100644 --- a/__tests__/mcp-initialize.test.ts +++ b/__tests__/mcp-initialize.test.ts @@ -50,7 +50,7 @@ function sendInitialize(child: ChildProcessWithoutNullStreams, projectPath: stri } function expectServerBranding(result: { - serverInfo: { name?: string; title?: string; icons?: Array<{ src?: string; mimeType?: string; sizes?: string }> }; + serverInfo: { name?: string; title?: string; icons?: Array<{ src?: string; mimeType?: string; sizes?: string[] }> }; _meta?: { icons?: unknown[] }; }) { expect(result.serverInfo.name).toBe('codegraph'); @@ -58,7 +58,7 @@ function expectServerBranding(result: { expect(result.serverInfo.icons).toHaveLength(1); const icon = result.serverInfo.icons![0]!; expect(icon.mimeType).toBe('image/svg+xml'); - expect(icon.sizes).toBe('32x32'); + 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(/^; + 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).toBe('32x32'); + expect(icon.sizes).toEqual(['32x32']); expect(icon.src).toMatch(/^data:image\/svg\+xml;base64,/); expect(tool._meta?.icons).toEqual(tool.icons); } @@ -158,7 +158,7 @@ describe('Unindexed-workspace session policy', () => { const tools = (list.result as { tools: Array<{ name: string; - icons?: Array<{ src?: string; mimeType?: string; sizes?: string }>; + icons?: Array<{ src?: string; mimeType?: string; sizes?: string[] }>; _meta?: { icons?: unknown[] }; }>; }).tools; diff --git a/src/mcp/branding.ts b/src/mcp/branding.ts index 82e719448..24db8b744 100644 --- a/src/mcp/branding.ts +++ b/src/mcp/branding.ts @@ -9,7 +9,7 @@ const SERVER_ICON_BASE64 = export const SERVER_ICON = { src: `data:image/svg+xml;base64,${SERVER_ICON_BASE64}`, mimeType: 'image/svg+xml', - sizes: '32x32', + sizes: ['32x32'], } as const; export const SERVER_INFO = {