Browse Source

Add dynamic MCP tool definition caching and registration

Introduces McpToolDefinition model and McpToolsCacheService to fetch and cache tool definitions from the server, with fallback to cache if the server is unavailable. Updates McpServerService to dynamically register tools based on cached or fetched definitions, and adds related constants and paths. This enables more flexible and up-to-date tool management in the MCP server.
pull/24677/head
Mansur Besleney 1 month ago
parent
commit
7821fc173b
  1. 1
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliConsts.cs
  2. 1
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliPaths.cs
  3. 23
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Models/McpToolDefinition.cs
  4. 41
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpHttpClientService.cs
  5. 150
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpServerService.cs
  6. 160
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpToolsCacheService.cs

1
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliConsts.cs

@ -23,5 +23,6 @@ public static class CliConsts
public static class MemoryKeys
{
public const string LatestCliVersionCheckDate = "LatestCliVersionCheckDate";
public const string McpToolsLastFetchDate = "McpToolsLastFetchDate";
}
}

1
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliPaths.cs

@ -14,6 +14,7 @@ public static class CliPaths
public static string Memory => Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location)!, "memory.bin");
public static string Build => Path.Combine(AbpRootPath, "build");
public static string Lic => Path.Combine(Path.GetTempPath(), Encoding.ASCII.GetString(new byte[] { 65, 98, 112, 76, 105, 99, 101, 110, 115, 101, 46, 98, 105, 110 }));
public static string McpToolsCache => Path.Combine(Root, "mcp-tools.json");
public static readonly string AbpRootPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".abp");
}

23
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Models/McpToolDefinition.cs

@ -0,0 +1,23 @@
using System.Collections.Generic;
namespace Volo.Abp.Cli.Commands.Models;
public class McpToolDefinition
{
public string Name { get; set; }
public string Description { get; set; }
public McpToolInputSchema InputSchema { get; set; }
}
public class McpToolInputSchema
{
public Dictionary<string, McpToolProperty> Properties { get; set; }
public List<string> Required { get; set; }
}
public class McpToolProperty
{
public string Type { get; set; }
public string Description { get; set; }
}

41
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpHttpClientService.cs

@ -1,9 +1,11 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Volo.Abp.Cli.Commands.Models;
using Volo.Abp.Cli.Http;
using Volo.Abp.DependencyInjection;
@ -105,5 +107,44 @@ public class McpHttpClientService : ITransientDependency
return false;
}
}
public async Task<List<McpToolDefinition>> GetToolDefinitionsAsync(bool useLocalServer = false)
{
var baseUrl = LocalMcpServerUrl; //useLocalServer ? LocalMcpServerUrl : DefaultMcpServerUrl;
var url = $"{baseUrl}/tools";
try
{
using var httpClient = _httpClientFactory.CreateClient(needsAuthentication: true);
var response = await httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
await Console.Error.WriteLineAsync($"[MCP] Failed to fetch tool definitions: {response.StatusCode} - {errorContent}");
throw new Exception($"Failed to fetch tool definitions: {response.StatusCode}");
}
var responseContent = await response.Content.ReadAsStringAsync();
// The API returns { tools: [...] } format
var result = JsonSerializer.Deserialize<McpToolsResponse>(responseContent, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return result?.Tools ?? new List<McpToolDefinition>();
}
catch (Exception ex)
{
await Console.Error.WriteLineAsync($"[MCP] Error fetching tool definitions: {ex.Message}");
throw;
}
}
private class McpToolsResponse
{
public List<McpToolDefinition> Tools { get; set; }
}
}

150
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpServerService.cs

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@ -7,6 +8,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using Volo.Abp.Cli.Commands.Models;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.Cli.Commands.Services;
@ -14,11 +16,14 @@ namespace Volo.Abp.Cli.Commands.Services;
public class McpServerService : ITransientDependency
{
private readonly McpHttpClientService _mcpHttpClient;
private readonly McpToolsCacheService _toolsCacheService;
public McpServerService(
McpHttpClientService mcpHttpClient)
McpHttpClientService mcpHttpClient,
McpToolsCacheService toolsCacheService)
{
_mcpHttpClient = mcpHttpClient;
_toolsCacheService = toolsCacheService;
}
public async Task RunAsync(CancellationToken cancellationToken = default)
@ -28,7 +33,7 @@ public class McpServerService : ITransientDependency
var options = new McpServerOptions();
RegisterAllTools(options);
await RegisterAllToolsAsync(options);
// Use NullLoggerFactory to prevent ModelContextProtocol library from logging to stdout
// All our logging goes to stderr via Console.Error
@ -42,121 +47,46 @@ public class McpServerService : ITransientDependency
await Console.Error.WriteLineAsync("[MCP] ABP MCP Server stopped");
}
private void RegisterAllTools(McpServerOptions options)
private async Task RegisterAllToolsAsync(McpServerOptions options)
{
RegisterTool(
options,
"get_relevant_abp_documentation",
"Search ABP framework technical documentation including official guides, API references, and framework documentation.",
new
{
type = "object",
properties = new
{
query = new
{
type = "string",
description = "The search query to find relevant documentation"
}
},
required = new[] { "query" }
}
);
// Get tool definitions from cache (or fetch from server)
var toolDefinitions = await _toolsCacheService.GetToolDefinitionsAsync();
RegisterTool(
options,
"get_relevant_abp_articles",
"Search ABP blog posts, tutorials, and community-contributed content.",
new
{
type = "object",
properties = new
{
query = new
{
type = "string",
description = "The search query to find relevant articles"
}
},
required = new[] { "query" }
}
);
await Console.Error.WriteLineAsync($"[MCP] Registering {toolDefinitions.Count} tools");
RegisterTool(
options,
"get_relevant_abp_support_questions",
"Search support ticket history containing real-world problems and their solutions.",
new
{
type = "object",
properties = new
{
query = new
{
type = "string",
description = "The search query to find relevant support questions"
}
},
required = new[] { "query" }
}
);
// Register each tool dynamically
foreach (var toolDef in toolDefinitions)
{
RegisterToolFromDefinition(options, toolDef);
}
}
RegisterTool(
options,
"search_code",
"Search for code across ABP repositories using regex patterns.",
new
{
type = "object",
properties = new
{
query = new
{
type = "string",
description = "The regex pattern or search query to find code"
},
repo_filter = new
{
type = "string",
description = "Optional repository filter to limit search scope"
}
},
required = new[] { "query" }
}
);
private void RegisterToolFromDefinition(McpServerOptions options, McpToolDefinition toolDef)
{
// Convert McpToolDefinition to the input schema format expected by MCP
var inputSchemaObject = new Dictionary<string, object>
{
["type"] = "object",
["properties"] = ConvertProperties(toolDef.InputSchema?.Properties),
["required"] = toolDef.InputSchema?.Required ?? new List<string>()
};
RegisterTool(
options,
"list_repos",
"List all available ABP repositories in SourceBot.",
new
{
type = "object",
properties = new { }
}
);
RegisterTool(options, toolDef.Name, toolDef.Description, inputSchemaObject);
}
private Dictionary<string, object> ConvertProperties(Dictionary<string, McpToolProperty> properties)
{
if (properties == null)
{
return new Dictionary<string, object>();
}
RegisterTool(
options,
"get_file_source",
"Retrieve the complete source code of a specific file from an ABP repository.",
new
return properties.ToDictionary(
kvp => kvp.Key,
kvp => (object)new Dictionary<string, object>
{
type = "object",
properties = new
{
repoId = new
{
type = "string",
description = "The repository identifier containing the file"
},
fileName = new
{
type = "string",
description = "The file path or name to retrieve"
}
},
required = new[] { "repoId", "fileName" }
["type"] = kvp.Value.Type,
["description"] = kvp.Value.Description
}
);
}

160
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/Services/McpToolsCacheService.cs

@ -0,0 +1,160 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Volo.Abp.Cli.Commands.Models;
using Volo.Abp.Cli.Memory;
using Volo.Abp.DependencyInjection;
using Volo.Abp.IO;
namespace Volo.Abp.Cli.Commands.Services;
public class McpToolsCacheService : ITransientDependency
{
private readonly McpHttpClientService _mcpHttpClient;
private readonly MemoryService _memoryService;
private readonly ILogger<McpToolsCacheService> _logger;
public McpToolsCacheService(
McpHttpClientService mcpHttpClient,
MemoryService memoryService,
ILogger<McpToolsCacheService> logger)
{
_mcpHttpClient = mcpHttpClient;
_memoryService = memoryService;
_logger = logger;
}
public async Task<List<McpToolDefinition>> GetToolDefinitionsAsync()
{
if (await IsCacheValidAsync())
{
var cachedTools = await LoadFromCacheAsync();
if (cachedTools != null)
{
await Console.Error.WriteLineAsync("[MCP] Using cached tool definitions");
return cachedTools;
}
}
// Cache is invalid or missing, fetch from server
try
{
await Console.Error.WriteLineAsync("[MCP] Fetching tool definitions from server...");
var tools = await _mcpHttpClient.GetToolDefinitionsAsync();
// Save to cache
await SaveToCacheAsync(tools);
await _memoryService.SetAsync(CliConsts.MemoryKeys.McpToolsLastFetchDate, DateTime.Now.ToString(CultureInfo.InvariantCulture));
await Console.Error.WriteLineAsync($"[MCP] Successfully fetched and cached {tools.Count} tool definitions");
return tools;
}
catch (Exception ex)
{
_logger.LogWarning($"Failed to fetch tool definitions from server: {ex.Message}");
await Console.Error.WriteLineAsync($"[MCP] Failed to fetch from server, attempting to use cached data...");
// Fall back to cache even if expired
var cachedTools = await LoadFromCacheAsync();
if (cachedTools != null)
{
await Console.Error.WriteLineAsync("[MCP] Using expired cache as fallback");
return cachedTools;
}
await Console.Error.WriteLineAsync("[MCP] No cached data available, using empty tool list");
return new List<McpToolDefinition>();
}
}
private async Task<bool> IsCacheValidAsync()
{
try
{
// Check if cache file exists
if (!File.Exists(CliPaths.McpToolsCache))
{
return false;
}
// Check timestamp in memory
var lastFetchTimeString = await _memoryService.GetAsync(CliConsts.MemoryKeys.McpToolsLastFetchDate);
if (string.IsNullOrEmpty(lastFetchTimeString))
{
return false;
}
if (DateTime.TryParse(lastFetchTimeString, CultureInfo.InvariantCulture, DateTimeStyles.None, out var lastFetchTime))
{
// Check if less than 24 hours old
if (DateTime.Now.Subtract(lastFetchTime).TotalHours < 24)
{
return true;
}
}
return false;
}
catch (Exception ex)
{
_logger.LogWarning($"Error checking cache validity: {ex.Message}");
return false;
}
}
private async Task<List<McpToolDefinition>> LoadFromCacheAsync()
{
try
{
if (!File.Exists(CliPaths.McpToolsCache))
{
return null;
}
var json = await FileHelper.ReadAllTextAsync(CliPaths.McpToolsCache);
var tools = JsonSerializer.Deserialize<List<McpToolDefinition>>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return tools;
}
catch (Exception ex)
{
_logger.LogWarning($"Error loading cached tool definitions: {ex.Message}");
return null;
}
}
private Task SaveToCacheAsync(List<McpToolDefinition> tools)
{
try
{
// Ensure directory exists
var directory = Path.GetDirectoryName(CliPaths.McpToolsCache);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(tools, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
File.WriteAllText(CliPaths.McpToolsCache, json);
}
catch (Exception ex)
{
_logger.LogWarning($"Error saving tool definitions to cache: {ex.Message}");
}
return Task.CompletedTask;
}
}
Loading…
Cancel
Save