Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/host-scoped-tools.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions docs/trackers/github.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions packages/runtime-claude/src/mcp-http-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,54 @@ afterEach(async () => {
});

describe("Claude host MCP HTTP server", () => {
it("freezes the advertised tool specs when the server starts", async () => {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — the freeze test covers one of the change's three halves

I have no concerns with the implementation; I exercised all three halves against a live server and every one behaves correctly. Just noting what the suite would catch on a regression:

Behavior Covered here
tools/list ignores a reloaded spec list
tools/call stays bound to the snapshot entry
structuredClone defeats in-place spec mutation

The second is the one I'd add, since it's the half that actually matters at runtime — a reload could otherwise re-point execution mid-session while tools/list still looks frozen:

const call = await fetch(server.url, { /* …tools/call name: "reloaded_tool" */ });
// must be rejected; "snapshotted_tool" must still execute

For the third: because the test reassigns specs rather than mutating it, it passes with or without structuredClone — so the deep copy is currently unverified. Mutating specs[0]!.name in place instead would cover both the reassignment and the mutation path in one test.

Non-blocking — the behavior is right, this is only about what a future regression would trip over.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged as non-blocking. I left this test unchanged in the narrow pre-merge correction: the implementation and owner smoke already verify execution binding and in-place mutation isolation, while this cycle is limited to the required architecture fix and the cheap adapter payload assertions. The existing snapshot test continues to pin the advertised-spec reload behavior.

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" },
Expand Down
51 changes: 40 additions & 11 deletions packages/runtime-claude/src/mcp-http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<ClaudeMcpHttpServer> {
const sessionToken = randomBytes(32).toString("base64url");
const toolSnapshot = createToolSnapshot(
options.adapters ?? resolveHostToolAdapters(options.env)
);
Comment thread
hojinzs marked this conversation as resolved.
let server: Server | null = createServer(async (request, response) => {
if (!isAuthorized(request, sessionToken)) {
response.writeHead(401, { "content-type": "application/json" });
Expand All @@ -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;
Expand Down Expand Up @@ -71,7 +84,8 @@ export async function startClaudeMcpHttpServer(options: {
async function dispatch(
payload: Record<string, unknown>,
env: NodeJS.ProcessEnv,
context: ClaudeMcpHostContext
context: ClaudeMcpHostContext,
toolSnapshot: readonly HostToolSnapshot[]
): Promise<Record<string, unknown>> {
const id = payload.id ?? null;
if (payload.method === "initialize")
Expand All @@ -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.`);
}
Expand Down Expand Up @@ -128,11 +146,22 @@ async function dispatch(
}
}

function availableTools(
env: NodeJS.ProcessEnv
): Array<Record<string, unknown>> {
return resolveHostToolAdapters(env).flatMap(
(adapter) => adapter.agentToolSpecs?.() ?? []
type HostToolSnapshot = {
spec: AgentToolSpec;
adapter: Pick<OrchestratorTrackerAdapter, "executeAgentTool">;
};

function createToolSnapshot(
adapters: readonly Pick<
OrchestratorTrackerAdapter,
"agentToolSpecs" | "executeAgentTool"
>[]
): readonly HostToolSnapshot[] {
return adapters.flatMap((adapter) =>
(adapter.agentToolSpecs?.() ?? []).map((spec) => ({
spec: structuredClone(spec),
adapter,
}))
);
}

Expand Down
34 changes: 34 additions & 0 deletions packages/tool-github-graphql/src/tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,40 @@ describe("resolveGitHubGraphQLToken", () => {
});

describe("executeGitHubGraphQL", () => {
it("executes a repository query while carrying host-side issue context", async () => {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — the test's name promises more than its assertions deliver (same for the Linear twin at tool-linear-graphql/src/tool.test.ts:57)

This is a genuine improvement on what it replaced — it uses a realistic nativeRef and proves guard removal doesn't block a legitimate call, which was its stated purpose. But context is passed and then never asserted on. The two assertions are "resolves to the payload" and "fetch called once", both of which hold identically if the 4th argument is dropped entirely.

The invariant worth pinning is the one the contract states in prose and nothing tests:

nativeRef remains host-internal and is never sent as an extra GraphQL payload field.

I verified it holds today — a repo query under a full GitHub nativeRef (including linkedPullRequests URLs and branch names) produced a body with keys query,variables only, and no context string anywhere in it. Two lines pin it:

const body = JSON.parse(String(fetchImpl.mock.calls[0][1]!.body));
expect(Object.keys(body).sort()).toEqual(["query"]);

Worth having because this is a claim a reader will treat as load-bearing, and it is currently held up by nothing but the absence of code that would break it.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 49eae3fe for both GitHub and Linear. Each context-carrying execution test now parses the outbound request body and asserts its only top-level key is query, pinning that normalized context and nativeRef are not serialized into provider payloads.

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<string, unknown>;
expect(Object.keys(body).sort()).toEqual(["query"]);
});

afterEach(() => {
githubGraphQLRateLimitPolicy.reset();
});
Expand Down
28 changes: 28 additions & 0 deletions packages/tool-linear-graphql/src/tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
expect(Object.keys(body).sort()).toEqual(["query"]);
});

it("posts a single operation with runtime-managed Authorization", async () => {
const fetchImpl = vi
.fn()
Expand Down
5 changes: 4 additions & 1 deletion packages/tracker-github/src/tracker-github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } } });
Expand Down
6 changes: 5 additions & 1 deletion packages/tracker-linear/src/tracker-linear.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } } });
Expand Down
5 changes: 4 additions & 1 deletion packages/worker/src/codex-dynamic-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down
9 changes: 7 additions & 2 deletions packages/worker/src/worker-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
},
},
});

Expand Down Expand Up @@ -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();
Expand Down
11 changes: 10 additions & 1 deletion test/e2e/claude/claude-docker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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;
}
Expand Down
Loading