1. MCP sessions are never re-initialized after the server returns HTTP 404 (expired session)
Description
After the chat has been idle for a while, MCP tools stop working. The model still sees the tools, but every tool invocation fails. The only way to recover is to restart the application.
The MCP specification (2025-11-25, Transports → Session Management) says:
When a client receives HTTP 404 in response to a request containing an
MCP-Session-Id, it MUST start a new session by sending a newInitializeRequestwithout a session ID attached.
https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
MCP servers end idle sessions after a timeout, and lose all sessions when they restart. The AI Management module does not handle this case.
Steps to reproduce
- Configure an MCP server with the Streamable HTTP transport and assign it to a workspace.
- Open the chat for that workspace and invoke an MCP tool. It works.
- Make the server drop the session: wait for its idle timeout, or restart the MCP server.
- Ask the chat something that invokes the same MCP tool.
Expected: The client receives 404, creates a new MCP session (a new initialize request without Mcp-Session-Id), and the tool call succeeds.
Actual: The tool call fails with an HttpRequestException (404). Every later call fails the same way until the application is restarted.
Root cause (from the module source)
McpToolProvidercaches sessions in a staticConcurrentDictionary<string, IMcpClientSession> ActiveSessionsfor the lifetime of the process.GetOrCreateSessionAsyncreuses a cached session wheneverIsConnectedis true.McpClientSession.IsConnectedis!_disposed. It does not reflect the real state of the underlyingMcpClientor its transport.- Nothing in production code disposes or evicts a cached session when a request fails, so
IsConnectedstays true for a dead session. The "replace disconnected session" branch inGetOrCreateSessionAsyncis therefore never reached. McpClientSessionalso caches its tool list (_cachedTools). TheMcpClientToolinstances returned to the chat are bound to the originalMcpClient, so the stale session ID is reused on every invocation.- In
ModelContextProtocol0.6.0-preview.1, a 404 surfaces as a genericHttpRequestException(EnsureSuccessStatusCode). Newer SDK versions mark the transport as expired, but they still leave re-initialization to the caller. Upgrading the SDK alone will not fix this.
Related issues (same root cause: sessions are cached indefinitely)
2. Changing an MCP server's configuration does not invalidate the cached session
The cache key in McpToolProvider.GetOrCreateSessionAsync is {McpServerId}_{Name}. Changing the endpoint, custom headers, auth type or API key/token of an MCP server leaves the key unchanged. The existing session, created with the old configuration, keeps being used until the application restarts.
Steps to reproduce: Use an MCP server in the chat. Then change its endpoint or credentials in AI Management → MCP Servers, and use the chat again.
Expected: The new configuration is used.
Actual: The old endpoint and credentials are still used. If only the server name changes, a new session is created, but the old one stays in ActiveSessions and is never disposed.
3. Changes to the tool list on the MCP server are never picked up
McpClientSession.GetToolsAsync caches the result of ListToolsAsync in _cachedTools for the lifetime of the session. Because sessions are never evicted (see 1), the tool list is effectively cached for the lifetime of the process. The module also does not handle the notifications/tools/list_changed notification.
Steps to reproduce: Use an MCP server in the chat. Then add, remove or change a tool on the MCP server, and use the chat again.
Expected: The chat sees the updated tool list.
Actual: The chat keeps the original tool list until the application restarts. Removed tools are still offered to the model, and new tools never appear.
4. MCP sessions are not disposed on application shutdown
McpToolProvider.DisposeAllSessionsAsync() exists, but it is only called from tests (McpToolProvider_Concurrency_Tests). VoloAbpAIAIManagementModule has no OnApplicationShutdown that calls it.
Impact: On shutdown, the cached McpClient instances are not closed in an orderly way:
- for Streamable HTTP, the session
DELETErequest is never sent to the server, so the server keeps the session until its own timeout; - for stdio, cleanup of the child process depends on the parent process exiting.
1 Answer(s)
-
1
Hi,
All four are bugs in the AI Management module. The MCP sessions are cached for the whole process and nothing refreshes or removes them.
You can replace
McpToolProviderin your Domain project for now. It creates a new session when the MCP server configuration changes. When a tool call gets 404, it starts a new session and retries the call once.using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; using System.Security.Cryptography; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Volo.Abp.DependencyInjection; using Volo.AIManagement.Mcp; namespace YourProjectName; [Dependency(ReplaceServices = true)] [ExposeServices(typeof(IMcpToolProvider), typeof(McpToolProvider))] public class MyMcpToolProvider : McpToolProvider { public MyMcpToolProvider( ILogger<McpToolProvider> logger, IMcpServerConfigurationStore mcpServerConfigurationStore, IOptions<McpClientFactoryOptions> options, IServiceProvider serviceProvider, IMcpToolCatalogCache toolCatalogCache) : base(logger, mcpServerConfigurationStore, options, serviceProvider, toolCatalogCache) { } protected override async Task<IReadOnlyList<AIFunction>> GetToolsForConfigurationAsync( McpClientCreationConfiguration configuration, string workspaceName, CancellationToken cancellationToken) { IMcpClientSession? session = null; try { session = await GetOrCreateSessionAsync(configuration, cancellationToken); var tools = await session.GetToolsAsync(cancellationToken); await ToolCatalogCache.SetAsync( configuration.McpServerId, McpToolCatalogFingerprint.Compute(configuration), McpToolCatalogMappingHelper.FromAIFunctions(tools), cancellationToken: cancellationToken); return tools .Select(tool => (AIFunction)new SessionRecoveringMcpTool(tool, this, configuration)) .ToList(); } catch (Exception ex) { if (session != null) { await RemoveSessionAsync(configuration, session); } Logger.LogWarning(ex, "Failed to get tools from MCP server '{ServerName}' for workspace '{WorkspaceName}'", configuration.Name, workspaceName); return Array.Empty<AIFunction>(); } } protected override async Task<IMcpClientSession> GetOrCreateSessionAsync( McpClientCreationConfiguration configuration, CancellationToken cancellationToken) { // The key contains the full configuration, so editing an MCP server creates a new session. var sessionKey = GetSessionKey(configuration); if (ActiveSessions.TryGetValue(sessionKey, out var existingSession) && existingSession.IsConnected) { return existingSession; } var gate = SessionCreateLocks.GetOrAdd(sessionKey, _ => new SemaphoreSlim(1, 1)); await gate.WaitAsync(cancellationToken); try { if (ActiveSessions.TryGetValue(sessionKey, out existingSession) && existingSession.IsConnected) { return existingSession; } if (existingSession != null) { ActiveSessions.TryRemove(sessionKey, out _); await DisposeSessionAsync(existingSession); } var session = await CreateSessionAsync(configuration, cancellationToken); ActiveSessions[sessionKey] = session; return session; } finally { gate.Release(); } } public virtual async Task<object?> InvokeToolAsync( McpClientCreationConfiguration configuration, string toolName, AIFunctionArguments arguments, CancellationToken cancellationToken) { var session = await GetOrCreateSessionAsync(configuration, cancellationToken); try { return await InvokeToolAsync(session, toolName, arguments, cancellationToken); } catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { // The MCP server dropped the session (idle timeout or restart), start a new one and retry once. await RemoveSessionAsync(configuration, session); session = await GetOrCreateSessionAsync(configuration, cancellationToken); return await InvokeToolAsync(session, toolName, arguments, cancellationToken); } } protected virtual async Task<object?> InvokeToolAsync( IMcpClientSession session, string toolName, AIFunctionArguments arguments, CancellationToken cancellationToken) { var tools = await session.GetToolsAsync(cancellationToken); var tool = tools.FirstOrDefault(t => t.Name == toolName) ?? throw new InvalidOperationException($"The MCP tool '{toolName}' is not available anymore."); return await tool.InvokeAsync(arguments, cancellationToken); } protected virtual async Task RemoveSessionAsync(McpClientCreationConfiguration configuration, IMcpClientSession session) { // Only remove the given session, another request may have replaced it already. if (ActiveSessions.TryRemove(new KeyValuePair<string, IMcpClientSession>(GetSessionKey(configuration), session))) { await DisposeSessionAsync(session); } } protected virtual string GetSessionKey(McpClientCreationConfiguration configuration) { var json = JsonSerializer.Serialize(new { Fingerprint = McpToolCatalogFingerprint.Compute(configuration), configuration.Name, configuration.ApiKey, configuration.CustomAuthHeaderValue }); return $"{configuration.McpServerId}_{Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(json)))}"; } protected virtual async Task DisposeSessionAsync(IMcpClientSession session) { try { await session.DisposeAsync(); } catch (Exception ex) { Logger.LogDebug(ex, "Failed to dispose MCP session '{SessionId}'", session.SessionId); } } } public class SessionRecoveringMcpTool : DelegatingAIFunction { protected MyMcpToolProvider Provider { get; } protected McpClientCreationConfiguration Configuration { get; } public SessionRecoveringMcpTool( AIFunction innerFunction, MyMcpToolProvider provider, McpClientCreationConfiguration configuration) : base(innerFunction) { Provider = provider; Configuration = configuration; } protected override async ValueTask<object?> InvokeCoreAsync( AIFunctionArguments arguments, CancellationToken cancellationToken) { return await Provider.InvokeToolAsync(Configuration, Name, arguments, cancellationToken); } }Then close the sessions on shutdown in your Domain module:
using System.Threading.Tasks; using Volo.Abp; using Volo.AIManagement.Mcp; public override async Task OnApplicationShutdownAsync(ApplicationShutdownContext context) { await McpToolProvider.DisposeAllSessionsAsync(); }This doesn't refresh the tool list of a running session. The updated tool list is used in the next chat request after a new session is created, for example after a 404 or a configuration change. Sessions of an old configuration are closed on shutdown.
We will fix all four in the module, including handling
notifications/tools/list_changed. Your ticket has been refunded.Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)