diff --git a/plugin/chatgpt-extension/README.md b/plugin/chatgpt-extension/README.md index 20fb8079da..4837c48211 100644 --- a/plugin/chatgpt-extension/README.md +++ b/plugin/chatgpt-extension/README.md @@ -44,7 +44,10 @@ not-found-or-not-authorized problem rather than an empty success. Inside the server a `DaemonBridge` reads through `@tracedecay/sdk` (the workspace's generated operation contracts, never hand-edited): -- MCP-transport operations (`project_list`, `search`, `node`, `impact`, +- Registry operations (`project_list`, `project_context`) use a projectless + `RegistryClient` over a profile-scoped `tracedecay serve` session; it takes + no base URL, project id, or token. +- MCP-transport project operations (`search`, `node`, `impact`, `context`, `status`, `find_exact_symbol`) go through an `McpToolAdapter` that spawns `tracedecay serve` as an MCP client. - HTTP operations (`code_symbol_search`, `code_callers`, `code_callees`, diff --git a/plugin/chatgpt-extension/src/server/bridge.ts b/plugin/chatgpt-extension/src/server/bridge.ts index b3427ca9cb..7cf097fb60 100644 --- a/plugin/chatgpt-extension/src/server/bridge.ts +++ b/plugin/chatgpt-extension/src/server/bridge.ts @@ -19,6 +19,7 @@ import type { } from "@tracedecay/sdk"; import type { DaemonState, Failure, ProjectRef } from "../shared/view.js"; import { httpBaseUrl, probeDaemon, readDaemonAuthority, type AuthorityLookup, type DaemonAuthorityRecord } from "./authority.js"; +import { RegistryClient } from "./registry-client.js"; import { classifyCode, DaemonFailure, ServeSession, toFailure } from "./serve-client.js"; export type BridgeOptions = { @@ -90,29 +91,21 @@ export class DaemonBridge { return this.#registry; } - #registryClient(): TraceDecayClient { - return createClient({ - baseUrl: "http://127.0.0.1", - projectId: "registry", - token: "unused-registry-reads-use-mcp", - mcp: { - callTool: (toolName, request, options) => - this.#registrySession().callJson(toolName, asRecord(request), options.signal), - }, - }); + #registryClient(): RegistryClient { + return new RegistryClient((toolName, request, signal) => this.#registrySession().callJson(toolName, request, signal)); } async listProjects(signal?: AbortSignal): Promise { await this.#requireAuthority(); const client = this.#registryClient(); - const listed = await runSdk(() => client.operations.project_list({ limit: 10 }, optional(signal))); + const listed = await runSdk(() => client.projectList({ limit: 10 }, signal)); return listed.projects.map(projectRef); } async resolveProject(projectId: string, signal?: AbortSignal): Promise { await this.#requireAuthority(); const client = this.#registryClient(); - const context = await runSdk(() => client.operations.project_context({ project_selector: { project_id: projectId } }, optional(signal))); + const context = await runSdk(() => client.projectContext({ project_selector: { project_id: projectId } }, signal)); if (context.status === "not_found") { const cached = this.#projects.get(projectId); this.#projects.delete(projectId); diff --git a/plugin/chatgpt-extension/src/server/registry-client.ts b/plugin/chatgpt-extension/src/server/registry-client.ts new file mode 100644 index 0000000000..1379f01e4b --- /dev/null +++ b/plugin/chatgpt-extension/src/server/registry-client.ts @@ -0,0 +1,72 @@ +import { + OPERATIONS, + TraceDecayAbortError, + TraceDecayMalformedResponseError, + TraceDecayTransportError, + type OperationApplicationProjectContextRequest, + type OperationApplicationProjectContextResult, + type OperationApplicationProjectListRequest, + type OperationApplicationProjectListResult, +} from "@tracedecay/sdk"; + +/** Executes a generated MCP tool binding against a projectless daemon session. */ +export type RegistryToolCaller = (toolName: string, request: Record, signal?: AbortSignal) => Promise; + +type RegistryDescriptor = { + readonly operation: string; + readonly transport: { readonly kind: string; readonly toolName?: string }; + readonly decodeRequest: (value: unknown) => Req; + readonly decodeResult: (value: unknown) => Res; +}; + +function registryDescriptor(operation: string): RegistryDescriptor { + const descriptor = OPERATIONS.find((candidate) => candidate.operation === operation); + if (descriptor === undefined || descriptor.transport.kind !== "mcp_tool") { + throw new TypeError(`${operation} is not an MCP-backed operation in the canonical catalog`); + } + return descriptor as unknown as RegistryDescriptor; +} + +const PROJECT_LIST = registryDescriptor("project_list"); +const PROJECT_CONTEXT = registryDescriptor("project_context"); + +/** + * Projectless registry capability. It has no base URL, project id, or token: + * registry reads are admitted by the profile-scoped serve session alone, with + * operation identity and request/result contracts taken from the generated + * catalog. + */ +export class RegistryClient { + readonly #call: RegistryToolCaller; + + constructor(call: RegistryToolCaller) { + this.#call = call; + } + + projectList(request: OperationApplicationProjectListRequest, signal?: AbortSignal): Promise { + return this.#invoke(PROJECT_LIST, request, signal); + } + + projectContext(request: OperationApplicationProjectContextRequest, signal?: AbortSignal): Promise { + return this.#invoke(PROJECT_CONTEXT, request, signal); + } + + async #invoke(descriptor: RegistryDescriptor, request: Req, signal?: AbortSignal): Promise { + const toolName = descriptor.transport.toolName as string; + const decoded = descriptor.decodeRequest(request) as Record; + let response: unknown; + try { + response = await this.#call(toolName, decoded, signal); + } catch (cause) { + if (signal?.aborted) throw new TraceDecayAbortError(signal.reason ?? cause); + throw new TraceDecayTransportError(`MCP tool ${toolName} failed`, cause); + } + try { + return descriptor.decodeResult(response); + } catch (cause) { + throw new TraceDecayMalformedResponseError(`MCP tool ${toolName} returned an invalid ${descriptor.operation} result`, { + payload: { response, cause }, + }); + } + } +}