Phase 1 was about standing up the MCP server and getting the plumbing right. Phase 2 is where things got real. I built a C# Quality Agent that takes a PR diff, fans it out to two separate analysis paths. One path being Roslyn via my own MCP server, and the other is Claude via the official Anthropic SDK. Then, the agent merges both result sets into one schema-locked findings list. This project has no framework wrappers or abstraction layers to hide the wiring from me, just one agent, real tools, and a lot of honest mistakes I'm going to walk through here.

Two Tools, One Schema

The core idea is that Roslyn is deterministic and precise about C# syntax, style, and diagnostics and Claude is good at spotting logic smells, naming issues, and things that are technically valid but clearly wrong. Neither one alone gives you the full picture, so the agent runs both in parallel and merges the results.

Every finding, regardless of whether it came from Roslyn or Claude, gets normalized into the same output schema before it's allowed to touch anything downstream. The schema is file path, line number, severity, message, source. If a finding can't populate that template, it doesn't exist as far as the agent is concerned. Schema enforcement is the first line of defense against LLM drift.

The merge step is where it gets messy. An LLM will confidently restate what a static analyzer already found, just worded differently. Left unchecked, and you end up with duplicate findings that erode trust in the output fast. Engineers will stop reading and using this tool if they see the same issue reported twice. I dedupe by file and line, and when there's a collision, I always trust the deterministic tool. Roslyn's version wins and Claude's version gets dropped which is the right call when you have a ground truth source available.

Roslyn on Diff Fragments: Why Full Files Matter

My first instinct was to feed Roslyn only the changed lines from the diff. My thought was the smaller the input, the faster the analysis, and the lower the cost which seemed like an obvious win. But, it was not a win. Roslyn started generating resolution errors on types and methods that exist perfectly fine in the actual codebase but weren't present in the fragment. It complained about missing references that weren't missing, but were just out of scope of what I gave it.

The fix was fetching full files through GitHub's hosted MCP server instead of slicing the diff to give Roslyn complete context. Then, the resolution errors disappeared. The agent still uses the diff to know which files to pull, but what it actually sends to Roslyn is the full source. This adds some overhead, but the alternative is drowning in false positives and spending more time filtering noise than reading real findings.

Getting those full files through the GitHub MCP server had its own surprise. The calls were failing with cryptic errors until I dug into a live error response and found a reference to session-pinning headers, specifically Mcp-Param headers I hadn't seen documented anywhere. These headers pin the session so GitHub's MCP server knows which authenticated context to use across multiple calls in the same agent run. Once I added them, everything worked. I only found out they were required because the error message was specific enough to point me in the right direction. That's the kind of thing you don't get to read about ahead of time, you just have to run into the issue and troubleshoot.

Grounding LLM Findings Before They Get Out

The most important rule in the agent is that every finding Claude produces gets validated against the parsed diff before it's allowed to exist. If Claude reports an issue on line 47 of PaymentProcessor.cs and that file and line aren't in the diff, the finding is dropped.

This matters more than I expected because Claude occasionally generates findings on lines adjacent to the diff, or on files it inferred were related but weren't changed. Some of those findings might even be correct observations about the codebase, but they're not what the agent is supposed to be reviewing. Letting them through makes the output unpredictable, and an unpredictable code review tool gets ignored fast.

Grounding is also what separates "I prompted it well" from "I built something I can trust." Without constraint, you just have noise with good vocabulary.

Cost Guardrails Are Code, Not Intentions

I want to be direct about this because I've seen a lot of AI projects treat cost control as a config value someone will tune later. That's not how I built this. The guardrails are in the code and they run on every call.

There's an input token cap that limits how much diff context gets sent to Claude. There's an output token cap so a single analysis call can't balloon. There's a context budget at the agent level that tracks cumulative spend across the full run and halts if it's exceeded. And every call logs the token count, input, output, total, so I have a receipt for exactly what the agent spent and why.

// Example: token budget check before each LLM call
if (_contextBudget.Remaining < estimatedInputTokens)
{
    _logger.LogWarning("Context budget exhausted. Skipping Claude analysis for {File}.", filePath);
    return [];
}

var response = await _anthropicClient.Messages.CreateAsync(request);
_contextBudget.Consume(response.Usage.InputTokens + response.Usage.OutputTokens);
_logger.LogInformation("Claude call: {Input} in / {Output} out / {Total} total tokens.",
    response.Usage.InputTokens, response.Usage.OutputTokens,
    response.Usage.InputTokens + response.Usage.OutputTokens);

I think this is the minimum bar for anything that runs automatically on PRs. An agent that can silently rack up costs on a noisy diff isn't production-ready and is a liability.

The Receipts: Sample Reviews and 42 Tests

I committed sample reviews to the repo from the agent output on real PR diffs so anyone reading the code can see what it produces, not just what I claim it produces. If the output is good, it's demonstrable, but if it's bad, it's findable.

There are also 42 tests covering the deduplication logic, schema validation, diff parsing, the grounding filter, and the budget tracking. Not 42 because it's a meaningful number, but because that's how many it took to cover the things I knew could go wrong. Each test is a lesson from something that did go wrong during development.

Building this without a framework forced me to understand every handoff. I know exactly where Roslyn's output goes, where Claude's output goes, how they get merged, and what gets thrown away and why. That understanding is hard to get when a framework is abstracting the interesting parts away from you. I'd recommend the no-framework approach for at least one agent project because you need to understand what they're doing before you can know when they're doing it wrong.

Phase 3 will focus on the evaluation layer. I'll be building a harness that runs the agent against a set of labeled PRs and scores the findings for precision and recall. That will be the real test of whether this thing is useful or just impressive-looking.