17 min readMehdi Hadeli

Repo Registry for GitHub Copilot CLI: A Dedicated MCP Server for Multi-Repo .NET Work

Repo Registry for GitHub Copilot CLI: A Dedicated MCP Server for Multi-Repo .NET Work

Part of a three-post series on GitHub Copilot CLI for multi-repo .NET work. If you want the setup before this article, start with Multi-Repo Workspace Setup for GitHub Copilot CLI: Context Across .NET Service Boundaries. The next piece is Per-Service Copilot Instructions for GitHub Copilot CLI: Keep Service Rules Close to the Repo.

Runnable sample: samples/repo-registry. It now uses a dedicated RepoRegistry.McpServer sample with a separate registry service, Git state reader, and MCP-facing tool layer.

Multi-repo work gets awkward long before the code itself gets difficult.

The awkward part is usually not the implementation. It is the workspace state around the implementation. Which repo owns the contract. Which service is already on a feature branch. Which worker is still on the old payload shape. Which frontend repo is dirty before you even start.

That is exactly the sort of context an agent needs before it can help in a useful way.

GitHub Copilot CLI can reason well once it has the right facts. The problem is getting those facts into the session without repeating the same discovery steps every time.

That is why I think the repo registry should not stop at a helper script or a console command. For agent-heavy workflows, it should be a dedicated MCP server.

Not a vague tooling layer. Not a workspace note with a few examples. A real server at the workspace level, with a narrow tool surface, live Git-backed state, and a clean service layer behind it.

Static Workspace Notes Are Still Not Enough

A workspace note is still useful. It tells you what belongs together and what usually changes in sequence. It helps a human, and it helps the assistant.

But it is not the right place to answer live questions such as these:

  • which repos are dirty right now
  • which repos are on non-default branches
  • which services depend on Shared.Contracts
  • where Orders.Api actually lives on disk
  • whether AdminPortal.Angular already changed the same screen

That is state, not documentation.

Once you treat it that way, the shape of the solution gets clearer. You do not want a bigger markdown file. You want a server that can answer questions at query time.

Why a Dedicated MCP Server Is the Better Shape

You can absolutely start with a console app. In fact, I did. That is a fine first step when you are just proving the idea.

The problem is that a console app is still built for a human sitting in a terminal. An MCP server is built for tool calls.

That distinction matters.

If Copilot CLI or another agent-capable client needs workspace discovery, you want explicit tools such as these:

  • list_repos
  • get_workspace_status
  • get_repo_info
  • find_dependents
  • search_across_repos

That tool surface is much cleaner than asking the agent to scrape console output or infer structure from a one-off shell command.

It also forces a healthier design. Once you know a server will expose these operations, you naturally separate the registry logic from the transport layer.

That is the part I care about most.

Summary: Which Approach Should You Use?

This is the practical comparison for the whole series.

The shared workspace layout is the best first step for almost any multi-repo team. Use it when the main problem is missing cross-repo context. It gives Copilot CLI the system map: what repos exist, which one owns the contract, and what order changes usually follow.

Per-service instruction files are the next layer when the main problem is bad local guesses inside a repo. Use them when one service has important invariants, generated-code boundaries, or shortcuts the assistant should avoid. That is usually the cheapest way to improve safety.

The repo registry is the better choice when the missing piece is live state. Use it when the assistant needs to know which repos are dirty, which branch each service is on, where a repo lives on disk, or which repos depend on a shared package right now. That is where a dedicated MCP server starts paying for itself.

So my recommendation is not to choose one of these patterns in isolation.

  • start with a shared workspace root
  • add per-service instructions once repo-local rules matter
  • add a repo registry when live discovery becomes a repeated bottleneck

That sequence matches the real progression for most teams. The workspace gives the map, repo instructions add local judgment, and the registry adds live facts.

If you want the shortest version, it is this:

  • start with the shared workspace root because it is the best first step
  • add per-service instructions because they are the best safety improvement
  • add the repo registry when live discovery and workspace state become the real bottleneck

The Design I Would Actually Build

If I were writing this for a real .NET workspace, I would split it into normal service objects and keep the MCP host thin.

At a minimum, I want these pieces:

  • RepoRegistryService as the deterministic core
  • GitRepositoryStateReader for live Git metadata
  • RepoRegistryTools as the tool-facing adapter
  • RepoRegistry.McpServer as the dedicated server process

That gives you a design where the server is easy to host and the registry logic is easy to test.

The rule is simple: the server should expose tools. It should not own the business logic for repository discovery.

That business logic belongs in normal C# classes.

Where Microsoft Agent Framework Fits

This is the part that gets muddy quickly if you are not deliberate.

After going back through the official docs, I think there are really two different stories here, and they should not be blended together.

Hosted MCP tools are the Foundry case. That is where an agent points at a remote MCP endpoint and the provider runtime manages the tool call path.

This sample is not that.

For a workspace repo registry, the right fit is the local MCP pattern: a stdio server running in your environment, exposing a small tool surface from normal C# code. In .NET, that means using the official MCP server host and AI function abstractions rather than hand-rolling a fake server loop.

So for this sample, I would use Microsoft Agent Framework and the MCP SDK like this:

That means:

  • the registry service owns repository metadata and Git-backed state
  • the MCP server owns stdio transport and tool exposure through the documented host APIs
  • the AI function layer describes the tools in a shape MCP clients can consume cleanly
  • hosted MCP configuration stays a separate concern for remote Foundry-backed agents
  • optional AI summarization sits above the facts, not inside them

That separation keeps the architecture honest.

The registry tells you what is true. The agent runtime tells you how tools are surfaced. An AI layer can summarize the output afterward if you want, but it should not invent repo relationships or working tree state.

I think that line matters a lot. The moment the assistant starts generating the dependency graph instead of reading it, you have built the wrong thing.

The Tool Surface Should Stay Small

It is tempting to make the server smart and keep adding features until it starts acting like a build system, a code search engine, and a release coordinator at the same time.

I would not do that.

The useful first version is small.

For me, the core tool surface looks like this:

list_repos

Returns the repos the workspace knows about, where they live, what stack they use, and whether they exist locally.

get_workspace_status

Returns live Git-backed status for each repo, including branch and dirty state.

get_repo_info

Returns the metadata and live state for one repo.

find_dependents

Returns direct and recursive dependents for blast-radius analysis.

search_across_repos

Returns matches across known repos, ideally with an optional file-pattern filter.

That is enough to support a surprising amount of agent work.

What This Changes for Copilot CLI

Once the registry is an MCP server instead of just a helper script, the interaction gets more disciplined.

At the start of a session, the agent can ask for live workspace state instead of waiting for you to paste it.

Before a breaking contract change, it can ask for dependents instead of making broad guesses.

Before a rename or signature change, it can search across known repos instead of treating the workspace like one giant, unbounded file tree.

That is not magic. It is just better service discovery.

The benefit is that your prompts stop carrying so much operational setup.

Instead of saying, "Here are the repos, here are the likely consumers, here is the branch state, now help me plan the change," you can let the tool layer provide that first.

A Concrete Workflow

Suppose you add PreferredLanguage to a shared customer summary contract.

In a typical workspace, the likely sequence is:

  1. update Shared.Contracts
  2. update Orders.Api
  3. check Billing.Worker
  4. update AdminPortal.Angular
  5. update infrastructure only if rollout needs new configuration

With a dedicated MCP server, the agent can make that sequence concrete.

First it calls get_workspace_status and sees which repos are already dirty.

Then it calls find_dependents Shared.Contracts and gets the direct and recursive blast radius.

Then it calls search_across_repos CustomerSummaryResponse and sees where the type is actually used.

That is much better than hoping the assistant remembered the workspace note correctly.

The Sample I Prefer Now

I revised the sample in this repo to match that server-first design.

The companion sample at samples/repo-registry no longer centers on a single console-shaped file. It now uses a dedicated RepoRegistry.McpServer project with a few normal classes behind it:

  • RepoRegistryService loads the registry and answers deterministic questions
  • GitRepositoryStateReader reads live Git state
  • RepoRegistryTools defines the tool-facing operations
  • RepoRegistryServer hosts the official stdio MCP server boundary
  • ProgramEntry keeps command parsing and server startup thin

That structure is much closer to what I would want in a real codebase.

The sample still stays runnable in this workspace. For testability, it supports a tool-invocation mode from the command line, which exercises the same tool handlers the MCP host would expose.

For example:

dotnet run --project ./samples/repo-registry/src/RepoRegistry.McpServer -- --registry ./samples/repo-registry/sample-data/repos.json describe
dotnet run --project ./samples/repo-registry/src/RepoRegistry.McpServer -- --registry ./samples/repo-registry/sample-data/repos.json serve-stdio
dotnet run --project ./samples/repo-registry/src/RepoRegistry.McpServer -- --registry ./samples/repo-registry/sample-data/repos.json --tool list_repos
dotnet run --project ./samples/repo-registry/src/RepoRegistry.McpServer -- --registry ./samples/repo-registry/sample-data/repos.json --tool get_workspace_status
dotnet run --project ./samples/repo-registry/src/RepoRegistry.McpServer -- --registry ./samples/repo-registry/sample-data/repos.json --tool get_repo_info Shared.Contracts
dotnet run --project ./samples/repo-registry/src/RepoRegistry.McpServer -- --registry ./samples/repo-registry/sample-data/repos.json --tool find_dependents Shared.Contracts

Each command is there to prove a different slice of the server. serve-stdio is the real MCP server path. describe is the quick smoke test that the registry loads at all. list_repos checks the static registry data. get_workspace_status exercises the live Git state reader. get_repo_info shows a single-repo lookup, and find_dependents proves the dependency traversal path. Together, they demonstrate that the sample is not just a passive data file; it is an executable tool surface.

That gives you a dedicated server project, an object-oriented core, and a testable tool surface without tying the core logic to one transport implementation.

The Actual MCP Server Code

The article should show the real shape, not just the command line around it. These are the core excerpts from the sample that make the server work.

The important design correction is this: the sample no longer pretends to be an MCP server with a custom switch statement. The real serve-stdio path now uses the official MCP host pattern from the .NET docs.

1. Bootstrap the registry service and server

This is the entry path in ProgramEntry.cs:

using System.Text.Json;

namespace RepoRegistry.McpServer;

internal static class ProgramEntry
{
  public static async Task<int> RunAsync(string[] args, TextWriter stdout, TextWriter stderr)
  {
    try
    {
      var parsed = CommandLineOptions.Parse(args);
      var registryService = RepoRegistryService.Load(
        parsed.RegistryPath,
        new GitRepositoryStateReader()
      );
      var tools = new RepoRegistryTools(registryService);
      var server = new RepoRegistryServer("repo-registry", tools);

      if (parsed.Command == ServerCommand.Describe)
      {
        await stdout.WriteLineAsync(
          JsonSerializer.Serialize(
            server.Describe(),
            new JsonSerializerOptions
            {
              WriteIndented = true,
              PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
            }
          )
        );
        return 0;
      }

      if (parsed.Command == ServerCommand.ServeStdio)
      {
        await server.RunAsync();
        return 0;
      }

      var result = server.InvokeForCli(parsed.ToolName!, parsed.ToolArguments);
      await stdout.WriteLineAsync(
        JsonSerializer.Serialize(
          result,
          new JsonSerializerOptions
          {
            WriteIndented = true,
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
          }
        )
      );
      return 0;
    }
    catch (Exception ex)
    {
      await stderr.WriteLineAsync(ex.Message);
      return 1;
    }
  }
}

This is the boundary I want in a sample. CommandLineOptions.Parse handles input shape, RepoRegistryService.Load builds the deterministic core from the registry file, GitRepositoryStateReader injects live Git state, RepoRegistryTools exposes the use cases, and RepoRegistryServer owns the actual stdio host. The entrypoint is coordinating objects, not burying repo logic inside Main.

2. Host the server with the documented stdio APIs

This is the MCP-facing host setup from RepoRegistryServer.cs:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace RepoRegistry.McpServer;

internal sealed class RepoRegistryServer
{
  private readonly string serverName;
  private readonly RepoRegistryTools tools;

  public RepoRegistryServer(string serverName, RepoRegistryTools tools)
  {
    this.serverName = serverName;
    this.tools = tools;
  }

  public Task RunAsync(CancellationToken cancellationToken = default)
  {
    HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(settings: null);
    builder
      .Services.AddMcpServer()
      .WithStdioServerTransport()
      .WithTools(tools.CreateMcpServerTools());

    return builder.Build().RunAsync(cancellationToken);
  }
}

This is the key fix. Instead of simulating MCP behavior in application code, the sample now uses AddMcpServer(), WithStdioServerTransport(), and WithTools(...) to host a real MCP server over stdio. That is the local MCP shape the official docs describe, and it is the right one for a workspace-scoped repo registry.

3. Wrap the registry operations as typed MCP tools

This is the tool registration layer from RepoRegistryTools.cs:

using Microsoft.Extensions.AI;
using ModelContextProtocol.Server;

namespace RepoRegistry.McpServer;

internal sealed class RepoRegistryTools
{
  private readonly RepoRegistryService registryService;

  public RepoRegistryTools(RepoRegistryService registryService)
  {
    this.registryService = registryService;
  }

  public ListReposResponse ListRepos() => registryService.ListRepos();

  public WorkspaceStatusResponse GetWorkspaceStatus() => registryService.GetWorkspaceStatus();

  public RepoInfoResponse GetRepoInfo(string repo) => registryService.GetRepoInfo(repo);

  public DependentsResponse FindDependents(string repo) => registryService.FindDependents(repo);

  public SearchAcrossReposResponse SearchAcrossRepos(string pattern, string? filePattern = null) =>
    registryService.SearchAcrossRepos(pattern, filePattern);

  public IReadOnlyList<McpServerTool> CreateMcpServerTools() =>
    [
      McpServerTool.Create(
        AIFunctionFactory.Create(
          ListRepos,
          new AIFunctionFactoryOptions
          {
            Name = "list_repos",
            Description = "List the repositories known to the workspace registry, including their stack and live Git state.",
          }
        )
      ),
      McpServerTool.Create(
        AIFunctionFactory.Create(
          GetWorkspaceStatus,
          new AIFunctionFactoryOptions
          {
            Name = "get_workspace_status",
            Description = "Get live Git-backed status for all registered repositories.",
          }
        )
      ),
    ];
}

This is where the Agent Framework-style abstraction starts paying off. The repo registry methods stay normal C# methods with typed results, AIFunctionFactory.Create(...) describes them as callable tools, and McpServerTool.Create(...) hands them to the MCP server host. That is a much healthier boundary than hard-coding protocol behavior into one server class.

4. Put the real repo logic in a normal service

This is the heart of the sample from RepoRegistryService.cs:

using System.IO.Enumeration;
using System.Text.Json;

namespace RepoRegistry.McpServer;

internal sealed class RepoRegistryService
{
  private readonly RegistryDocument document;
  private readonly IRepositoryStateReader repositoryStateReader;

  private RepoRegistryService(
    RegistryDocument document,
    IRepositoryStateReader repositoryStateReader
  )
  {
    this.document = document;
    this.repositoryStateReader = repositoryStateReader;
  }

  public static RepoRegistryService Load(
    string registryPath,
    IRepositoryStateReader repositoryStateReader
  )
  {
    var json = File.ReadAllText(registryPath);
    var document =
      JsonSerializer.Deserialize<RegistryDocument>(
        json,
        new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
      ) ?? throw new InvalidOperationException("Failed to parse registry file.");

    return new RepoRegistryService(document, repositoryStateReader);
  }

  public ListReposResponse ListRepos() =>
    new(document.Repos.Count, document.Repos.Select(CreateSummary).ToArray());

  public RepoInfoResponse GetRepoInfo(string name)
  {
    var repo = FindRepo(name);
    return new RepoInfoResponse(
      repo.Name,
      repo.Path,
      repo.Stack,
      repo.DependsOn.ToArray(),
      repo.Dependents.ToArray(),
      repositoryStateReader.Read(repo)
    );
  }

  public DependentsResponse FindDependents(string name)
  {
    var repo = FindRepo(name);
    var allDependents = new List<string>();
    TraverseDependents(
      repo.Name,
      new HashSet<string>(StringComparer.OrdinalIgnoreCase),
      allDependents
    );

    return new DependentsResponse(repo.Name, repo.Dependents.ToArray(), allDependents.ToArray());
  }
}

This is the part that makes the sample teach the right lesson. The registry is loaded once from JSON, every repo result can be enriched with live state through repositoryStateReader.Read(repo), and the dependency walk stays in a normal service method instead of getting spread across host startup or MCP wiring. The service owns the facts. The MCP layer only exposes them.

A Better C# Shape Than One Giant File

I do not like registry samples that collapse everything into one entrypoint. That is the fastest way to make a sample look simple while teaching a structure nobody should keep.

For this kind of server, I want the boundaries to be obvious.

The registry service should be able to answer questions without caring whether the caller is:

  • a local smoke test
  • an MCP host
  • a CLI wrapper
  • a future web API

That is why I prefer a shape like this:

src/
  RepoRegistry.McpServer/
    Program.cs
    ProgramEntry.cs
    CommandLineOptions.cs
    RepoRegistryServer.cs
    RepoRegistryTools.cs
    RepoRegistryService.cs
    GitRepositoryStateReader.cs
    RepoModels.cs

This layout is worth calling out because each file answers one question only. Program.cs and ProgramEntry.cs deal with startup, CommandLineOptions.cs handles input shape, RepoRegistryService.cs owns deterministic registry logic, GitRepositoryStateReader.cs owns live Git inspection, and the server and tool files sit at the transport edge. That separation is what lets the same core logic work for a CLI probe today and an MCP host tomorrow.

That is still small. It is also much easier to maintain than a single file that mixes parsing, Git calls, dependency traversal, JSON serialization, and tool exposure in one place.

Microsoft Agent Framework Should Stay at the Edge

I want to say this plainly because it is easy to get wrong.

If you are using Microsoft Agent Framework for the MCP-facing layer, keep it at the edge of the application.

Do not bury MAF-specific concepts inside the registry service.

Do not make RepoRegistryService depend on how tool calls are transported.

Do not make the Git reader depend on agent concepts at all.

The framework should host and expose the tools. The registry should answer questions. That is it.

Once those responsibilities get mixed together, the server becomes harder to test and harder to trust.

Why This Is Better Than a Bigger Workspace File

Some teams try to solve this problem by putting more and more information into a workspace markdown file.

That helps until it does not.

The static file can tell you what is supposed to exist. It cannot reliably tell you what is true right now.

A dedicated MCP server is better for exactly that reason. It answers runtime questions from live state.

That is what an agent needs when it is planning edits across several repos.

Keep the Registry Scope Disciplined

Even with a dedicated server, I would keep the scope tight.

The registry should know:

  • repo identity
  • repo path
  • declared dependencies
  • live Git state
  • a small number of high-value search operations

It should not try to become CI, release orchestration, package management, and code review all at once.

Useful tools usually get brittle when they stop respecting their own boundary.

Closing Thought

For human-driven workflows, a console helper can be enough for a while.

For agent-driven workflows, I think the better endpoint is a dedicated MCP server.

That gives GitHub Copilot CLI a clean way to ask the questions that actually matter in a multi-repo workspace: what exists, what changed, what depends on what, and where the risky edges are.

If you keep the server thin, the registry logic deterministic, and the tool surface small, the whole setup stays understandable.

That is the real goal.

Not a flashy agent demo. Just a repo registry that answers the right questions, in the right place, with live data, through a server shape the rest of the tooling stack can actually use.

So if the question is which one to use, my answer is simple.

Use the workspace approach first. Add per-service instructions next. Add the repo registry when the missing piece is live discovery.

That gives you the right foundation, the right guardrails, and then the right tool layer.