session.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. /**
  2. * MCP per-connection session — speaks the JSON-RPC protocol (initialize,
  3. * tools/list, tools/call) over a single {@link JsonRpcTransport}. It owns
  4. * per-client state only (which protocol version the client asked for, whether
  5. * it advertised `roots`, the one-shot roots/list latch); the heavyweight
  6. * resources (CodeGraph, watcher, ToolHandler) live in the shared
  7. * {@link MCPEngine} so daemon mode can collapse N inotify sets / DB handles
  8. * to one.
  9. *
  10. * The state-machine itself mirrors what `MCPServer` used to do inline before
  11. * issue #411 split it out — the same regression tests in
  12. * `__tests__/mcp-initialize.test.ts` still drive this code path.
  13. */
  14. import * as path from 'path';
  15. import { JsonRpcRequest, JsonRpcNotification, JsonRpcTransport, ErrorCodes } from './transport';
  16. import { MCPEngine } from './engine';
  17. import { tools } from './tools';
  18. import { SERVER_INSTRUCTIONS, SERVER_INSTRUCTIONS_NO_ROOT_INDEX } from './server-instructions';
  19. import { CodeGraphPackageVersion } from './version';
  20. import { resolveServerRoot } from '../directory';
  21. import { getTelemetry, ClientInfo } from '../telemetry';
  22. import { getUpdateNotice } from '../upgrade/update-check';
  23. import { ExploreSessionState } from './explore-session-state';
  24. /**
  25. * MCP Server Info — kept on the session because some clients log it. The
  26. * version tracks the real package version (was a hard-coded '0.1.0').
  27. */
  28. // Exported so the proxy can answer `initialize` locally with the IDENTICAL
  29. // payload the daemon would send — no drift between the two handshake paths.
  30. export const SERVER_INFO = {
  31. name: 'codegraph',
  32. version: CodeGraphPackageVersion,
  33. };
  34. /**
  35. * Instructions for the `initialize` response, with the update-availability
  36. * notice appended when one is known (#1243). Exported so the proxy's local
  37. * handshake sends the IDENTICAL payload — same convention as SERVER_INFO.
  38. * `getUpdateNotice` is a memoized synchronous cache read, so the #172
  39. * respond-fast contract holds; when no notice exists the instructions are
  40. * byte-identical to the bare constants.
  41. *
  42. * Test-authoring note: on a machine whose real `~/.codegraph` cache knows a
  43. * newer release, spawned servers append the notice — a test asserting exact
  44. * instructions equality must set `CODEGRAPH_NO_UPDATE_CHECK=1` in the spawn
  45. * env or it will fail only in the weeks after a release ships.
  46. */
  47. export function initializeInstructions(base: string, notice: string | null = getUpdateNotice()): string {
  48. if (!notice) return base;
  49. return (
  50. `${base}\n\n---\n${notice} This server keeps running the old version until ` +
  51. `the user upgrades — mention it when convenient; do not run the upgrade yourself.`
  52. );
  53. }
  54. /** MCP Protocol Version (latest the server claims). */
  55. export const PROTOCOL_VERSION = '2024-11-05';
  56. /**
  57. * How long to wait for the client's `roots/list` response before giving up
  58. * and falling back to the process cwd.
  59. */
  60. const ROOTS_LIST_TIMEOUT_MS = 5000;
  61. /**
  62. * Convert a file:// URI to a filesystem path. Handles URL encoding and
  63. * Windows drive letter paths.
  64. */
  65. function fileUriToPath(uri: string): string {
  66. try {
  67. const url = new URL(uri);
  68. let filePath = decodeURIComponent(url.pathname);
  69. if (process.platform === 'win32' && /^\/[a-zA-Z]:/.test(filePath)) {
  70. filePath = filePath.slice(1);
  71. }
  72. return path.resolve(filePath);
  73. } catch {
  74. return uri.replace(/^file:\/\/\/?/, '');
  75. }
  76. }
  77. /** First usable filesystem path from a `roots/list` result, or null. */
  78. function firstRootPath(result: unknown): string | null {
  79. if (!result || typeof result !== 'object') return null;
  80. const roots = (result as { roots?: unknown }).roots;
  81. if (!Array.isArray(roots) || roots.length === 0) return null;
  82. const first = roots[0] as { uri?: unknown };
  83. if (typeof first?.uri !== 'string') return null;
  84. return fileUriToPath(first.uri);
  85. }
  86. export interface MCPSessionOptions {
  87. /**
  88. * Explicit project path from the `--path` CLI flag. When set, the session
  89. * will not bother asking the client for `roots/list` — we already know
  90. * where the project lives.
  91. */
  92. explicitProjectPath?: string | null;
  93. }
  94. /**
  95. * One MCP client's view of the server. Created fresh per stdio launch
  96. * (direct mode) or per socket connection (daemon mode).
  97. */
  98. export class MCPSession {
  99. private clientSupportsRoots = false;
  100. /** From the initialize handshake — attributes usage rollups to the agent host. */
  101. private clientInfo: ClientInfo | undefined;
  102. private rootsAttempted = false;
  103. private resolvePromise: Promise<void> | null = null;
  104. private explicitProjectPath: string | null;
  105. /**
  106. * What `codegraph_explore` has already returned to THIS client, per project
  107. * (CG-17). Owned by the session, not the engine: the daemon shares one engine
  108. * (and one ToolHandler, and a pool of worker threads) across every connected
  109. * client, so state kept over there would blend two agents' histories and let
  110. * one session's calls suppress source the other has never seen. It dies with
  111. * the session — a reconnecting client starts clean.
  112. */
  113. private readonly exploreSession = new ExploreSessionState();
  114. constructor(
  115. private transport: JsonRpcTransport,
  116. private engine: MCPEngine,
  117. opts: MCPSessionOptions = {},
  118. ) {
  119. this.explicitProjectPath = opts.explicitProjectPath ?? null;
  120. }
  121. /**
  122. * Start handling messages from the transport. Returns immediately — the
  123. * session lives for as long as the transport is open.
  124. */
  125. start(): void {
  126. this.transport.start(this.handleMessage.bind(this));
  127. }
  128. /**
  129. * Tear down the session. Does NOT touch the engine (the engine may serve
  130. * other sessions) or call `process.exit` (the daemon decides when to exit).
  131. */
  132. stop(): void {
  133. this.transport.stop();
  134. }
  135. /** Underlying transport — exposed for daemon-side close hooks. */
  136. getTransport(): JsonRpcTransport {
  137. return this.transport;
  138. }
  139. /**
  140. * This session's explore call history (CG-17). Exposed so tests can assert
  141. * that two sessions on one daemon keep separate state; nothing in the server
  142. * reaches for another session's copy.
  143. */
  144. getExploreSessionState(): ExploreSessionState {
  145. return this.exploreSession;
  146. }
  147. private async handleMessage(message: JsonRpcRequest | JsonRpcNotification): Promise<void> {
  148. const isRequest = 'id' in message;
  149. switch (message.method) {
  150. case 'initialize':
  151. if (isRequest) await this.handleInitialize(message as JsonRpcRequest);
  152. break;
  153. case 'initialized':
  154. // Notification that client has finished initialization — no action needed.
  155. break;
  156. case 'tools/list':
  157. if (isRequest) await this.handleToolsList(message as JsonRpcRequest);
  158. break;
  159. case 'tools/call':
  160. if (isRequest) await this.handleToolsCall(message as JsonRpcRequest);
  161. break;
  162. case 'ping':
  163. if (isRequest) this.transport.sendResult((message as JsonRpcRequest).id, {});
  164. break;
  165. case 'resources/list':
  166. // We expose no MCP resources, but some clients (opencode, Codex) probe
  167. // for them on connect; reply with an empty list instead of a
  168. // MethodNotFound error that surfaces as a scary `-32601` log line. (#621)
  169. if (isRequest) this.transport.sendResult((message as JsonRpcRequest).id, { resources: [] });
  170. break;
  171. case 'resources/templates/list':
  172. if (isRequest) this.transport.sendResult((message as JsonRpcRequest).id, { resourceTemplates: [] });
  173. break;
  174. case 'prompts/list':
  175. // Likewise — no prompts exposed, but answer the probe cleanly. (#621)
  176. if (isRequest) this.transport.sendResult((message as JsonRpcRequest).id, { prompts: [] });
  177. break;
  178. default:
  179. if (isRequest) {
  180. this.transport.sendError(
  181. (message as JsonRpcRequest).id,
  182. ErrorCodes.MethodNotFound,
  183. `Method not found: ${message.method}`,
  184. );
  185. }
  186. }
  187. }
  188. private async handleInitialize(request: JsonRpcRequest): Promise<void> {
  189. const params = request.params as {
  190. rootUri?: string;
  191. workspaceFolders?: Array<{ uri: string; name: string }>;
  192. capabilities?: { roots?: unknown };
  193. clientInfo?: { name?: unknown; version?: unknown };
  194. } | undefined;
  195. this.clientSupportsRoots = !!params?.capabilities?.roots;
  196. if (params?.clientInfo) {
  197. this.clientInfo = {
  198. name: typeof params.clientInfo.name === 'string' ? params.clientInfo.name : undefined,
  199. version: typeof params.clientInfo.version === 'string' ? params.clientInfo.version : undefined,
  200. };
  201. }
  202. // Explicit project signal, strongest first: client-provided rootUri /
  203. // workspaceFolders (LSP-style), else the --path the server was launched
  204. // with. cwd is NOT used here — we defer it so a roots/list answer can
  205. // win over it. See issue #196.
  206. let explicitPath: string | null = null;
  207. if (params?.rootUri) {
  208. explicitPath = fileUriToPath(params.rootUri);
  209. } else if (params?.workspaceFolders?.[0]?.uri) {
  210. explicitPath = fileUriToPath(params.workspaceFolders[0].uri);
  211. } else if (this.explicitProjectPath) {
  212. explicitPath = this.explicitProjectPath;
  213. }
  214. // Pick the instructions variant by the root's index state — synchronous
  215. // and bounded (an existsSync walk-up plus, when that misses, the depth- and
  216. // count-bounded workspace down-scan; no DB open, so the #172 respond-fast
  217. // contract holds). This is the SAME resolution the engine's doInitialize
  218. // runs (#1606), so the variant matches what the engine will actually adopt
  219. // — a workspace whose single indexed sub-project becomes the default gets
  220. // the full single-project playbook, race-free by construction (both sides
  221. // compute it independently; no ordering between handshake and engine init
  222. // is assumed). When the root ISN'T indexed (and nothing was adopted), send
  223. // the per-project variant (tools are still exposed — see handleToolsList):
  224. // it tells the agent there is no default project and to pass `projectPath`
  225. // to any project that has a `.codegraph/`. Gating tool AVAILABILITY on
  226. // whether `./` is indexed was the #964 bug — it broke monorepos (only
  227. // sub-projects indexed) and never surfaced the tools after a mid-session
  228. // `codegraph init`. When no explicit path is known yet (roots/list dance
  229. // pending), cwd is the best predictor of where the default will resolve.
  230. const indexed = resolveServerRoot(explicitPath ?? process.cwd()).root !== null;
  231. // Respond to the handshake BEFORE doing any heavy init — see issue #172.
  232. this.transport.sendResult(request.id, {
  233. protocolVersion: PROTOCOL_VERSION,
  234. capabilities: { tools: {} },
  235. serverInfo: SERVER_INFO,
  236. instructions: initializeInstructions(indexed ? SERVER_INSTRUCTIONS : SERVER_INSTRUCTIONS_NO_ROOT_INDEX),
  237. });
  238. if (explicitPath) {
  239. // Kick off engine init in the background. If another session in the
  240. // same daemon already opened the project, `ensureInitialized` is a
  241. // ~free no-op — N concurrent clients pay exactly one open.
  242. this.resolvePromise = this.engine.ensureInitialized(explicitPath);
  243. }
  244. }
  245. private async handleToolsList(request: JsonRpcRequest): Promise<void> {
  246. await this.retryInitIfNeeded();
  247. // Always expose the tools — even when the server root has no index. Gating
  248. // availability on whether `./` is indexed (the old behavior) breaks the
  249. // monorepo case where only sub-projects carry a `.codegraph/` (the agent
  250. // saw zero tools and couldn't even reach an indexed sub-project by
  251. // `projectPath`), and it hides the tools from a session that started before
  252. // the user ran `codegraph init` (most hosts request the list once, so the
  253. // freshly-built index never surfaces). #964. The not-indexed case is still
  254. // safe: a call against an un-indexed path returns SUCCESS-shaped guidance
  255. // ("pass projectPath / run codegraph init"), never `isError`, so it can't
  256. // teach the agent to abandon codegraph. `getTools()` returns the default
  257. // surface even before a project is open.
  258. this.transport.sendResult(request.id, {
  259. tools: this.engine.getToolHandler().getTools(),
  260. });
  261. }
  262. private async handleToolsCall(request: JsonRpcRequest): Promise<void> {
  263. const params = request.params as {
  264. name: string;
  265. arguments?: Record<string, unknown>;
  266. };
  267. if (!params || !params.name) {
  268. this.transport.sendError(request.id, ErrorCodes.InvalidParams, 'Missing tool name');
  269. return;
  270. }
  271. const toolName = params.name;
  272. const toolArgs = params.arguments || {};
  273. const tool = tools.find((t) => t.name === toolName);
  274. if (!tool) {
  275. this.transport.sendError(
  276. request.id,
  277. ErrorCodes.InvalidParams,
  278. `Unknown tool: ${toolName}`,
  279. );
  280. return;
  281. }
  282. if (process.env.CODEGRAPH_MCP_DEBUG) process.stderr.write(`[mcp-debug] toolsCall ${toolName} id=${String(request.id)} pre-init\n`);
  283. await this.retryInitIfNeeded();
  284. if (process.env.CODEGRAPH_MCP_DEBUG) process.stderr.write(`[mcp-debug] toolsCall ${toolName} id=${String(request.id)} dispatch\n`);
  285. const result = await this.engine.getToolHandler().execute(toolName, toolArgs, this.exploreSession);
  286. if (process.env.CODEGRAPH_MCP_DEBUG) process.stderr.write(`[mcp-debug] toolsCall ${toolName} id=${String(request.id)} done\n`);
  287. this.transport.sendResult(request.id, result);
  288. // After the reply is on the wire — telemetry must never delay a tool
  289. // response (in-memory increment only; see src/telemetry).
  290. getTelemetry().recordUsage('mcp_tool', toolName, !result.isError, this.clientInfo);
  291. }
  292. /**
  293. * Lazy default-project resolution. Three layers:
  294. * 1. await the in-flight init kicked off from `handleInitialize` (if any);
  295. * 2. if still uninitialized and we never asked the client for its roots,
  296. * do so now (one-shot); fall back to cwd if the client lacks roots;
  297. * 3. last-resort: re-walk from the best candidate — picks up projects
  298. * that were `codegraph init`'d *after* the server started.
  299. */
  300. private async retryInitIfNeeded(): Promise<void> {
  301. if (this.resolvePromise) {
  302. try { await this.resolvePromise; } catch { /* fall through to retry */ }
  303. this.resolvePromise = null;
  304. }
  305. if (this.engine.hasDefaultCodeGraph()) return;
  306. const hint = this.explicitProjectPath ?? this.engine.getProjectPath();
  307. if (!hint && !this.rootsAttempted) {
  308. this.rootsAttempted = true;
  309. this.resolvePromise = this.clientSupportsRoots
  310. ? this.initFromRoots()
  311. : this.engine.ensureInitialized(process.cwd());
  312. try { await this.resolvePromise; } catch { /* fall through */ }
  313. this.resolvePromise = null;
  314. if (this.engine.hasDefaultCodeGraph()) return;
  315. }
  316. // Last resort: walk from the best candidate (sync open). Picks up
  317. // projects that appeared after the server started.
  318. const candidate = hint ?? process.cwd();
  319. this.engine.retryInitializeSync(candidate);
  320. }
  321. /**
  322. * Ask the client for its workspace root via `roots/list` and open the
  323. * first one. Falls back to `process.cwd()` on timeout or empty answer.
  324. */
  325. private async initFromRoots(): Promise<void> {
  326. let target = process.cwd();
  327. try {
  328. const result = await this.transport.request('roots/list', undefined, ROOTS_LIST_TIMEOUT_MS);
  329. const rootPath = firstRootPath(result);
  330. if (rootPath) {
  331. target = rootPath;
  332. } else {
  333. process.stderr.write('[CodeGraph MCP] Client returned no workspace roots; falling back to process cwd.\n');
  334. }
  335. } catch (err) {
  336. const msg = err instanceof Error ? err.message : String(err);
  337. process.stderr.write(`[CodeGraph MCP] roots/list request failed (${msg}); falling back to process cwd.\n`);
  338. }
  339. await this.engine.ensureInitialized(target);
  340. }
  341. }