// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Globalization;
using System.Net;
using System.Reflection;
using System.Text.Json;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Commands;
using Aspire.Cli.Mcp;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Tests.Mcp;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Hosting.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using System.Threading.Channels;
namespace Aspire.Cli.Tests.Commands;
/// <summary>
/// In-process unit tests for AgentMcpCommand that test the MCP server functionality
/// without starting a new CLI process. The IO communication between the MCP server
/// and test client is abstracted using in-memory pipes via DI.
/// </summary>
public class AgentMcpCommandTests(ITestOutputHelper outputHelper)
{
private async Task<McpTestContext> CreateMcpClientAsync(string? dashboardUrl = null, string? appHostPath = null)
{
var cts = new CancellationTokenSource();
var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var loggerFactory = LoggerFactory.Create(builder => builder.AddXunit(outputHelper));
var testTransport = new TestMcpServerTransport(loggerFactory);
var backchannelMonitor = new TestAuxiliaryBackchannelMonitor();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.McpServerTransportFactory = _ => testTransport;
options.DocsIndexServiceFactory = _ => new TestDocsIndexService();
options.AuxiliaryBackchannelMonitorFactory = _ => backchannelMonitor;
if (appHostPath is not null)
{
options.ProjectLocatorFactory = _ => new TestProjectLocator();
}
});
if (dashboardUrl is not null)
{
var handler = new MockHttpMessageHandler(request =>
{
var url = request.RequestUri!.ToString();
if (url.Contains("/api/telemetry/resources"))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]", System.Text.Encoding.UTF8, "application/json")
};
}
if (url.Contains("/api/telemetry/"))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"data\":{},\"totalCount\":0,\"returnedCount\":0}", System.Text.Encoding.UTF8, "application/json")
};
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
services.AddSingleton(handler);
services.Replace(ServiceDescriptor.Singleton<IHttpClientFactory>(new MockHttpClientFactory(handler)));
}
// ServiceProvider lifetime is managed by McpTestContext.DisposeAsync, not this method.
var serviceProvider = services.BuildServiceProvider();
var agentMcpCommand = serviceProvider.GetRequiredService<AgentMcpCommand>();
var rootCommand = serviceProvider.GetRequiredService<RootCommand>();
var commandLine = "agent mcp";
if (dashboardUrl is not null)
{
commandLine += $" --dashboard-url {dashboardUrl}";
}
if (appHostPath is not null)
{
commandLine += $" --apphost \"{appHostPath}\"";
}
var parseResult = rootCommand.Parse(commandLine);
var serverRunTask = Task.Run(async () =>
{
try
{
await agentMcpCommand.ExecuteCommandAsync(parseResult, cts.Token);
}
catch (OperationCanceledException)
{
}
}, cts.Token);
var mcpClient = await testTransport.CreateClientAsync(loggerFactory, cts.Token);
return new McpTestContext(mcpClient, cts, workspace, serverRunTask, testTransport, agentMcpCommand, serviceProvider, loggerFactory)
{
BackchannelMonitor = backchannelMonitor
};
}
[Fact]
public async Task McpServer_ListTools_ReturnsExpectedTools()
{
await using var ctx = await CreateMcpClientAsync();
var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
// Assert
Assert.NotNull(tools);
Assert.Collection(tools.OrderBy(t => t.Name),
tool => AssertTool(KnownMcpTools.Doctor, tool),
tool => AssertTool(KnownMcpTools.ExecuteResourceCommand, tool),
tool => AssertTool(KnownMcpTools.GetDoc, tool),
tool => AssertTool(KnownMcpTools.ListAppHosts, tool),
tool => AssertTool(KnownMcpTools.ListConsoleLogs, tool),
tool => AssertTool(KnownMcpTools.ListDocs, tool),
tool => AssertTool(KnownMcpTools.ListIntegrations, tool),
tool => AssertTool(KnownMcpTools.ListResources, tool),
tool => AssertTool(KnownMcpTools.ListStructuredLogs, tool),
tool => AssertTool(KnownMcpTools.ListTraceStructuredLogs, tool),
tool => AssertTool(KnownMcpTools.ListTraces, tool),
tool => AssertTool(KnownMcpTools.RefreshTools, tool),
tool => AssertTool(KnownMcpTools.SearchDocs, tool),
tool => AssertTool(KnownMcpTools.SelectAppHost, tool),
tool => AssertTool(KnownMcpTools.WaitForResources, tool));
static void AssertTool(string expectedName, McpClientTool tool)
{
Assert.Equal(expectedName, tool.Name);
Assert.False(string.IsNullOrEmpty(tool.Description), $"Tool '{tool.Name}' should have a description");
Assert.NotEqual(default, tool.JsonSchema);
}
}
[Fact]
public async Task McpServer_ListTools_FixedToolsHaveAccurateAnnotations()
{
await using var ctx = await CreateMcpClientAsync();
var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
AssertFixedToolAnnotations(tools);
}
[Fact]
public async Task McpServer_ListTools_WithPinnedAppHost_FixedToolsKeepAccurateAnnotationsAfterRefresh()
{
var appHostPath = Path.GetFullPath(Path.Combine("PinnedAppHost", "PinnedAppHost.csproj"));
await using var ctx = await CreateMcpClientAsync(appHostPath: appHostPath);
var connection = new TestAppHostAuxiliaryBackchannel
{
Hash = "pinned-apphost-hash",
SocketPath = "socket.pinned",
IsInScope = false,
AppHostInfo = new AppHostInformation
{
AppHostPath = appHostPath,
ProcessId = 2002
}
};
ctx.BackchannelMonitor!.AddConnection(connection.Hash, connection.SocketPath, connection);
await ctx.Client.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: ctx.Cts.Token).DefaultTimeout();
var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
AssertFixedToolAnnotations(tools, KnownMcpTools.ListAppHosts, KnownMcpTools.SelectAppHost);
}
[Fact]
public async Task McpServer_ListTools_WaitForResourcesHasExpectedSchema()
{
await using var ctx = await CreateMcpClientAsync();
var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
var tool = Assert.Single(tools, tool => tool.Name == KnownMcpTools.WaitForResources);
var schema = tool.JsonSchema;
Assert.Equal("object", schema.GetProperty("type").GetString());
Assert.False(schema.TryGetProperty("required", out _));
Assert.False(schema.GetProperty("additionalProperties").GetBoolean());
var properties = schema.GetProperty("properties");
Assert.Equal(
["resourceNames", "targetState", "timeoutSeconds"],
properties.EnumerateObject().Select(static property => property.Name));
var resourceNames = properties.GetProperty("resourceNames");
Assert.Equal("array", resourceNames.GetProperty("type").GetString());
Assert.Equal(100, resourceNames.GetProperty("maxItems").GetInt32());
Assert.Equal("string", resourceNames.GetProperty("items").GetProperty("type").GetString());
Assert.Equal(256, resourceNames.GetProperty("items").GetProperty("maxLength").GetInt32());
var targetState = properties.GetProperty("targetState");
Assert.Equal("string", targetState.GetProperty("type").GetString());
Assert.Equal("healthy", targetState.GetProperty("default").GetString());
Assert.Equal(
["healthy", "up", "down"],
targetState.GetProperty("enum").EnumerateArray().Select(static value => value.GetString()));
var timeoutSeconds = properties.GetProperty("timeoutSeconds");
Assert.Equal("integer", timeoutSeconds.GetProperty("type").GetString());
Assert.Equal(120, timeoutSeconds.GetProperty("default").GetInt32());
Assert.Equal(1, timeoutSeconds.GetProperty("minimum").GetInt32());
Assert.Equal(3600, timeoutSeconds.GetProperty("maximum").GetInt32());
}
[Fact]
public async Task McpServer_ListTools_IncludesResourceMcpTools()
{
await using var ctx = await CreateMcpClientAsync();
var mockBackchannel = new TestAppHostAuxiliaryBackchannel
{
IsInScope = true,
AppHostInfo = new AppHostInformation
{
AppHostPath = Path.Combine(ctx.Workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"),
ProcessId = 12345
},
ResourceSnapshots =
[
new ResourceSnapshot
{
Name = "test-resource-abcd1234",
DisplayName = "test-resource",
ResourceType = "Container",
State = "Running",
McpServer = new ResourceSnapshotMcpServer
{
EndpointUrl = "http://localhost:8080/mcp",
Tools =
[
new Tool
{
Name = "resource_tool_one",
Description = "A test tool from the resource"
},
new Tool
{
Name = "resource_tool_two",
Description = "Another test tool from the resource"
}
]
}
}
]
};
ctx.BackchannelMonitor!.AddConnection(mockBackchannel.SocketPath, mockBackchannel);
await ctx.Client.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: ctx.Cts.Token).DefaultTimeout();
var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
// Assert - Verify resource tools are included
Assert.NotNull(tools);
// The resource tools should be exposed with a prefixed name using the DisplayName (app-model name):
// DisplayName "test-resource" becomes "test_resource" (dashes replaced with underscores)
var resourceToolOne = tools.FirstOrDefault(t => t.Name == "test_resource_resource_tool_one");
var resourceToolTwo = tools.FirstOrDefault(t => t.Name == "test_resource_resource_tool_two");
Assert.NotNull(resourceToolOne);
Assert.NotNull(resourceToolTwo);
Assert.Equal("A test tool from the resource", resourceToolOne.Description);
Assert.Equal("Another test tool from the resource", resourceToolTwo.Description);
}
[Fact]
public async Task McpServer_ListTools_DynamicToolAnnotationsPersistAfterRefresh()
{
await using var ctx = await CreateMcpClientAsync();
var connection = new TestAppHostAuxiliaryBackchannel
{
Hash = "annotated-apphost",
SocketPath = "socket.annotated",
IsInScope = true,
AppHostInfo = new AppHostInformation
{
AppHostPath = Path.Combine(ctx.Workspace.WorkspaceRoot.FullName, "AnnotatedAppHost", "AnnotatedAppHost.csproj"),
ProcessId = 12345
},
ResourceSnapshots =
[
new ResourceSnapshot
{
Name = "annotated-resource-runtime",
DisplayName = "annotated-resource",
ResourceType = "Container",
State = "Running",
McpServer = new ResourceSnapshotMcpServer
{
EndpointUrl = "http://localhost:8080/mcp",
Tools =
[
new Tool
{
Name = "inspect",
Description = "Inspects the resource",
Annotations = new ToolAnnotations
{
ReadOnlyHint = true,
DestructiveHint = false
}
},
new Tool
{
Name = "reset",
Description = "Resets the resource",
Annotations = new ToolAnnotations
{
ReadOnlyHint = false,
DestructiveHint = true
}
}
]
}
}
]
};
ctx.BackchannelMonitor!.AddConnection(connection.Hash, connection.SocketPath, connection);
var initialTools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
AssertDynamicToolAnnotations(initialTools);
await ctx.Client.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: ctx.Cts.Token).DefaultTimeout();
var refreshedTools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
AssertDynamicToolAnnotations(refreshedTools);
static void AssertDynamicToolAnnotations(IList<McpClientTool> tools)
{
AssertToolAnnotations(
Assert.Single(tools, static tool => tool.Name == "annotated_resource_inspect"),
readOnly: true,
destructive: false);
AssertToolAnnotations(
Assert.Single(tools, static tool => tool.Name == "annotated_resource_reset"),
readOnly: false,
destructive: true);
}
}
[Fact]
public async Task McpServer_CallTool_ResourceMcpTool_ReturnsResult()
{
await using var ctx = await CreateMcpClientAsync();
var expectedToolResult = "Tool executed successfully with custom data";
string? callResourceName = null;
string? callToolName = null;
var mockBackchannel = new TestAppHostAuxiliaryBackchannel
{
IsInScope = true,
AppHostInfo = new AppHostInformation
{
AppHostPath = Path.Combine(ctx.Workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"),
ProcessId = 12345
},
ResourceSnapshots =
[
new ResourceSnapshot
{
Name = "my-resource-abcd1234",
DisplayName = "my-resource",
ResourceType = "Container",
State = "Running",
McpServer = new ResourceSnapshotMcpServer
{
EndpointUrl = "http://localhost:8080/mcp",
Tools =
[
new Tool
{
Name = "do_something",
Description = "Does something useful"
}
]
}
}
],
// Configure the handler to capture the arguments and return a specific result
CallResourceMcpToolHandler = (resourceName, toolName, arguments, ct) =>
{
callResourceName = resourceName;
callToolName = toolName;
return Task.FromResult(new CallToolResult
{
Content = [new TextContentBlock { Text = expectedToolResult }]
});
}
};
ctx.BackchannelMonitor!.AddConnection(mockBackchannel.SocketPath, mockBackchannel);
await ctx.Client.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: ctx.Cts.Token).DefaultTimeout();
var result = await ctx.Client.CallToolAsync(
"my_resource_do_something",
cancellationToken: ctx.Cts.Token).DefaultTimeout();
// Assert
Assert.NotNull(result);
Assert.True(result.IsError is null or false, $"Tool returned error: {GetResultText(result)}");
Assert.NotNull(result.Content);
Assert.NotEmpty(result.Content);
var textContent = result.Content[0] as TextContentBlock;
Assert.NotNull(textContent);
Assert.Equal(expectedToolResult, textContent.Text);
// Verify the handler was called with the correct resource and tool names
Assert.Equal("my-resource", callResourceName);
Assert.Equal("do_something", callToolName);
}
[Fact]
public async Task McpServer_CallTool_ResourceMcpTool_UsesConnectionThatProducedToolMap()
{
await using var ctx = await CreateMcpClientAsync();
var appHostAPath = Path.Combine(ctx.Workspace.WorkspaceRoot.FullName, "AppHostA", "AppHostA.csproj");
var appHostBPath = Path.Combine(ctx.Workspace.WorkspaceRoot.FullName, "AppHostB", "AppHostB.csproj");
var appHostAToolCalls = 0;
var appHostBToolCalls = 0;
var appHostA = CreateResourceToolConnection(
ctx.Workspace,
hash: "apphost-a",
socketPath: "socket.a",
displayName: "resource-a",
toolName: "tool_a",
appHostPath: appHostAPath);
var appHostB = CreateResourceToolConnection(
ctx.Workspace,
hash: "apphost-b",
socketPath: "socket.b",
displayName: "resource-b",
toolName: "tool_b",
appHostPath: appHostBPath);
appHostA.CallResourceMcpToolHandler = (_, _, _, _) =>
{
Interlocked.Increment(ref appHostAToolCalls);
return Task.FromResult(new CallToolResult
{
Content = [new TextContentBlock { Text = "apphost-a" }]
});
};
appHostB.CallResourceMcpToolHandler = (_, _, _, _) =>
{
Interlocked.Increment(ref appHostBToolCalls);
return Task.FromResult(new CallToolResult
{
Content = [new TextContentBlock { Text = "apphost-b" }]
});
};
appHostA.GetResourceSnapshotsHandler = _ =>
{
// Selection can change while the map is being built. Dispatch must remain bound to
// the connection whose resource snapshot advertised the selected tool.
ctx.BackchannelMonitor!.SelectedAppHostPath = appHostBPath;
return Task.FromResult(appHostA.ResourceSnapshots);
};
ctx.BackchannelMonitor!.AddConnection(appHostA.Hash, appHostA.SocketPath, appHostA);
ctx.BackchannelMonitor.AddConnection(appHostB.Hash, appHostB.SocketPath, appHostB);
ctx.BackchannelMonitor.SelectedAppHostPath = appHostAPath;
var result = await ctx.Client.CallToolAsync(
"resource_a_tool_a",
cancellationToken: ctx.Cts.Token).DefaultTimeout();
Assert.Equal("apphost-a", GetResultText(result));
Assert.Equal(1, appHostAToolCalls);
Assert.Equal(0, appHostBToolCalls);
Assert.Equal(appHostBPath, ctx.BackchannelMonitor.SelectedAppHostPath);
}
[Fact]
public async Task McpServer_CallTool_ResourceMcpTool_UsesDisplayNameForRouting()
{
await using var ctx = await CreateMcpClientAsync();
var expectedToolResult = "List schemas completed";
string? callResourceName = null;
string? callToolName = null;
var mockBackchannel = new TestAppHostAuxiliaryBackchannel
{
IsInScope = true,
AppHostInfo = new AppHostInformation
{
AppHostPath = Path.Combine(ctx.Workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"),
ProcessId = 12345
},
ResourceSnapshots =
[
new ResourceSnapshot
{
Name = "db1-mcp-ypnvhwvw",
DisplayName = "db1-mcp",
ResourceType = "Container",
State = "Running",
McpServer = new ResourceSnapshotMcpServer
{
EndpointUrl = "http://localhost:8080/mcp",
Tools =
[
new Tool
{
Name = "list_schemas",
Description = "Lists database schemas"
}
]
}
}
],
CallResourceMcpToolHandler = (resourceName, toolName, arguments, ct) =>
{
callResourceName = resourceName;
callToolName = toolName;
return Task.FromResult(new CallToolResult
{
Content = [new TextContentBlock { Text = expectedToolResult }]
});
}
};
ctx.BackchannelMonitor!.AddConnection(mockBackchannel.SocketPath, mockBackchannel);
await ctx.Client.CallToolAsync(KnownMcpTools.RefreshTools, cancellationToken: ctx.Cts.Token).DefaultTimeout();
var result = await ctx.Client.CallToolAsync("db1_mcp_list_schemas", cancellationToken: ctx.Cts.Token).DefaultTimeout();
// Assert
Assert.NotNull(result);
Assert.True(result.IsError is null or false, $"Tool returned error: {GetResultText(result)}");
Assert.Equal("db1-mcp", callResourceName);
Assert.Equal("list_schemas", callToolName);
}
[Fact]
public async Task McpServer_CallTool_ListAppHosts_ReturnsResult()
{
await using var ctx = await CreateMcpClientAsync();
var result = await ctx.Client.CallToolAsync(
KnownMcpTools.ListAppHosts,
cancellationToken: ctx.Cts.Token).DefaultTimeout();
// Assert
Assert.NotNull(result);
Assert.Null(result.IsError);
Assert.NotNull(result.Content);
Assert.NotEmpty(result.Content);
var textContent = result.Content[0] as TextContentBlock;
Assert.NotNull(textContent);
Assert.Contains("App hosts", textContent.Text);
}
[Fact]
public async Task McpServer_ListTools_WithPinnedAppHost_HidesAppHostSelectionTools()
{
var appHostPath = Path.GetFullPath(Path.Combine("PinnedAppHost", "PinnedAppHost.csproj"));
await using var ctx = await CreateMcpClientAsync(appHostPath: appHostPath);
var connection = new TestAppHostAuxiliaryBackchannel
{
Hash = "pinned-apphost-hash",
SocketPath = "socket.pinned",
IsInScope = false,
AppHostInfo = new AppHostInformation
{
AppHostPath = appHostPath,
ProcessId = 2002
}
};
ctx.BackchannelMonitor!.AddConnection(connection.Hash, connection.SocketPath, connection);
var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
Assert.NotNull(tools);
Assert.Equal(
[
KnownMcpTools.Doctor,
KnownMcpTools.ExecuteResourceCommand,
KnownMcpTools.GetDoc,
KnownMcpTools.ListConsoleLogs,
KnownMcpTools.ListDocs,
KnownMcpTools.ListIntegrations,
KnownMcpTools.ListResources,
KnownMcpTools.ListStructuredLogs,
KnownMcpTools.ListTraceStructuredLogs,
KnownMcpTools.ListTraces,
KnownMcpTools.RefreshTools,
KnownMcpTools.SearchDocs,
KnownMcpTools.WaitForResources
],
tools.Select(t => t.Name).OrderBy(static name => name, StringComparer.Ordinal));
}
[Fact]
public async Task McpServer_CallTool_HiddenAppHostSelectionTools_WithPinnedAppHost_ReturnUnavailableError()
{
var appHostPath = Path.GetFullPath(Path.Combine("PinnedAppHost", "PinnedAppHost.csproj"));
await using var ctx = await CreateMcpClientAsync(appHostPath: appHostPath);
await AssertToolUnavailableAsync(KnownMcpTools.ListAppHosts);
await AssertToolUnavailableAsync(KnownMcpTools.SelectAppHost);
async Task AssertToolUnavailableAsync(string toolName)
{
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await ctx.Client.CallToolAsync(
toolName,
cancellationToken: ctx.Cts.Token).DefaultTimeout());
Assert.Equal(McpErrorCode.MethodNotFound, exception.ErrorCode);
Assert.Equal(
$"Request failed (remote): Tool '{toolName}' is not available because this MCP server is pinned to an AppHost. Start an unpinned MCP server to use AppHost selection tools.",
exception.Message);
}
}
[Fact]
public async Task McpServer_WithPinnedAppHost_UsesOnlyPinnedConnectionForResourceToolDiscoveryAndRouting()
{
var appHostPath = Path.GetFullPath(Path.Combine("PinnedAppHost", "PinnedAppHost.csproj"));
await using var ctx = await CreateMcpClientAsync(appHostPath: appHostPath);
var unrelatedSnapshotCalls = 0;
var unrelatedToolCalls = 0;
var pinnedSnapshotCalls = 0;
var pinnedToolCalls = 0;
var unrelatedConnection = new TestAppHostAuxiliaryBackchannel
{
Hash = "unrelated-apphost-hash",
IsInScope = true,
AppHostInfo = new AppHostInformation
{
AppHostPath = Path.Combine(ctx.Workspace.WorkspaceRoot.FullName, "UnrelatedAppHost", "UnrelatedAppHost.csproj"),
ProcessId = 1001
},
GetResourceSnapshotsHandler = _ =>
{
Interlocked.Increment(ref unrelatedSnapshotCalls);
return Task.FromResult(new List<ResourceSnapshot>
{
new ResourceSnapshot
{
Name = "unrelated-db-xyz",
DisplayName = "unrelated-db",
ResourceType = "Container",
State = "Running",
McpServer = new ResourceSnapshotMcpServer
{
EndpointUrl = "http://localhost:8081/mcp",
Tools =
[
new Tool
{
Name = "drop_database",
Description = "Drops the unrelated database"
}
]
}
}
});
},
CallResourceMcpToolHandler = (_, _, _, _) =>
{
Interlocked.Increment(ref unrelatedToolCalls);
return Task.FromResult(new CallToolResult
{
Content = [new TextContentBlock { Text = "unrelated" }]
});
}
};
var pinnedConnection = new TestAppHostAuxiliaryBackchannel
{
Hash = "pinned-apphost-hash",
IsInScope = false,
AppHostInfo = new AppHostInformation
{
AppHostPath = appHostPath,
ProcessId = 2002
},
GetResourceSnapshotsHandler = _ =>
{
Interlocked.Increment(ref pinnedSnapshotCalls);
return Task.FromResult(new List<ResourceSnapshot>
{
new ResourceSnapshot
{
Name = "pinned-db-xyz",
DisplayName = "pinned-db",
ResourceType = "Container",
State = "Running",
McpServer = new ResourceSnapshotMcpServer
{
EndpointUrl = "http://localhost:8082/mcp",
Tools =
[
new Tool
{
Name = "query_database",
Description = "Queries the pinned database"
}
]
}
}
});
},
CallResourceMcpToolHandler = (_, _, _, _) =>
{
Interlocked.Increment(ref pinnedToolCalls);
return Task.FromResult(new CallToolResult
{
Content = [new TextContentBlock { Text = "pinned" }]
});
}
};
ctx.BackchannelMonitor!.AddConnection(unrelatedConnection.Hash, unrelatedConnection.SocketPath, unrelatedConnection);
ctx.BackchannelMonitor.ScanAsyncCallback = _ =>
{
ctx.BackchannelMonitor.AddConnection(pinnedConnection.Hash, pinnedConnection.SocketPath, pinnedConnection);
return Task.CompletedTask;
};
var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
Assert.Equal(
[
KnownMcpTools.Doctor,
KnownMcpTools.ExecuteResourceCommand,
KnownMcpTools.GetDoc,
KnownMcpTools.ListConsoleLogs,
KnownMcpTools.ListDocs,
KnownMcpTools.ListIntegrations,
KnownMcpTools.ListResources,
KnownMcpTools.ListStructuredLogs,
KnownMcpTools.ListTraceStructuredLogs,
KnownMcpTools.ListTraces,
"pinned_db_query_database",
KnownMcpTools.RefreshTools,
KnownMcpTools.SearchDocs,
KnownMcpTools.WaitForResources
],
tools.Select(t => t.Name).OrderBy(static name => name, StringComparer.Ordinal));
Assert.Equal(0, unrelatedSnapshotCalls);
Assert.Equal(1, pinnedSnapshotCalls);
Assert.Equal(1, ctx.BackchannelMonitor.ScanCallCount);
ctx.BackchannelMonitor.SelectedAppHostPath = unrelatedConnection.AppHostInfo!.AppHostPath;
var result = await ctx.Client.CallToolAsync("pinned_db_query_database", cancellationToken: ctx.Cts.Token).DefaultTimeout();
Assert.NotNull(result);
Assert.True(result.IsError is null or false, $"Tool returned error: {GetResultText(result)}");
Assert.Equal("pinned", GetResultText(result));
Assert.Equal(appHostPath, ctx.BackchannelMonitor.SelectedAppHostPath);
Assert.Equal(0, unrelatedToolCalls);
Assert.Equal(1, pinnedToolCalls);
}
[Fact]
public async Task McpServer_WithPinnedAppHost_ListWaitList_UsesSameConnectionAndPath()
{
var appHostPath = Path.GetFullPath(Path.Combine("PinnedAppHost", "PinnedAppHost.csproj"));
await using var ctx = await CreateMcpClientAsync(appHostPath: appHostPath);
var unrelatedSnapshotCalls = 0;
var unrelatedWaitCalls = 0;
var unrelatedPath = Path.GetFullPath(Path.Combine("UnrelatedAppHost", "UnrelatedAppHost.csproj"));
var unrelatedConnection = new TestAppHostAuxiliaryBackchannel
{
Hash = "unrelated-apphost-hash",
SocketPath = "socket.unrelated",
IsInScope = true,
AppHostInfo = new AppHostInformation
{
AppHostPath = unrelatedPath,
ProcessId = 1001
},
GetResourceSnapshotsHandler = _ =>
{
Interlocked.Increment(ref unrelatedSnapshotCalls);
return Task.FromResult(new List<ResourceSnapshot>
{
new()
{
Name = "unrelated",
ResourceType = "Container",
State = "Running"
}
});
},
WaitForResourceHandler = (_, _, _, _) =>
{
Interlocked.Increment(ref unrelatedWaitCalls);
return Task.FromResult(new WaitForResourceResponse { Success = true, State = "Running" });
}
};
var currentState = "Starting";
var pinnedWaitCalls = 0;
var pinnedConnection = new TestAppHostAuxiliaryBackchannel
{
Hash = "pinned-apphost-hash",
SocketPath = "socket.pinned",
IsInScope = false,
AppHostInfo = new AppHostInformation
{
AppHostPath = appHostPath,
ProcessId = 2002
},
GetResourceSnapshotsHandler = _ => Task.FromResult(new List<ResourceSnapshot>
{
new()
{
Name = "api",
DisplayName = "API",
ResourceType = "Project",
State = currentState,
HealthStatus = currentState == "Running" ? "Healthy" : null
}
}),
WaitForResourceHandler = (resourceName, targetState, timeoutSeconds, _) =>
{
Assert.Equal("api", resourceName);
Assert.Equal("up", targetState);
Assert.Equal(15, timeoutSeconds);
Interlocked.Increment(ref pinnedWaitCalls);
currentState = "Running";
return Task.FromResult(new WaitForResourceResponse
{
Success = true,
State = currentState,
HealthStatus = "Healthy"
});
}
};
ctx.BackchannelMonitor!.AddConnection(
unrelatedConnection.Hash,
unrelatedConnection.SocketPath,
unrelatedConnection);
ctx.BackchannelMonitor.AddConnection(
pinnedConnection.Hash,
pinnedConnection.SocketPath,
pinnedConnection);
var firstList = await ctx.Client.CallToolAsync(
KnownMcpTools.ListResources,
cancellationToken: ctx.Cts.Token).DefaultTimeout();
ctx.BackchannelMonitor.SelectedAppHostPath = unrelatedPath;
var wait = await ctx.Client.CallToolAsync(
KnownMcpTools.WaitForResources,
new Dictionary<string, object?>
{
["resourceNames"] = new[] { "api" },
["targetState"] = "up",
["timeoutSeconds"] = 15
},
cancellationToken: ctx.Cts.Token).DefaultTimeout();
ctx.BackchannelMonitor.SelectedAppHostPath = unrelatedPath;
var secondList = await ctx.Client.CallToolAsync(
KnownMcpTools.ListResources,
cancellationToken: ctx.Cts.Token).DefaultTimeout();
using var firstListJson = GetMarkedJson(firstList, "# RESOURCE DATA");
using var waitJson = GetMarkedJson(wait, "# WAIT RESULT");
using var secondListJson = GetMarkedJson(secondList, "# RESOURCE DATA");
Assert.False(waitJson.RootElement.TryGetProperty("app_host_path", out _));
Assert.Equal(
"Starting",
firstListJson.RootElement[0].GetProperty("state").GetString());
Assert.Equal("success", waitJson.RootElement.GetProperty("outcome").GetString());
Assert.Equal(
"Running",
secondListJson.RootElement[0].GetProperty("state").GetString());
Assert.Equal(3, pinnedConnection.GetResourceSnapshotsCallCount);
Assert.Equal(1, pinnedWaitCalls);
Assert.Equal(0, unrelatedSnapshotCalls);
Assert.Equal(0, unrelatedWaitCalls);
Assert.Equal(appHostPath, ctx.BackchannelMonitor.SelectedAppHostPath);
}
[Fact]
public async Task McpServer_WithPinnedAppHost_CachesResourceToolsWhileConnectionRemainsAvailable()
{
var appHostPath = Path.GetFullPath(Path.Combine("PinnedAppHost", "PinnedAppHost.csproj"));
await using var ctx = await CreateMcpClientAsync(appHostPath: appHostPath);
var snapshotCalls = 0;
var connection = new TestAppHostAuxiliaryBackchannel
{
Hash = "pinned-apphost-hash",
SocketPath = "socket.pinned",
IsInScope = false,
AppHostInfo = new AppHostInformation
{
AppHostPath = appHostPath,
ProcessId = 2002
},
GetResourceSnapshotsHandler = _ =>
{
Interlocked.Increment(ref snapshotCalls);
return Task.FromResult(new List<ResourceSnapshot>
{
new()
{
Name = "pinned-db-xyz",
DisplayName = "pinned-db",
ResourceType = "Container",
State = "Running",
McpServer = new ResourceSnapshotMcpServer
{
EndpointUrl = "http://localhost:8082/mcp",
Tools =
[
new Tool
{
Name = "query_database",
Description = "Queries the pinned database"
}
]
}
}
});
},
CallResourceMcpToolHandler = (_, _, _, _) =>
Task.FromResult(new CallToolResult
{
Content = [new TextContentBlock { Text = "pinned" }]
})
};
ctx.BackchannelMonitor!.AddConnection(connection.Hash, connection.SocketPath, connection);
var firstTools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
var secondTools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
var result = await ctx.Client.CallToolAsync("pinned_db_query_database", cancellationToken: ctx.Cts.Token).DefaultTimeout();
Assert.Contains(firstTools, tool => tool.Name == "pinned_db_query_database");
Assert.Contains(secondTools, tool => tool.Name == "pinned_db_query_database");
Assert.Equal("pinned", GetResultText(result));
Assert.Equal(1, snapshotCalls);
ctx.BackchannelMonitor.RemoveConnection(connection.Hash, connection.SocketPath);
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await ctx.Client.CallToolAsync(
"pinned_db_query_database",
cancellationToken: ctx.Cts.Token).DefaultTimeout());
Assert.Equal(McpErrorCode.InternalError, exception.ErrorCode);
Assert.Equal(
"Request failed (remote): The selected AppHost is not available. Start that AppHost and retry.",
exception.Message);
Assert.Equal(1, snapshotCalls);
}
[Fact]
public async Task McpServer_ListTools_WithPinnedAppHostAndCanceledScan_PropagatesCancellation()
{
var appHostPath = Path.GetFullPath(Path.Combine("PinnedAppHost", "PinnedAppHost.csproj"));
await using var ctx = await CreateMcpClientAsync(appHostPath: appHostPath);
ctx.BackchannelMonitor!.AddConnection(
"unrelated-apphost-hash",
"socket.unrelated",
new TestAppHostAuxiliaryBackchannel
{
Hash = "unrelated-apphost-hash",
SocketPath = "socket.unrelated",
IsInScope = true,
AppHostInfo = new AppHostInformation
{
AppHostPath = Path.Combine(ctx.Workspace.WorkspaceRoot.FullName, "UnrelatedAppHost", "UnrelatedAppHost.csproj"),
ProcessId = 1001
}
});
using var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(ctx.Cts.Token);
ctx.BackchannelMonitor.ScanAsyncCallback = cancellationToken =>
{
requestCancellation.Cancel();
cancellationToken.ThrowIfCancellationRequested();
return Task.CompletedTask;
};
var handleListToolsAsync = typeof(AgentMcpCommand)
.GetMethod("HandleListToolsAsync", BindingFlags.Instance | BindingFlags.NonPublic)!;
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
await ((ValueTask<ListToolsResult>)handleListToolsAsync.Invoke(ctx.Command, [null, requestCancellation.Token])!).AsTask().DefaultTimeout());
}
[Fact]
public async Task McpServer_ListTools_WithUnavailablePinnedAppHost_ReturnsBuiltInTools()
{
var appHostPath = Path.GetFullPath(Path.Combine("PinnedAppHost", "PinnedAppHost.csproj"));
await using var ctx = await CreateMcpClientAsync(appHostPath: appHostPath);
ctx.BackchannelMonitor!.AddConnection(
"unrelated-apphost-hash",
"socket.unrelated",
new TestAppHostAuxiliaryBackchannel
{
Hash = "unrelated-apphost-hash",
SocketPath = "socket.unrelated",
IsInScope = true,
AppHostInfo = new AppHostInformation
{
AppHostPath = Path.GetFullPath(Path.Combine("UnrelatedAppHost", "UnrelatedAppHost.csproj")),
ProcessId = 1001
}
});
var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
Assert.Equal(
KnownMcpTools.All
.Except([KnownMcpTools.ListAppHosts, KnownMcpTools.SelectAppHost])
.OrderBy(static name => name, StringComparer.Ordinal),
tools.Select(static tool => tool.Name).OrderBy(static name => name, StringComparer.Ordinal));
Assert.Equal(1, ctx.BackchannelMonitor.ScanCallCount);
}
[Fact]
public async Task McpServer_CallTool_RefreshTools_ReturnsResult()
{
await using var ctx = await CreateMcpClientAsync();
var notificationChannel = Channel.CreateUnbounded<JsonRpcNotification>();
await using var notificationHandler = ctx.Client.RegisterNotificationHandler(
NotificationMethods.ToolListChangedNotification,
(notification, cancellationToken) =>
{
notificationChannel.Writer.TryWrite(notification);
return default;
});
var result = await ctx.Client.CallToolAsync(
KnownMcpTools.RefreshTools,
cancellationToken: ctx.Cts.Token).DefaultTimeout();
// Assert - Verify result
Assert.NotNull(result);
Assert.True(result.IsError is null or false, $"Tool returned error: {GetResultText(result)}");
Assert.NotNull(result.Content);
Assert.NotEmpty(result.Content);
var textContent = result.Content[0] as TextContentBlock;
Assert.NotNull(textContent);
// Verify the text content indicates refresh success (resource tool count is 0 in this test, so total = known tools)
var expectedToolCount = KnownMcpTools.All.Count;
Assert.Equal($"Tools refreshed: {expectedToolCount} tools available", textContent.Text);
var notification = await notificationChannel.Reader.ReadAsync(ctx.Cts.Token).AsTask().DefaultTimeout();
Assert.NotNull(notification);
Assert.Equal(NotificationMethods.ToolListChangedNotification, notification.Method);
}
[Fact]
public async Task McpServer_CallTool_RefreshTools_WithPinnedAppHost_ReportsVisibleToolCount()
{
var appHostPath = Path.GetFullPath(Path.Combine("PinnedAppHost", "PinnedAppHost.csproj"));
await using var ctx = await CreateMcpClientAsync(appHostPath: appHostPath);
var connection = new TestAppHostAuxiliaryBackchannel
{
Hash = "pinned-apphost-hash",
SocketPath = "socket.pinned",
IsInScope = false,
AppHostInfo = new AppHostInformation
{
AppHostPath = appHostPath,
ProcessId = 2002
}
};
ctx.BackchannelMonitor!.AddConnection(connection.Hash, connection.SocketPath, connection);
var visibleTools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
var result = await ctx.Client.CallToolAsync(
KnownMcpTools.RefreshTools,
cancellationToken: ctx.Cts.Token).DefaultTimeout();
Assert.Equal(KnownMcpTools.All.Count - 2, visibleTools.Count);
Assert.Equal($"Tools refreshed: {visibleTools.Count} tools available", GetResultText(result));
}
[Fact]
public async Task McpServer_CallTool_RefreshTools_WithUnavailablePinnedAppHost_PropagatesSelectionError()
{
var appHostPath = Path.GetFullPath(Path.Combine("PinnedAppHost", "PinnedAppHost.csproj"));
await using var ctx = await CreateMcpClientAsync(appHostPath: appHostPath);
var connection = new TestAppHostAuxiliaryBackchannel
{
Hash = "pinned-apphost-hash",
SocketPath = "socket.pinned",
IsInScope = false,
AppHostInfo = new AppHostInformation
{
AppHostPath = appHostPath,
ProcessId = 2002
},
ResourceSnapshots =
[
new ResourceSnapshot
{
Name = "pinned-db-xyz",
DisplayName = "pinned-db",
ResourceType = "Container",
State = "Running",
McpServer = new ResourceSnapshotMcpServer
{
EndpointUrl = "http://localhost:8082/mcp",
Tools =
[
new Tool
{
Name = "query_database",
Description = "Queries the pinned database"
}
]
}
}
]
};
ctx.BackchannelMonitor!.AddConnection(connection.Hash, connection.SocketPath, connection);
var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
Assert.Contains(tools, tool => tool.Name == "pinned_db_query_database");
ctx.BackchannelMonitor.RemoveConnection(connection.Hash, connection.SocketPath);
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await ctx.Client.CallToolAsync(
KnownMcpTools.RefreshTools,
cancellationToken: ctx.Cts.Token).DefaultTimeout());
Assert.Equal(McpErrorCode.InternalError, exception.ErrorCode);
Assert.Equal(
"Request failed (remote): The selected AppHost is not available. Start that AppHost and retry.",
exception.Message);
}
[Fact]
public async Task McpServer_CallTool_RefreshTools_WithTransientDiscoveryFailure_LeavesCliToolsAvailable()
{
await using var ctx = await CreateMcpClientAsync();
var connection = new TestAppHostAuxiliaryBackchannel
{
Hash = "test-apphost-hash",
SocketPath = "socket.test",
IsInScope = true,
AppHostInfo = new AppHostInformation
{
AppHostPath = Path.Combine(ctx.Workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"),
ProcessId = 12345
},
GetResourceSnapshotsHandler = _ => throw new IOException("Transient resource discovery failure.")
};
ctx.BackchannelMonitor!.AddConnection(connection.Hash, connection.SocketPath, connection);
var result = await ctx.Client.CallToolAsync(
KnownMcpTools.RefreshTools,
cancellationToken: ctx.Cts.Token).DefaultTimeout();
var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
Assert.Equal($"Tools refreshed: {KnownMcpTools.All.Count} tools available", GetResultText(result));
Assert.Equal(
KnownMcpTools.All.OrderBy(static name => name, StringComparer.Ordinal),
tools.Select(tool => tool.Name).OrderBy(static name => name, StringComparer.Ordinal));
}
[Fact]
public async Task McpServer_ListTools_DoesNotSendToolsListChangedNotification()
{
await using var ctx = await CreateMcpClientAsync();
var mockBackchannel = new TestAppHostAuxiliaryBackchannel
{
IsInScope = true,
AppHostInfo = new AppHostInformation
{
AppHostPath = Path.Combine(ctx.Workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"),
ProcessId = 12345
},
ResourceSnapshots =
[
new ResourceSnapshot
{
Name = "db-mcp-abcd1234",
DisplayName = "db-mcp",
ResourceType = "Container",
State = "Running",
McpServer = new ResourceSnapshotMcpServer
{
EndpointUrl = "http://localhost:8080/mcp",
Tools =
[
new Tool
{
Name = "query_database",
Description = "Query a database"
}
]
}
}
]
};
ctx.BackchannelMonitor!.AddConnection(mockBackchannel.SocketPath, mockBackchannel);
var notificationCount = 0;
await using var notificationHandler = ctx.Client.RegisterNotificationHandler(
NotificationMethods.ToolListChangedNotification,
(notification, cancellationToken) =>
{
Interlocked.Increment(ref notificationCount);
return default;
});
var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
// Assert - tools should include the resource tool
Assert.NotNull(tools);
var dbMcpTool = tools.FirstOrDefault(t => t.Name == "db_mcp_query_database");
Assert.NotNull(dbMcpTool);
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
var notificationChannel = Channel.CreateUnbounded<JsonRpcNotification>();
await using var channelHandler = ctx.Client.RegisterNotificationHandler(
NotificationMethods.ToolListChangedNotification,
(notification, _) =>
{
notificationChannel.Writer.TryWrite(notification);
return default;
});
var received = false;
try
{
await notificationChannel.Reader.ReadAsync(timeoutCts.Token);
received = true;
}
catch (OperationCanceledException)
{
// Expected — no notification arrived within the timeout
}
Assert.False(received, "tools/list_changed notification should not be sent during tools/list handling");
Assert.Equal(0, notificationCount);
}
[Fact]
public void ResourceToolEntry_ToProtocolTool_PreservesOutputSchema()
{
var source = CreateResourceToolContract();
var projected = new ResourceToolEntry("resource", source).ToProtocolTool("resource_contract_tool");
Assert.True(source.OutputSchema.HasValue);
Assert.True(projected.OutputSchema.HasValue);
Assert.True(JsonElement.DeepEquals(source.OutputSchema.Value, projected.OutputSchema.Value));
}
[Theory]
[InlineData(ResourceToolContractMutation.Description)]
[InlineData(ResourceToolContractMutation.InputSchema)]
[InlineData(ResourceToolContractMutation.OutputSchema)]
[InlineData(ResourceToolContractMutation.AnnotationTitle)]
[InlineData(ResourceToolContractMutation.DestructiveHint)]
[InlineData(ResourceToolContractMutation.IdempotentHint)]
[InlineData(ResourceToolContractMutation.OpenWorldHint)]
[InlineData(ResourceToolContractMutation.ReadOnlyHint)]
public async Task McpServer_UnknownTool_SendsToolsListChangedWhenResourceToolContractChanges(
ResourceToolContractMutation mutation)
{
await using var ctx = await CreateMcpClientAsync();
var connection = CreateResourceToolConnection(
ctx.Workspace,
hash: "contract-apphost",
socketPath: "socket.contract",
displayName: "contract-resource",
toolName: "contract_tool");
SetResourceToolContract(connection, CreateResourceToolContract());
ctx.BackchannelMonitor!.AddConnection(connection.Hash, connection.SocketPath, connection);
var initialTools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
Assert.Contains(initialTools, tool => tool.Name == "contract_resource_contract_tool");
var notificationChannel = Channel.CreateUnbounded<JsonRpcNotification>();
await using var notificationHandler = ctx.Client.RegisterNotificationHandler(
NotificationMethods.ToolListChangedNotification,
(notification, _) =>
{
notificationChannel.Writer.TryWrite(notification);
return default;
});
var changedTool = CreateResourceToolContract();
ApplyResourceToolContractMutation(changedTool, mutation);
SetResourceToolContract(connection, changedTool);
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await ctx.Client.CallToolAsync(
"force_resource_tool_contract_refresh",
cancellationToken: ctx.Cts.Token).DefaultTimeout());
Assert.Equal(McpErrorCode.MethodNotFound, exception.ErrorCode);
var notification = await notificationChannel.Reader.ReadAsync(ctx.Cts.Token).AsTask().DefaultTimeout();
Assert.Equal(NotificationMethods.ToolListChangedNotification, notification.Method);
}
[Fact]
public async Task McpServer_UnknownTool_DoesNotSendToolsListChangedForEquivalentReorderedSchema()
{
await using var ctx = await CreateMcpClientAsync();
var connection = CreateResourceToolConnection(
ctx.Workspace,
hash: "contract-apphost",
socketPath: "socket.contract",
displayName: "contract-resource",
toolName: "contract_tool");
SetResourceToolContract(connection, CreateResourceToolContract());
ctx.BackchannelMonitor!.AddConnection(connection.Hash, connection.SocketPath, connection);
var initialTools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
Assert.Contains(initialTools, tool => tool.Name == "contract_resource_contract_tool");
var notificationChannel = Channel.CreateUnbounded<JsonRpcNotification>();
await using var notificationHandler = ctx.Client.RegisterNotificationHandler(
NotificationMethods.ToolListChangedNotification,
(notification, _) =>
{
notificationChannel.Writer.TryWrite(notification);
return default;
});
var equivalentTool = CreateResourceToolContract();
equivalentTool.InputSchema = ParseJsonElement(
"""
{
"properties": {
"second": { "type": "integer" },
"first": { "type": "string" }
},
"type": "object"
}
""");
SetResourceToolContract(connection, equivalentTool);
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await ctx.Client.CallToolAsync(
"force_equivalent_resource_tool_contract_refresh",
cancellationToken: ctx.Cts.Token).DefaultTimeout());
Assert.Equal(McpErrorCode.MethodNotFound, exception.ErrorCode);
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
var received = false;
try
{
await notificationChannel.Reader.ReadAsync(timeoutCts.Token);
received = true;
}
catch (OperationCanceledException)
{
// Expected because an equivalent JSON object does not change the listed tool contract.
}
Assert.False(received);
}
[Fact]
public async Task McpServer_ListTools_CachesResourceToolMap_WhenConnectionUnchanged()
{
await using var ctx = await CreateMcpClientAsync();
var getResourceSnapshotsCallCount = 0;
var mockBackchannel = new TestAppHostAuxiliaryBackchannel
{
IsInScope = true,
AppHostInfo = new AppHostInformation
{
AppHostPath = Path.Combine(ctx.Workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj"),
ProcessId = 12345
},
GetResourceSnapshotsHandler = (ct) =>
{
Interlocked.Increment(ref getResourceSnapshotsCallCount);
return Task.FromResult(new List<ResourceSnapshot>
{
new ResourceSnapshot
{
Name = "db-mcp-xyz",
DisplayName = "db-mcp",
ResourceType = "Container",
State = "Running",
McpServer = new ResourceSnapshotMcpServer
{
EndpointUrl = "http://localhost:8080/mcp",
Tools =
[
new Tool
{
Name = "query_db",
Description = "Query the database"
}
]
}
}
});
}
};
ctx.BackchannelMonitor!.AddConnection(mockBackchannel.SocketPath, mockBackchannel);
var tools1 = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
var tools2 = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
// Assert - Both calls return the resource tool
Assert.Contains(tools1, t => t.Name == "db_mcp_query_db");
Assert.Contains(tools2, t => t.Name == "db_mcp_query_db");
// The resource tool map should be cached after the first call,
// so GetResourceSnapshotsAsync should only be called once (during the first refresh).
// Before the fix, TryGetResourceToolMap always returned false due to
// SelectedAppHostPath vs SelectedConnection path mismatch, causing every
// ListTools call to trigger a full refresh.
Assert.Equal(1, getResourceSnapshotsCallCount);
}
[Fact]
public async Task McpServer_ListTools_DoesNotReuseCachedResourceTools_WhenConnectionBecomesOutOfScope()
{
await using var ctx = await CreateMcpClientAsync();
var connection = CreateResourceToolConnection(
ctx.Workspace,
hash: "apphost-a",
socketPath: "socket.a",
displayName: "cached-resource",
toolName: "cached_tool");
ctx.BackchannelMonitor!.AddConnection(connection.Hash, connection.SocketPath, connection);
var initialTools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
Assert.Contains(initialTools, tool => tool.Name == "cached_resource_cached_tool");
connection.IsInScope = false;
var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
Assert.Equal(
KnownMcpTools.All.OrderBy(static name => name, StringComparer.Ordinal),
tools.Select(tool => tool.Name).OrderBy(static name => name, StringComparer.Ordinal));
}
[Fact]
public async Task McpServer_ListTools_DoesNotReuseCachedResourceTools_WhenSelectionBecomesAmbiguous()
{
await using var ctx = await CreateMcpClientAsync();
var connectionA = CreateResourceToolConnection(
ctx.Workspace,
hash: "apphost-a",
socketPath: "socket.a",
displayName: "resource-a",
toolName: "tool_a");
ctx.BackchannelMonitor!.AddConnection(connectionA.Hash, connectionA.SocketPath, connectionA);
var initialTools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
Assert.Contains(initialTools, tool => tool.Name == "resource_a_tool_a");
var connectionB = CreateResourceToolConnection(
ctx.Workspace,
hash: "apphost-b",
socketPath: "socket.b",
displayName: "resource-b",
toolName: "tool_b");
ctx.BackchannelMonitor.AddConnection(connectionB.Hash, connectionB.SocketPath, connectionB);
var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
Assert.Equal(
KnownMcpTools.All.OrderBy(static name => name, StringComparer.Ordinal),
tools.Select(static tool => tool.Name).OrderBy(static name => name, StringComparer.Ordinal));
}
[Fact]
public async Task McpServer_ListTools_RefreshesCachedResourceTools_WhenConnectionAtSamePathIsReplaced()
{
await using var ctx = await CreateMcpClientAsync();
var appHostPath = Path.Combine(ctx.Workspace.WorkspaceRoot.FullName, "TestAppHost", "TestAppHost.csproj");
var connectionA = CreateResourceToolConnection(
ctx.Workspace,
hash: "apphost-a",
socketPath: "socket.a",
displayName: "resource-a",
toolName: "tool_a",
appHostPath: appHostPath);
ctx.BackchannelMonitor!.AddConnection(connectionA.Hash, connectionA.SocketPath, connectionA);
var initialTools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
Assert.Contains(initialTools, tool => tool.Name == "resource_a_tool_a");
ctx.BackchannelMonitor.RemoveConnection(connectionA.Hash, connectionA.SocketPath);
var connectionB = CreateResourceToolConnection(
ctx.Workspace,
hash: "apphost-b",
socketPath: "socket.b",
displayName: "resource-b",
toolName: "tool_b",
appHostPath: appHostPath);
ctx.BackchannelMonitor.AddConnection(connectionB.Hash, connectionB.SocketPath, connectionB);
var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
Assert.Equal(
KnownMcpTools.All.Append("resource_b_tool_b").OrderBy(static name => name, StringComparer.Ordinal),
tools.Select(tool => tool.Name).OrderBy(static name => name, StringComparer.Ordinal));
}
[Fact]
public async Task McpServer_CallTool_UnknownTool_ReturnsError()
{
await using var ctx = await CreateMcpClientAsync();
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await ctx.Client.CallToolAsync(
"nonexistent_tool_that_does_not_exist",
cancellationToken: ctx.Cts.Token).DefaultTimeout());
Assert.Equal(McpErrorCode.MethodNotFound, exception.ErrorCode);
}
[Fact]
public async Task McpServer_DashboardOnlyMode_ListTools_ReturnsOnlyTelemetryTools()
{
await using var ctx = await CreateMcpClientAsync(dashboardUrl: "http://localhost:18888");
var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
Assert.NotNull(tools);
Assert.Equal(3, tools.Count);
Assert.Collection(tools.OrderBy(t => t.Name),
tool => Assert.Equal(KnownMcpTools.ListStructuredLogs, tool.Name),
tool => Assert.Equal(KnownMcpTools.ListTraceStructuredLogs, tool.Name),
tool => Assert.Equal(KnownMcpTools.ListTraces, tool.Name));
}
[Fact]
public async Task McpServer_DashboardOnlyMode_ListTools_HasReadOnlyAnnotations()
{
await using var ctx = await CreateMcpClientAsync(dashboardUrl: "http://localhost:18888");
var tools = await ctx.Client.ListToolsAsync(cancellationToken: ctx.Cts.Token).DefaultTimeout();
Assert.All(tools, static tool => AssertToolAnnotations(tool, readOnly: true, destructive: false));
}
[Theory]
[InlineData(KnownMcpTools.ListResources)]
[InlineData(KnownMcpTools.WaitForResources)]
public async Task McpServer_DashboardOnlyMode_CallNonTelemetryTool_ReturnsError(string toolName)
{
await using var ctx = await CreateMcpClientAsync(dashboardUrl: "http://localhost:18888");
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await ctx.Client.CallToolAsync(
toolName,
cancellationToken: ctx.Cts.Token).DefaultTimeout());
Assert.Equal(McpErrorCode.MethodNotFound, exception.ErrorCode);
}
[Fact]
public async Task McpServer_WithInvalidDashboardUrl_ReturnsInvalidCommand()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
using var serviceProvider = services.BuildServiceProvider();
var agentMcpCommand = serviceProvider.GetRequiredService<AgentMcpCommand>();
var rootCommand = serviceProvider.GetRequiredService<RootCommand>();
var parseResult = rootCommand.Parse("agent mcp --dashboard-url not-a-url");
var result = await agentMcpCommand.ExecuteCommandAsync(parseResult, CancellationToken.None).DefaultTimeout();
Assert.Equal(CliExitCodes.InvalidCommand, result.ExitCode);
}
[Fact]
public async Task McpServer_WithCredentialBearingInvalidDashboardUrl_DoesNotLeakValue()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var sink = new TestSink();
var loggerFactory = new TestLoggerFactory(sink, enabled: true);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
services.Replace(ServiceDescriptor.Singleton<ILogger<AgentMcpCommand>>(
new TestLogger<AgentMcpCommand>(loggerFactory)));
using var serviceProvider = services.BuildServiceProvider();
var agentMcpCommand = serviceProvider.GetRequiredService<AgentMcpCommand>();
var rootCommand = serviceProvider.GetRequiredService<RootCommand>();
var parseResult = rootCommand.Parse(
"agent mcp --dashboard-url " +
"ftp://request-user:request-password@example.com/path?token=request-secret#request-fragment");
var result = await agentMcpCommand.ExecuteCommandAsync(
parseResult,
TestContext.Current.CancellationToken).DefaultTimeout();
Assert.Equal(CliExitCodes.InvalidCommand, result.ExitCode);
Assert.Equal(
string.Format(
CultureInfo.CurrentCulture,
TelemetryCommandStrings.DashboardUrlInvalid,
TelemetryCommandStrings.InvalidDashboardUrlDisplayValue),
result.ErrorMessage);
Assert.Collection(
sink.Writes,
write =>
{
Assert.Equal(
$"Invalid --dashboard-url: {TelemetryCommandStrings.InvalidDashboardUrlDisplayValue}",
write.Message);
Assert.Null(write.Exception);
});
}
[Theory]
[InlineData("agent mcp --apphost Pinned.AppHost.csproj --dashboard-url http://localhost:18888")]
[InlineData("agent mcp --apphost --dashboard-url http://localhost:18888")]
[InlineData("agent mcp --project Pinned.AppHost.csproj --dashboard-url http://localhost:18888")]
[InlineData("agent mcp --project --dashboard-url http://localhost:18888")]
public async Task McpServer_WithAppHostAndDashboardUrl_ReturnsInvalidCommandBeforeResolutionOrServerStart(
string commandLine)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var projectResolutionCallCount = 0;
var projectLocator = new TestProjectLocator
{
UseOrFindAppHostProjectFileWithBehaviorAsyncCallback = (projectFile, _, _, _) =>
{
projectResolutionCallCount++;
var selectedProjectFile = Assert.IsType<FileInfo>(projectFile);
return Task.FromResult(new AppHostProjectSearchResult(selectedProjectFile, [selectedProjectFile]));
}
};
using var testTransport = new TestMcpServerTransport
{
CreateTransportException = new InvalidOperationException("The server transport must not start.")
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.ProjectLocatorFactory = _ => projectLocator;
options.McpServerTransportFactory = _ => testTransport;
});
using var serviceProvider = services.BuildServiceProvider();
var command = serviceProvider.GetRequiredService<AgentMcpCommand>();
var rootCommand = serviceProvider.GetRequiredService<RootCommand>();
var parseResult = rootCommand.Parse(commandLine);
var result = await command.ExecuteCommandAsync(
parseResult,
TestContext.Current.CancellationToken).DefaultTimeout();
Assert.Equal(CliExitCodes.InvalidCommand, result.ExitCode);
Assert.Equal(TelemetryCommandStrings.DashboardUrlAndAppHostExclusive, result.ErrorMessage);
Assert.Equal(0, projectResolutionCallCount);
Assert.Equal(0, testTransport.CreateTransportCallCount);
}
[Fact]
public async Task McpServer_WithPinnedAppHostFile_StoresResolvedProjectPath()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("PinnedAppHost");
var appHostProjectFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "PinnedAppHost.csproj"));
await File.WriteAllTextAsync(appHostProjectFile.FullName, "Not a real apphost", TestContext.Current.CancellationToken);
var serverStartException = new InvalidOperationException("Server transport should be reached for a valid AppHost.");
using var testTransport = new TestMcpServerTransport
{
CreateTransportException = serverStartException
};
var monitor = new TestAuxiliaryBackchannelMonitor();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.McpServerTransportFactory = _ => testTransport;
options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
options.AppHostProjectFactory = _ => new TestAppHostProjectFactory();
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AgentMcpCommand>();
var rootCommand = provider.GetRequiredService<RootCommand>();
var parseResult = rootCommand.Parse($"agent mcp --apphost \"{appHostProjectFile.FullName}\"");
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => command.ExecuteCommandAsync(parseResult, TestContext.Current.CancellationToken)).DefaultTimeout();
Assert.Same(serverStartException, exception);
Assert.Equal(
PathNormalizer.ResolveToFilesystemPath(PathNormalizer.ResolveSymlinks(appHostProjectFile.FullName)),
monitor.SelectedAppHostPath);
}
[Fact]
public async Task McpServer_WithPinnedAppHostDirectory_StoresResolvedProjectPath()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("PinnedAppHost");
var appHostProjectFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "PinnedAppHost.csproj"));
await File.WriteAllTextAsync(appHostProjectFile.FullName, "Not a real apphost", TestContext.Current.CancellationToken);
var serverStartException = new InvalidOperationException("Server transport should be reached for a valid AppHost.");
using var testTransport = new TestMcpServerTransport
{
CreateTransportException = serverStartException
};
var monitor = new TestAuxiliaryBackchannelMonitor();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.McpServerTransportFactory = _ => testTransport;
options.AuxiliaryBackchannelMonitorFactory = _ => monitor;
options.AppHostProjectFactory = _ => new TestAppHostProjectFactory();
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AgentMcpCommand>();
var rootCommand = provider.GetRequiredService<RootCommand>();
var parseResult = rootCommand.Parse($"agent mcp --apphost \"{appHostDirectory.FullName}\"");
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => command.ExecuteCommandAsync(parseResult, TestContext.Current.CancellationToken)).DefaultTimeout();
Assert.Same(serverStartException, exception);
Assert.Equal(
PathNormalizer.ResolveToFilesystemPath(PathNormalizer.ResolveSymlinks(appHostProjectFile.FullName)),
monitor.SelectedAppHostPath);
}
[Fact]
public async Task McpServer_WithMissingPinnedAppHost_FailsBeforeTransportStarts()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var missingPath = Path.Combine(workspace.WorkspaceRoot.FullName, "MissingAppHost");
using var testTransport = new TestMcpServerTransport
{
CreateTransportException = new InvalidOperationException("The server transport must not start.")
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.McpServerTransportFactory = _ => testTransport;
options.AppHostProjectFactory = _ => new TestAppHostProjectFactory();
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AgentMcpCommand>();
var rootCommand = provider.GetRequiredService<RootCommand>();
var parseResult = rootCommand.Parse($"agent mcp --apphost \"{missingPath}\"");
var result = await command.ExecuteCommandAsync(
parseResult,
TestContext.Current.CancellationToken).DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToFindProject, result.ExitCode);
Assert.Equal(InteractionServiceStrings.ProjectOptionDoesntExist, result.ErrorMessage);
}
[Fact]
public async Task McpServer_WithAmbiguousPinnedAppHostDirectory_FailsBeforeTransportStarts()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("AmbiguousAppHosts");
await File.WriteAllTextAsync(
Path.Combine(appHostDirectory.FullName, "First.AppHost.csproj"),
"Not a real apphost",
TestContext.Current.CancellationToken);
await File.WriteAllTextAsync(
Path.Combine(appHostDirectory.FullName, "Second.AppHost.csproj"),
"Not a real apphost",
TestContext.Current.CancellationToken);
using var testTransport = new TestMcpServerTransport
{
CreateTransportException = new InvalidOperationException("The server transport must not start.")
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.McpServerTransportFactory = _ => testTransport;
options.AppHostProjectFactory = _ => new TestAppHostProjectFactory();
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<AgentMcpCommand>();
var rootCommand = provider.GetRequiredService<RootCommand>();
var parseResult = rootCommand.Parse($"agent mcp --apphost \"{appHostDirectory.FullName}\"");
var result = await command.ExecuteCommandAsync(
parseResult,
TestContext.Current.CancellationToken).DefaultTimeout();
Assert.Equal(CliExitCodes.FailedToFindProject, result.ExitCode);
Assert.Equal(
InteractionServiceStrings.ProjectOptionSpecifiedDirectoryContainsMultipleAppHosts,
result.ErrorMessage);
}
private static TestAppHostAuxiliaryBackchannel CreateResourceToolConnection(
TemporaryWorkspace workspace,
string hash,
string socketPath,
string displayName,
string toolName,
string? appHostPath = null)
{
return new TestAppHostAuxiliaryBackchannel
{
Hash = hash,
SocketPath = socketPath,
IsInScope = true,
AppHostInfo = new AppHostInformation
{
AppHostPath = appHostPath ?? Path.Combine(workspace.WorkspaceRoot.FullName, hash, $"{hash}.csproj"),
ProcessId = 12345
},
ResourceSnapshots =
[
new ResourceSnapshot
{
Name = $"{displayName}-runtime",
DisplayName = displayName,
ResourceType = "Container",
State = "Running",
McpServer = new ResourceSnapshotMcpServer
{
EndpointUrl = "http://localhost:8080/mcp",
Tools =
[
new Tool
{
Name = toolName,
Description = $"Runs {toolName}"
}
]
}
}
]
};
}
private static Tool CreateResourceToolContract()
{
return new Tool
{
Name = "contract_tool",
Description = "Initial description",
InputSchema = ParseJsonElement(
"""
{
"type": "object",
"properties": {
"first": { "type": "string" },
"second": { "type": "integer" }
}
}
"""),
OutputSchema = ParseJsonElement(
"""
{
"type": "object",
"properties": {
"result": { "type": "string" }
}
}
"""),
Annotations = new ToolAnnotations
{
Title = "Initial title",
DestructiveHint = false,
IdempotentHint = true,
OpenWorldHint = false,
ReadOnlyHint = true
}
};
}
private static void ApplyResourceToolContractMutation(Tool tool, ResourceToolContractMutation mutation)
{
switch (mutation)
{
case ResourceToolContractMutation.Description:
tool.Description = "Changed description";
break;
case ResourceToolContractMutation.InputSchema:
tool.InputSchema = ParseJsonElement(
"""
{
"type": "object",
"properties": {
"first": { "type": "string" },
"second": { "type": "integer" },
"third": { "type": "boolean" }
}
}
""");
break;
case ResourceToolContractMutation.OutputSchema:
tool.OutputSchema = ParseJsonElement(
"""
{
"type": "object",
"properties": {
"result": { "type": "string" },
"metadata": { "type": "object" }
}
}
""");
break;
case ResourceToolContractMutation.AnnotationTitle:
tool.Annotations!.Title = "Changed title";
break;
case ResourceToolContractMutation.DestructiveHint:
tool.Annotations!.DestructiveHint = true;
break;
case ResourceToolContractMutation.IdempotentHint:
tool.Annotations!.IdempotentHint = false;
break;
case ResourceToolContractMutation.OpenWorldHint:
tool.Annotations!.OpenWorldHint = true;
break;
case ResourceToolContractMutation.ReadOnlyHint:
tool.Annotations!.ReadOnlyHint = false;
break;
default:
throw new ArgumentOutOfRangeException(nameof(mutation));
}
}
private static void SetResourceToolContract(TestAppHostAuxiliaryBackchannel connection, Tool tool)
{
connection.ResourceSnapshots =
[
new ResourceSnapshot
{
Name = "contract-resource-runtime",
DisplayName = "contract-resource",
ResourceType = "Container",
State = "Running",
McpServer = new ResourceSnapshotMcpServer
{
EndpointUrl = "http://localhost:8080/mcp",
Tools = [tool]
}
}
];
}
private static JsonElement ParseJsonElement(string json)
{
using var document = JsonDocument.Parse(json);
return document.RootElement.Clone();
}
private static void AssertFixedToolAnnotations(IList<McpClientTool> tools, params string[] excludedToolNames)
{
var expectedToolNames = KnownMcpTools.All
.Except(excludedToolNames)
.OrderBy(static name => name, StringComparer.Ordinal);
Assert.Equal(
expectedToolNames,
tools.Select(static tool => tool.Name).OrderBy(static name => name, StringComparer.Ordinal));
foreach (var tool in tools)
{
AssertToolAnnotations(
tool,
readOnly: tool.Name is not (KnownMcpTools.ExecuteResourceCommand or KnownMcpTools.SelectAppHost),
destructive: tool.Name == KnownMcpTools.ExecuteResourceCommand);
}
}
private static void AssertToolAnnotations(McpClientTool tool, bool readOnly, bool destructive)
{
var annotations = Assert.IsType<ToolAnnotations>(tool.ProtocolTool.Annotations);
Assert.Equal(readOnly, annotations.ReadOnlyHint);
Assert.Equal(destructive, annotations.DestructiveHint);
}
private static string GetResultText(CallToolResult result)
{
if (result.Content?.FirstOrDefault() is TextContentBlock textContent)
{
return textContent.Text;
}
return string.Empty;
}
private static JsonDocument GetMarkedJson(CallToolResult result, string marker)
{
var text = GetResultText(result);
var markerIndex = text.IndexOf(marker, StringComparison.Ordinal);
Assert.True(markerIndex >= 0, $"Result should contain the '{marker}' marker.");
return JsonDocument.Parse(text[(markerIndex + marker.Length)..].Trim());
}
public enum ResourceToolContractMutation
{
Description,
InputSchema,
OutputSchema,
AnnotationTitle,
DestructiveHint,
IdempotentHint,
OpenWorldHint,
ReadOnlyHint
}
}
internal sealed class McpTestContext(
McpClient client,
CancellationTokenSource cts,
TemporaryWorkspace workspace,
Task serverRunTask,
TestMcpServerTransport testTransport,
AgentMcpCommand command,
ServiceProvider serviceProvider,
ILoggerFactory loggerFactory) : IAsyncDisposable
{
public McpClient Client => client;
public CancellationTokenSource Cts => cts;
public TemporaryWorkspace Workspace => workspace;
public TestAuxiliaryBackchannelMonitor? BackchannelMonitor { get; init; }
public AgentMcpCommand Command => command;
public async ValueTask DisposeAsync()
{
await client.DisposeAsync();
await cts.CancelAsync();
try
{
await serverRunTask.WaitAsync(TimeSpan.FromSeconds(2));
}
catch (OperationCanceledException)
{
}
catch (TimeoutException)
{
}
testTransport.Dispose();
await serviceProvider.DisposeAsync();
workspace.Dispose();
loggerFactory.Dispose();
cts.Dispose();
}
}