diff --git a/.changeset/host-scoped-tools.md b/.changeset/host-scoped-tools.md new file mode 100644 index 00000000..55353237 --- /dev/null +++ b/.changeset/host-scoped-tools.md @@ -0,0 +1,5 @@ +--- +"@gh-symphony/cli": patch +--- + +Run tracker tools through host-owned runtime integrations with normalized issue context and frozen per-session contracts for #673. diff --git a/docs/architecture.md b/docs/architecture.md index 18f92a0d..6c7c002a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,6 +93,11 @@ touches a layer, check that its slice (and the linked documents) still holds. [Linear](trackers/linear.md), and [file](trackers/file.md). GitHub's synthetic `Archived` state is a GitHub-specific implementation choice, not normalized Symphony core behavior. +- Host-side tracker tools: provider adapters own advertised schemas and credentials, + and receive normalized active-issue context that stays host-internal. Callers are + responsible for narrowing documents; adapters do not infer or rewrite a target. + Codex snapshots those schemas in its runtime plan; Claude snapshots them when its + loopback Streamable HTTP MCP server starts. ### 6. Observability — events and status surfaces diff --git a/docs/trackers/github.md b/docs/trackers/github.md index 02b1ab3f..fa138265 100644 --- a/docs/trackers/github.md +++ b/docs/trackers/github.md @@ -11,9 +11,9 @@ the result only; the adapter uses the host's GitHub credential or token broker. | Name | `github_graphql` | | Input | An object with required `query: string`; optional `variables: object` and `operationName: string`; no additional top-level properties. | | Mutations | Permitted. A mutation must be intentionally scoped to the active issue or its repository. | -| Scope | The worker supplies the normalized active issue `{ id, identifier, nativeRef }` to the adapter. `nativeRef` remains host-internal and is never sent as an extra GraphQL payload field. The tool is an arbitrary GitHub GraphQL transport, so callers must constrain their document and variables to that active issue/repository; it does not infer or rewrite a query's target. | +| Scope | The worker supplies the normalized active issue `{ id, identifier, nativeRef }` to the adapter. `nativeRef` remains host-internal and is never sent as an extra GraphQL payload field. The tool is an arbitrary GitHub GraphQL transport, so callers must constrain their document and variables to the active issue/repository; the adapter does not infer or rewrite a target. | | Result | The provider GraphQL payload. For queries, Symphony adds the GitHub `rateLimit` selection when absent and may return normalized rate-limit metadata with the payload. | -| Errors | Invalid tool arguments, missing host authentication, HTTP failures, and GraphQL errors are returned to the runtime as structured tool failures. Unknown tool names are rejected. | +| Errors | Invalid tool arguments, missing host authentication, HTTP failures, and GraphQL errors are returned to the runtime as structured tool failures. Unknown tool names are rejected. | | Rate limits | GitHub GraphQL rate-limit headers and the GraphQL `rateLimit` field are measured by the host and applied to the shared GitHub rate-limit policy; callers should keep queries small and respect retry guidance. | The GitHub tool is always advertised because GitHub repository and pull-request diff --git a/packages/runtime-claude/src/mcp-http-server.test.ts b/packages/runtime-claude/src/mcp-http-server.test.ts index 67020bd5..75934cbf 100644 --- a/packages/runtime-claude/src/mcp-http-server.test.ts +++ b/packages/runtime-claude/src/mcp-http-server.test.ts @@ -13,6 +13,54 @@ afterEach(async () => { }); describe("Claude host MCP HTTP server", () => { + it("freezes the advertised tool specs when the server starts", async () => { + let specs = [ + { + name: "snapshotted_tool", + description: "Initial tool", + inputSchema: { + type: "object" as const, + properties: {}, + required: [], + additionalProperties: false, + }, + }, + ]; + const adapter = { + agentToolSpecs: () => specs, + executeAgentTool: vi.fn(), + }; + server = await startClaudeMcpHttpServer({ + env: {}, + context: { + issue: { id: "issue-1", identifier: "owner/repo#1", nativeRef: {} }, + }, + adapters: [adapter], + }); + specs = [ + { + ...specs[0]!, + name: "reloaded_tool", + description: "Reloaded tool", + }, + ]; + + const response = await fetch(server.url, { + method: "POST", + headers: { + authorization: `Bearer ${server.sessionToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }); + + await expect(response.json()).resolves.toMatchObject({ + result: { + tools: [expect.objectContaining({ name: "snapshotted_tool" })], + }, + }); + }); + it("requires its session capability and exposes only the selected host tool", async () => { server = await startClaudeMcpHttpServer({ env: { SYMPHONY_TRACKER_KIND: "github" }, diff --git a/packages/runtime-claude/src/mcp-http-server.ts b/packages/runtime-claude/src/mcp-http-server.ts index 6c8f433e..5ac8af6f 100644 --- a/packages/runtime-claude/src/mcp-http-server.ts +++ b/packages/runtime-claude/src/mcp-http-server.ts @@ -3,6 +3,7 @@ import { createServer, type IncomingMessage, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import type { AgentToolExecutionContext, + AgentToolSpec, OrchestratorTrackerAdapter, } from "@gh-symphony/core"; import { githubProjectTrackerAdapter } from "@gh-symphony/tracker-github"; @@ -20,8 +21,15 @@ export async function startClaudeMcpHttpServer(options: { env: NodeJS.ProcessEnv; context: ClaudeMcpHostContext; onEvent?: (event: "started" | "stopped") => void; + adapters?: readonly Pick< + OrchestratorTrackerAdapter, + "agentToolSpecs" | "executeAgentTool" + >[]; }): Promise { const sessionToken = randomBytes(32).toString("base64url"); + const toolSnapshot = createToolSnapshot( + options.adapters ?? resolveHostToolAdapters(options.env) + ); let server: Server | null = createServer(async (request, response) => { if (!isAuthorized(request, sessionToken)) { response.writeHead(401, { "content-type": "application/json" }); @@ -38,7 +46,12 @@ export async function startClaudeMcpHttpServer(options: { response.end(JSON.stringify(error(null, -32700, "Parse error"))); return; } - const result = await dispatch(payload, options.env, options.context); + const result = await dispatch( + payload, + options.env, + options.context, + toolSnapshot + ); if (!("id" in payload)) { response.writeHead(202).end(); return; @@ -71,7 +84,8 @@ export async function startClaudeMcpHttpServer(options: { async function dispatch( payload: Record, env: NodeJS.ProcessEnv, - context: ClaudeMcpHostContext + context: ClaudeMcpHostContext, + toolSnapshot: readonly HostToolSnapshot[] ): Promise> { const id = payload.id ?? null; if (payload.method === "initialize") @@ -86,16 +100,20 @@ async function dispatch( }; if (payload.method === "ping") return { jsonrpc: "2.0", id, result: {} }; if (payload.method === "tools/list") - return { jsonrpc: "2.0", id, result: { tools: availableTools(env) } }; + return { + jsonrpc: "2.0", + id, + result: { tools: toolSnapshot.map((entry) => entry.spec) }, + }; if (payload.method !== "tools/call" || !isRecord(payload.params)) return error(id, -32601, "Method not found"); const name = payload.params.name; const argumentsValue = payload.params.arguments; if (!isRecord(argumentsValue) || typeof name !== "string") return error(id, -32602, "Tool arguments must be an object."); - const adapter = resolveHostToolAdapters(env).find((candidate) => - candidate.agentToolSpecs?.().some((tool) => tool.name === name) - ); + const adapter = toolSnapshot.find( + (entry) => entry.spec.name === name + )?.adapter; if (!adapter?.executeAgentTool) { return error(id, -32602, `Tool "${name}" is not available.`); } @@ -128,11 +146,22 @@ async function dispatch( } } -function availableTools( - env: NodeJS.ProcessEnv -): Array> { - return resolveHostToolAdapters(env).flatMap( - (adapter) => adapter.agentToolSpecs?.() ?? [] +type HostToolSnapshot = { + spec: AgentToolSpec; + adapter: Pick; +}; + +function createToolSnapshot( + adapters: readonly Pick< + OrchestratorTrackerAdapter, + "agentToolSpecs" | "executeAgentTool" + >[] +): readonly HostToolSnapshot[] { + return adapters.flatMap((adapter) => + (adapter.agentToolSpecs?.() ?? []).map((spec) => ({ + spec: structuredClone(spec), + adapter, + })) ); } diff --git a/packages/tool-github-graphql/src/tool.test.ts b/packages/tool-github-graphql/src/tool.test.ts index 49e29fd9..29f1576b 100644 --- a/packages/tool-github-graphql/src/tool.test.ts +++ b/packages/tool-github-graphql/src/tool.test.ts @@ -300,6 +300,40 @@ describe("resolveGitHubGraphQLToken", () => { }); describe("executeGitHubGraphQL", () => { + it("executes a repository query while carrying host-side issue context", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: { viewer: { login: "octo" } } }), { + status: 200, + }) + ); + + await expect( + executeGitHubGraphQL( + { query: "query Viewer { viewer { login } }" }, + { token: "ghs_static" }, + fetchImpl as typeof fetch, + { + issue: { + id: "issue-1", + identifier: "owner/repo#1", + nativeRef: { + itemId: "project-item-1", + contentType: "Issue", + sourceState: "OPEN", + linkedPullRequests: [], + linkedPullRequestsTruncated: false, + }, + }, + } + ) + ).resolves.toEqual({ data: { viewer: { login: "octo" } } }); + expect(fetchImpl).toHaveBeenCalledOnce(); + const body = JSON.parse( + String(fetchImpl.mock.calls[0]![1]!.body) + ) as Record; + expect(Object.keys(body).sort()).toEqual(["query"]); + }); + afterEach(() => { githubGraphQLRateLimitPolicy.reset(); }); diff --git a/packages/tool-linear-graphql/src/tool.test.ts b/packages/tool-linear-graphql/src/tool.test.ts index 5cc41f9f..9001ce4a 100644 --- a/packages/tool-linear-graphql/src/tool.test.ts +++ b/packages/tool-linear-graphql/src/tool.test.ts @@ -54,6 +54,34 @@ describe("validateLinearGraphQLInvocation", () => { }); describe("executeLinearGraphQL", () => { + it("executes a workspace query while carrying host-side issue context", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ data: { viewer: { id: "user-1" } } }), { + status: 200, + }) + ); + + await expect( + executeLinearGraphQL( + { query: "query Viewer { viewer { id } }" }, + { apiKey: "lin_api_key" }, + fetchImpl as typeof fetch, + { + issue: { + id: "issue-1", + identifier: "ENG-1", + nativeRef: { itemId: "issue-1", projectSlug: "project-a" }, + }, + } + ) + ).resolves.toEqual({ data: { viewer: { id: "user-1" } } }); + expect(fetchImpl).toHaveBeenCalledOnce(); + const body = JSON.parse( + String(fetchImpl.mock.calls[0]![1]!.body) + ) as Record; + expect(Object.keys(body).sort()).toEqual(["query"]); + }); + it("posts a single operation with runtime-managed Authorization", async () => { const fetchImpl = vi .fn() diff --git a/packages/tracker-github/src/tracker-github.test.ts b/packages/tracker-github/src/tracker-github.test.ts index 90ea9895..36ba595a 100644 --- a/packages/tracker-github/src/tracker-github.test.ts +++ b/packages/tracker-github/src/tracker-github.test.ts @@ -58,7 +58,10 @@ describe("GitHub canonical subject adapter hook", () => { await expect( githubProjectTrackerAdapter.executeAgentTool?.( "github_graphql", - { query: "query { viewer { login } }" }, + { + query: "query ActiveIssue($id: ID!) { node(id: $id) { id } }", + variables: { id: "issue-1" }, + }, context ) ).resolves.toMatchObject({ data: { viewer: { login: "octo" } } }); diff --git a/packages/tracker-linear/src/tracker-linear.test.ts b/packages/tracker-linear/src/tracker-linear.test.ts index daaca3b7..c7c0e24c 100644 --- a/packages/tracker-linear/src/tracker-linear.test.ts +++ b/packages/tracker-linear/src/tracker-linear.test.ts @@ -98,7 +98,11 @@ describe("linearTrackerAdapter", () => { await expect( linearTrackerAdapter.executeAgentTool?.( "linear_graphql", - { query: "mutation { issueUpdate { success } }" }, + { + query: + "mutation UpdateIssue($id: String!) { issueUpdate(id: $id) { success } }", + variables: { id: "issue-1" }, + }, context ) ).resolves.toEqual({ data: { issueUpdate: { success: true } } }); diff --git a/packages/worker/src/codex-dynamic-tools.test.ts b/packages/worker/src/codex-dynamic-tools.test.ts index 686cebda..a848c4ac 100644 --- a/packages/worker/src/codex-dynamic-tools.test.ts +++ b/packages/worker/src/codex-dynamic-tools.test.ts @@ -101,7 +101,10 @@ describe("Codex host dynamic tools", () => { const response = await executeCodexDynamicToolCall( "github_graphql", - { query: "query { viewer { login } }" }, + { + query: "query ActiveIssue($id: ID!) { node(id: $id) { id } }", + variables: { id: "issue-730" }, + }, createTrackerToolContext(env), env ); diff --git a/packages/worker/src/worker-protocol.test.ts b/packages/worker/src/worker-protocol.test.ts index 500946f8..25479d53 100644 --- a/packages/worker/src/worker-protocol.test.ts +++ b/packages/worker/src/worker-protocol.test.ts @@ -2906,7 +2906,10 @@ describe("lastEventAt timestamp tracking", () => { params: { tool: "github_graphql", callId: "call-1", - arguments: { query: "query { viewer { login } }" }, + arguments: { + query: "query ActiveIssue($id: ID!) { node(id: $id) { id } }", + variables: { id: "issue-730" }, + }, }, }); @@ -2944,7 +2947,9 @@ describe("lastEventAt timestamp tracking", () => { expect(fetchSpy).toHaveBeenCalledWith( "https://api.github.com/graphql", expect.objectContaining({ - headers: expect.objectContaining({ authorization: "Bearer host-token" }), + headers: expect.objectContaining({ + authorization: "Bearer host-token", + }), }) ); fetchSpy.mockRestore(); diff --git a/test/e2e/claude/claude-docker.spec.ts b/test/e2e/claude/claude-docker.spec.ts index 4834b94b..08256a79 100644 --- a/test/e2e/claude/claude-docker.spec.ts +++ b/test/e2e/claude/claude-docker.spec.ts @@ -177,6 +177,9 @@ global.fetch = async (url, options) => { SYMPHONY_RUN_ID: "run-worker-claude", SYMPHONY_ISSUE_ID: "issue-worker-claude", SYMPHONY_ISSUE_IDENTIFIER: "test-owner/test-repo#254", + SYMPHONY_ISSUE_NATIVE_REF: JSON.stringify({ + itemId: "item-worker-claude", + }), SYMPHONY_ISSUE_STATE: "In progress", SYMPHONY_MAX_TURNS: "2", SYMPHONY_CONTINUATION_GUIDANCE: @@ -593,7 +596,13 @@ async function createTurnLeaseServer(): Promise<{ if (request.method === "POST" && request.url === "/api/v1/tracker-state") { response.writeHead(200, { "content-type": "application/json" }); response.end( - JSON.stringify({ ok: true, outcome: "confirmed", state: "In progress" }) + JSON.stringify({ + ok: true, + outcome: "confirmed", + state: "In progress", + routable: true, + routableReason: null, + }) ); return; }