MCP compatibility for AI agents: server registry, JSON-RPC client, gateway, run-time tool catalog

Agents can now use Model Context Protocol servers. End to end:
- SharedKernel seam IMcpGateway (ListToolsAsync / CallToolAsync) + McpToolDescriptor / McpToolResult,
  so the Assembler discovers and can invoke MCP tools without referencing Integrations' tables.
- Integrations: McpServerConfig (org-scoped, owner-only; auth headers AES-GCM encrypted, never
  returned — only their names) + AddMcpServers migration. McpClient: a dependency-free Streamable-HTTP
  JSON-RPC 2.0 client (initialize → notifications/initialized → tools/list / tools/call), carrying the
  Mcp-Session-Id and parsing both application/json and text/event-stream replies. McpGateway resolves
  an org's servers, decrypts headers server-side, and is best-effort: an unreachable server is logged
  and skipped, never failing the run. CRUD + connectivity-test endpoints (create/test/delete owner-only
  via ManageApiKeys; list via ConfigureAgents to bind).
- OrgBoard: Agent gains McpServerIds (uuid[]; migration backfills existing agents to empty) flowing
  through ConfigureAgent + AgentRunContext.
- Assembler: AgentRunExecutor lists the agent's MCP tools (best-effort) and PromptAssembler renders a
  "# Tools (MCP)" catalog — labelled as data, never instructions — and records it in the run trace.
- Client: SeatsPage gains an MCP servers card (add/test/delete, encrypted auth header) and a per-agent
  MCP server multi-select; api client gains del().

Note: discovery + the governed call gateway are in place now; the autonomous model-driven tool-call
loop (model emits tool_calls → gated execution → feedback) needs a tool-calling model client and is
the next increment — the stub model can't drive it.

Verified: ArchitectureTests 8/8, IntegrationTests 53/53 (McpClientTests: JSON-RPC handshake/session,
json + SSE; McpServerRegistryTests: owner-only, encrypted-header-never-returned, graceful test,
Member 403; PromptAssemblerMcpTests: catalog + trace, omitted when empty), client build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-13 19:25:43 +03:30
parent 0ac15c7308
commit c5e0e5cfe3
27 changed files with 1506 additions and 8 deletions
@@ -17,6 +17,9 @@ internal sealed class Agent : Entity
public Guid ApiConfigId { get; private set; }
public Guid? FallbackApiConfigId { get; private set; }
public List<string> SkillKeys { get; private set; } = [];
/// <summary>Ids of the org's MCP servers this agent may use (resolved at run time).</summary>
public List<Guid> McpServerIds { get; private set; } = [];
public List<string> Docs { get; private set; } = [];
public DateTimeOffset CreatedAtUtc { get; private set; }
public DateTimeOffset UpdatedAtUtc { get; private set; }
@@ -39,6 +42,7 @@ internal sealed class Agent : Entity
Guid apiConfigId,
Guid? fallbackApiConfigId,
List<string> skillKeys,
List<Guid> mcpServerIds,
List<string> docs,
DateTimeOffset nowUtc)
{
@@ -48,6 +52,7 @@ internal sealed class Agent : Entity
ApiConfigId = apiConfigId;
FallbackApiConfigId = fallbackApiConfigId;
SkillKeys = skillKeys;
McpServerIds = mcpServerIds;
Docs = docs;
UpdatedAtUtc = nowUtc;
}
@@ -51,6 +51,7 @@ internal sealed record ConfigureAgentRequest(
Guid ApiConfigId,
Guid? FallbackApiConfigId,
List<string> SkillKeys,
List<Guid> McpServerIds,
List<string> Docs);
internal sealed record AgentResponse(
@@ -62,4 +63,5 @@ internal sealed record AgentResponse(
Guid ApiConfigId,
Guid? FallbackApiConfigId,
List<string> SkillKeys,
List<Guid> McpServerIds,
List<string> Docs);
@@ -343,7 +343,7 @@ internal static class OrgBoardEndpoints
private static AgentResponse ToAgent(Agent agent) => new(
agent.Id, agent.SeatId, agent.Name, agent.Monogram, agent.Autonomy.ToString(),
agent.ApiConfigId, agent.FallbackApiConfigId, agent.SkillKeys, agent.Docs);
agent.ApiConfigId, agent.FallbackApiConfigId, agent.SkillKeys, agent.McpServerIds, agent.Docs);
private static async Task<IResult> CreateSeat(
CreateSeatRequest request, ICurrentUser user, IPermissionService permissions,
@@ -422,7 +422,7 @@ internal static class OrgBoardEndpoints
agent ??= new Agent(seat.Id, now);
agent.Configure(
request.Name.Trim(), request.Monogram, request.Autonomy, request.ApiConfigId,
request.FallbackApiConfigId, request.SkillKeys ?? [], request.Docs ?? [], now);
request.FallbackApiConfigId, request.SkillKeys ?? [], request.McpServerIds ?? [], request.Docs ?? [], now);
if (isNew)
{
@@ -0,0 +1,321 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TeamUp.Modules.OrgBoard.Persistence;
#nullable disable
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
{
[DbContext(typeof(OrgBoardDbContext))]
[Migration("20260613153921_AddAgentMcpServers")]
partial class AddAgentMcpServers
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("orgboard")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Agent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ApiConfigId")
.HasColumnType("uuid");
b.Property<string>("Autonomy")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<List<string>>("Docs")
.IsRequired()
.HasColumnType("text[]");
b.Property<Guid?>("FallbackApiConfigId")
.HasColumnType("uuid");
b.PrimitiveCollection<List<Guid>>("McpServerIds")
.IsRequired()
.HasColumnType("uuid[]");
b.Property<string>("Monogram")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<Guid>("SeatId")
.HasColumnType("uuid");
b.PrimitiveCollection<List<string>>("SkillKeys")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("SeatId")
.IsUnique();
b.ToTable("agents", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Division", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.ToTable("divisions", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.ToTable("organizations", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Product", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DivisionId")
.HasColumnType("uuid");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("DivisionId");
b.HasIndex("OrganizationId");
b.ToTable("products", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Seat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AgentId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("MemberId")
.HasColumnType("uuid");
b.Property<string>("RoleName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TeamId");
b.ToTable("seats", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Team", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<Guid?>("ProductId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.HasIndex("ProductId");
b.ToTable("teams", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssigneeId")
.HasColumnType("uuid");
b.Property<string>("AssigneeKind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedByMemberId")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("TeamId");
b.HasIndex("AssigneeKind", "AssigneeId");
b.ToTable("work_items", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItemTransition", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("ActorMemberId")
.HasColumnType("uuid");
b.Property<string>("FromStatus")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("OccurredAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.Property<string>("ToStatus")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("WorkItemId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TeamId");
b.HasIndex("WorkItemId");
b.ToTable("work_item_transitions", "orgboard");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddAgentMcpServers : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<List<Guid>>(
name: "McpServerIds",
schema: "orgboard",
table: "agents",
type: "uuid[]",
nullable: false,
defaultValueSql: "'{}'"); // existing agents get an empty array (no MCP servers bound)
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "McpServerIds",
schema: "orgboard",
table: "agents");
}
}
}
@@ -48,6 +48,10 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
b.Property<Guid?>("FallbackApiConfigId")
.HasColumnType("uuid");
b.PrimitiveCollection<List<Guid>>("McpServerIds")
.IsRequired()
.HasColumnType("uuid[]");
b.Property<string>("Monogram")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
@@ -29,7 +29,7 @@ internal sealed class AgentRunContextProvider(OrgBoardDbContext db) : IAgentRunC
return new AgentRunContext(
seatId, agent.Id, agent.Name, agent.Monogram, agent.Autonomy,
agent.ApiConfigId, agent.FallbackApiConfigId, agent.SkillKeys, agent.Docs,
agent.ApiConfigId, agent.FallbackApiConfigId, agent.SkillKeys, agent.McpServerIds, agent.Docs,
item.Id, item.Title, item.Description, item.Type.ToString(),
team.Id, team.OrganizationId);
}