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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.0" />
<PackageVersion Include="OsmSharp" Version="6.2.0" />
<PackageVersion Include="PublicApiGenerator" Version="11.5.4" />
Expand Down
2 changes: 2 additions & 0 deletions LICENSING.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ Rationale: this tier protects two moats at once — an extensible SDK and a sing
| OPEN | `src/Nexo.Mcp.Server/Nexo.Mcp.Server.csproj` |
| OPEN | `src/Nexo.Mcp.Server.Host/Nexo.Mcp.Server.Host.csproj` |
| OPEN | `src/Nexo.Mcp.Server.Tests/Nexo.Mcp.Server.Tests.csproj` |
| OPEN | `src/Nexo.Mcp.Client/Nexo.Mcp.Client.csproj` |
| OPEN | `src/Nexo.Mcp.Client.Tests/Nexo.Mcp.Client.Tests.csproj` |
| OPEN | `src/Nexo.Tests.Application/Nexo.Tests.Application.csproj` |
| OPEN | `src/Nexo.Tests.BackgroundAgents/Nexo.Tests.BackgroundAgents.csproj` |
| OPEN | `src/Nexo.Tests.Contracts/Nexo.Tests.Contracts.csproj` |
Expand Down
30 changes: 30 additions & 0 deletions Nexo.sln
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.Mcp.Server.Host", "src
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.Mcp.Server.Tests", "src\Nexo.Mcp.Server.Tests\Nexo.Mcp.Server.Tests.csproj", "{D01611B1-E0C8-4F44-8D9E-768CF3BD7394}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.Mcp.Client", "src\Nexo.Mcp.Client\Nexo.Mcp.Client.csproj", "{1877B520-4ACB-43DB-9F34-C06BA8DFCB7D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nexo.Mcp.Client.Tests", "src\Nexo.Mcp.Client.Tests\Nexo.Mcp.Client.Tests.csproj", "{B47FAA09-BB17-4A63-913C-438E1DD699B6}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -1017,6 +1021,30 @@ Global
{D01611B1-E0C8-4F44-8D9E-768CF3BD7394}.Release|x64.Build.0 = Release|Any CPU
{D01611B1-E0C8-4F44-8D9E-768CF3BD7394}.Release|x86.ActiveCfg = Release|Any CPU
{D01611B1-E0C8-4F44-8D9E-768CF3BD7394}.Release|x86.Build.0 = Release|Any CPU
{1877B520-4ACB-43DB-9F34-C06BA8DFCB7D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1877B520-4ACB-43DB-9F34-C06BA8DFCB7D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1877B520-4ACB-43DB-9F34-C06BA8DFCB7D}.Debug|x64.ActiveCfg = Debug|Any CPU
{1877B520-4ACB-43DB-9F34-C06BA8DFCB7D}.Debug|x64.Build.0 = Debug|Any CPU
{1877B520-4ACB-43DB-9F34-C06BA8DFCB7D}.Debug|x86.ActiveCfg = Debug|Any CPU
{1877B520-4ACB-43DB-9F34-C06BA8DFCB7D}.Debug|x86.Build.0 = Debug|Any CPU
{1877B520-4ACB-43DB-9F34-C06BA8DFCB7D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1877B520-4ACB-43DB-9F34-C06BA8DFCB7D}.Release|Any CPU.Build.0 = Release|Any CPU
{1877B520-4ACB-43DB-9F34-C06BA8DFCB7D}.Release|x64.ActiveCfg = Release|Any CPU
{1877B520-4ACB-43DB-9F34-C06BA8DFCB7D}.Release|x64.Build.0 = Release|Any CPU
{1877B520-4ACB-43DB-9F34-C06BA8DFCB7D}.Release|x86.ActiveCfg = Release|Any CPU
{1877B520-4ACB-43DB-9F34-C06BA8DFCB7D}.Release|x86.Build.0 = Release|Any CPU
{B47FAA09-BB17-4A63-913C-438E1DD699B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B47FAA09-BB17-4A63-913C-438E1DD699B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B47FAA09-BB17-4A63-913C-438E1DD699B6}.Debug|x64.ActiveCfg = Debug|Any CPU
{B47FAA09-BB17-4A63-913C-438E1DD699B6}.Debug|x64.Build.0 = Debug|Any CPU
{B47FAA09-BB17-4A63-913C-438E1DD699B6}.Debug|x86.ActiveCfg = Debug|Any CPU
{B47FAA09-BB17-4A63-913C-438E1DD699B6}.Debug|x86.Build.0 = Debug|Any CPU
{B47FAA09-BB17-4A63-913C-438E1DD699B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B47FAA09-BB17-4A63-913C-438E1DD699B6}.Release|Any CPU.Build.0 = Release|Any CPU
{B47FAA09-BB17-4A63-913C-438E1DD699B6}.Release|x64.ActiveCfg = Release|Any CPU
{B47FAA09-BB17-4A63-913C-438E1DD699B6}.Release|x64.Build.0 = Release|Any CPU
{B47FAA09-BB17-4A63-913C-438E1DD699B6}.Release|x86.ActiveCfg = Release|Any CPU
{B47FAA09-BB17-4A63-913C-438E1DD699B6}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -1095,6 +1123,8 @@ Global
{003FFC1F-C2A4-4A5A-BE4C-C3A3818BFA97} = {9D4F8B1A-0B6E-4A3E-8A6A-0DE12C7C6E2F}
{97B79E4E-E40C-4229-8368-DC7993FC14BE} = {9D4F8B1A-0B6E-4A3E-8A6A-0DE12C7C6E2F}
{D01611B1-E0C8-4F44-8D9E-768CF3BD7394} = {9D4F8B1A-0B6E-4A3E-8A6A-0DE12C7C6E2F}
{1877B520-4ACB-43DB-9F34-C06BA8DFCB7D} = {9D4F8B1A-0B6E-4A3E-8A6A-0DE12C7C6E2F}
{B47FAA09-BB17-4A63-913C-438E1DD699B6} = {9D4F8B1A-0B6E-4A3E-8A6A-0DE12C7C6E2F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {12345678-1234-1234-1234-123456789ABC}
Expand Down
39 changes: 37 additions & 2 deletions docs/architecture/ProtocolIntegration-MCP-A2A.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,45 @@ by the operator explicitly in their own host.
mapping helper deliberately refuses the commercial endpoint's `AllowAnonymous()` pattern and maps
nothing at all while disabled.

## Phase 2 (this phase): Nexo as MCP client

Projects: `src/Nexo.Mcp.Client/` (+ `src/Nexo.Mcp.Client.Tests/`, whose round-trip suite runs a
real `McpClient` against the Phase-1 server bridge over in-memory pipes).

External MCP servers are configured under `Nexo:Mcp:Client` (streamable HTTP only in v1) and
their tools surface as `ITool`s through the `IToolSource` seam:

1. **Connect + pin at startup.** `McpClientConnectionManager` (a hosted service) dials each
configured server, lists tools (optionally narrowed by a per-server `AllowedTools` list), and
pins each definition — name, description, raw JSON schema — for the process lifetime.
2. **Namespaced ids.** Proxies register as `mcp:{server}:{tool}`, so a remote server can never
shadow a native tool id (`CapabilityRegistry` registration is last-wins by id).
3. **Drift faults, never follows.** A periodic re-list compares against the pins; a changed or
vanished definition marks the tool faulted (withdrawn from toolboxes, calls fail with the
reason) until a restart re-pins. Remote servers do not get to redefine an agent's tools
mid-flight.
4. **Failures degrade, not crash.** An unreachable server contributes zero tools; remote
`isError` results and transport failures come back as error payloads (repo tool convention),
visible to the calling model.
5. **Secrets via environment.** Per-server API keys are named by env var
(`ApiKeyHeader`/`ApiKeyEnvVar` pair); a referenced-but-unset variable fails startup.

Toolboxes pick proxies up through `Nexo.Abstractions.IToolSource`:
`RepoFsToolboxFactory.CreateMinimal/CreateWithBuildTest` accept `extraTools`, and
`SelfExtendRunnerAdapter` folds all DI-registered `IToolSource`s in per cycle. Hosts wire it with
`services.AddNexoMcpClient(configuration)`.

```bash
Nexo__Mcp__Client__Enabled=true
Nexo__Mcp__Client__Servers__0__Name=github
Nexo__Mcp__Client__Servers__0__Url=https://mcp.example.com/mcp
Nexo__Mcp__Client__Servers__0__ApiKeyHeader=Authorization
Nexo__Mcp__Client__Servers__0__ApiKeyEnvVar=GITHUB_MCP_TOKEN
Nexo__Mcp__Client__Servers__0__AllowedTools__0=search_issues
```

## Later phases (planned)

- **MCP client** — external MCP servers' tools proxied as namespaced `ITool`s
(`mcp:{server}:{tool}`) with pinned descriptions, HTTP transports only in v1.
- **A2A server** — agent-card projection of allowlisted `AgentCard`s; task execution through the
gRPC-facade pattern (barrier identity resolution → registry-validated agent name →
`IAgentTransport.SendAsync`); synchronous terminal tasks in v1.
Expand Down
19 changes: 19 additions & 0 deletions src/Nexo.Abstractions/IToolSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace Nexo.Abstractions;

/// <summary>
/// A provider of dynamically discovered tools.
///
/// Complements direct <see cref="ITool"/> registration for tools whose set is only known after
/// startup work has completed — e.g. tools proxied from remote MCP servers, which are discovered
/// by connecting and listing. Toolbox factories fold sources in at toolbox-build time, so a
/// source's tools become visible to agents without the source being constructible at DI
/// registration time.
///
/// Implementations must be safe to call repeatedly and should return a stable snapshot; an
/// empty list is the correct fail-closed answer while discovery is incomplete or has failed.
/// </summary>
public interface IToolSource
{
/// <summary>Returns the currently available tools (possibly empty, never null).</summary>
IReadOnlyList<ITool> GetTools();
}
25 changes: 23 additions & 2 deletions src/Nexo.BackgroundAgents.HostRunners/RepoFsToolboxFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,20 @@ internal static class RepoFsToolboxFactory
/// Creates the original minimal toolbox (read/list/write/search_replace).
/// Preserved for callers that don't want build/test capability surface.
/// </summary>
public static (CapabilityRegistry tools, PolicyEngine policies) CreateMinimal()
/// <param name="extraTools">Additional tools folded in after the built-ins (e.g. proxies from
/// <see cref="IToolSource"/> providers such as remote MCP servers). Registered last, so an
/// extra tool with a clashing id wins per <see cref="CapabilityRegistry.Register"/> semantics —
/// providers are expected to namespace their ids (e.g. <c>mcp:server:tool</c>).</param>
public static (CapabilityRegistry tools, PolicyEngine policies) CreateMinimal(
IEnumerable<ITool>? extraTools = null)
{
var tools = new CapabilityRegistry();
tools.Register(new RepoFsListTool());
tools.Register(new RepoFsReadTool());
tools.Register(new RepoFsWriteTool());
tools.Register(new RepoFsSearchReplaceTool());
tools.Register(new TileMapRenderTool());
RegisterExtraTools(tools, extraTools);

var policies = new PolicyEngine(new IPolicy[]
{
Expand Down Expand Up @@ -71,14 +77,16 @@ public static (CapabilityRegistry tools, PolicyEngine policies, BuildTestBudget
IAggressivenessModeStore? modeStore = null,
ICertificationRecordStore? certificationStore = null,
IBackgroundAgentRegistry? agentRegistry = null,
IDataSensitivityRegistry? sensitivityRegistry = null)
IDataSensitivityRegistry? sensitivityRegistry = null,
IEnumerable<ITool>? extraTools = null)
{
var tools = new CapabilityRegistry();
tools.Register(new RepoFsListTool());
tools.Register(new RepoFsReadTool());
tools.Register(new RepoFsWriteTool());
tools.Register(new RepoFsSearchReplaceTool());
tools.Register(new TileMapRenderTool());
RegisterExtraTools(tools, extraTools);

ITool buildTool = new DotnetBuildTool();
ITool testTool = new DotnetTestTool();
Expand Down Expand Up @@ -148,4 +156,17 @@ public static (CapabilityRegistry tools, PolicyEngine policies, BuildTestBudget

return (tools, policies, budget);
}

private static void RegisterExtraTools(CapabilityRegistry tools, IEnumerable<ITool>? extraTools)
{
if (extraTools is null)
{
return;
}

foreach (var tool in extraTools)
{
tools.Register(tool);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ public SelfExtendRunnerAdapter(
IAggressivenessModeStore? modeStore = null,
ICertificationRecordStore? certificationStore = null,
Lazy<IBackgroundAgentRegistry>? agentRegistry = null,
IDataSensitivityRegistry? sensitivityRegistry = null)
IDataSensitivityRegistry? sensitivityRegistry = null,
IEnumerable<IToolSource>? toolSources = null)
{
_model = model ?? throw new ArgumentNullException(nameof(model));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
Expand All @@ -88,8 +89,11 @@ public SelfExtendRunnerAdapter(
_certificationStore = certificationStore ?? FailClosedCertificationRecordStore.Instance;
_agentRegistry = agentRegistry;
_sensitivityRegistry = sensitivityRegistry;
_toolSources = toolSources;
}

private readonly IEnumerable<IToolSource>? _toolSources;

/// <inheritdoc />
public async Task<SelfExtendRunResult> RunAsync(string repoRoot, CancellationToken cancellationToken = default)
=> await RunAsync(repoRoot, objective: null, agentName: null, modelProvider: null, modelName: null, cancellationToken).ConfigureAwait(false);
Expand Down Expand Up @@ -177,7 +181,10 @@ public async Task<SelfExtendRunResult> RunAsync(
// .Value here, not in the constructor: by the time a self-extend run
// executes, the registry is fully built and resolving it is a no-op.
agentRegistry: _agentRegistry?.Value,
sensitivityRegistry: _sensitivityRegistry);
sensitivityRegistry: _sensitivityRegistry,
// Sources are snapshotted per cycle: discovery (e.g. remote MCP servers)
// completes after DI build, so the tool set is only known here.
extraTools: _toolSources?.SelectMany(s => s.GetTools()));
budget.Reset();
// Register objective lifecycle tools only when a store is wired — the
// tools cannot operate without one and must not be advertised to the LLM
Expand Down
100 changes: 100 additions & 0 deletions src/Nexo.Mcp.Client.Tests/McpJsonBridgeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
using System.Text.Json;
using FluentAssertions;
using ModelContextProtocol.Protocol;
using Xunit;

namespace Nexo.Mcp.Client.Tests;

public sealed class McpJsonBridgeTests
{
private static JsonElement Parse(string json)
{
using var doc = JsonDocument.Parse(json);
return doc.RootElement.Clone();
}

[Fact]
public void Object_arguments_become_a_dictionary_of_elements()
{
var args = Parse("""{"path":"a.txt","count":3}""");

var dictionary = McpJsonBridge.ToArgumentDictionary(args);

dictionary.Should().HaveCount(2);
((JsonElement)dictionary["path"]!).GetString().Should().Be("a.txt");
((JsonElement)dictionary["count"]!).GetInt32().Should().Be(3);
}

[Theory]
[InlineData("42")]
[InlineData("\"text\"")]
[InlineData("[1,2]")]
[InlineData("null")]
public void Non_object_arguments_become_an_empty_dictionary(string json)
{
McpJsonBridge.ToArgumentDictionary(Parse(json)).Should().BeEmpty();
}

[Fact]
public void Text_blocks_join_into_the_text_payload()
{
var result = new CallToolResult
{
Content =
[
new TextContentBlock { Text = "line one" },
new TextContentBlock { Text = "line two" },
],
};

var mapped = McpJsonBridge.ToToolResult(result, "mcp:s:t", tick: 5);

mapped.Payload.Should().Be("line one\nline two");
mapped.Delta.TickFrom.Should().Be(5);
mapped.Delta.TickTo.Should().Be(6);
mapped.Delta.Log.Should().ContainSingle().Which.Should().Contain("ok");
}

[Fact]
public void Structured_content_wins_over_text_as_payload()
{
var result = new CallToolResult
{
Content = [new TextContentBlock { Text = "{\"answer\":42}" }],
StructuredContent = Parse("""{"answer":42}"""),
};

var mapped = McpJsonBridge.ToToolResult(result, "mcp:s:t", tick: 0);

mapped.Payload.Should().BeOfType<JsonElement>()
.Which.GetProperty("answer").GetInt32().Should().Be(42);
}

[Fact]
public void Remote_isError_maps_to_a_failure_payload_not_an_exception()
{
var result = new CallToolResult
{
IsError = true,
Content = [new TextContentBlock { Text = "remote exploded" }],
};

var mapped = McpJsonBridge.ToToolResult(result, "mcp:s:t", tick: 0);

var failure = mapped.Payload.Should().BeOfType<McpToolFailure>().Subject;
failure.Error.Should().Contain("remote exploded");
failure.IsError.Should().BeTrue();
mapped.Delta.Log.Should().ContainSingle().Which.Should().Contain("error");
}

[Fact]
public void Remote_isError_with_no_text_still_reports_an_error()
{
var result = new CallToolResult { IsError = true, Content = [] };

var mapped = McpJsonBridge.ToToolResult(result, "mcp:s:t", tick: 0);

mapped.Payload.Should().BeOfType<McpToolFailure>()
.Which.Error.Should().NotBeNullOrWhiteSpace();
}
}
Loading
Loading