-
Notifications
You must be signed in to change notification settings - Fork 10
Add Mock MCP Server #74
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
0f88180
initial commit
ec6e419
remove non utf-8 chars
034fea7
add copyright headers and fix endpoints
5492e2c
remove admin endpoints
ceae33c
updated mock mcps to use updated mcp descriptions
abdulanu0 456730b
Merge branch 'users/jterrazas/move-mock-mcp' of https://github.com/mi…
3b35e1b
copilot comments
a9488dd
copilot comments
b38ea1d
copilot comments 3x
26bed50
Update tool defs
9b72bb2
update to readme
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| <Project Sdk="Microsoft.NET.Sdk.Web"> | ||
| <PropertyGroup> | ||
| <OutputType>Exe</OutputType> | ||
| <TargetFramework>net8.0</TargetFramework> | ||
| <Nullable>enable</Nullable> | ||
| <ImplicitUsings>enable</ImplicitUsings> | ||
| <RootNamespace>Microsoft.Agents.A365.DevTools.MockToolingServer</RootNamespace> | ||
| </PropertyGroup> | ||
| <ItemGroup> | ||
| <Using Include="System.Collections.Concurrent" /> | ||
| <Using Include="System.Text.Json" /> | ||
| <Using Include="System.Text.Json.Serialization" /> | ||
| <Using Include="System.Text.RegularExpressions" /> | ||
| <Using Include="Microsoft.Agents.A365.DevTools.MockToolingServer.MockTools" /> | ||
| </ItemGroup> | ||
| <ItemGroup> | ||
| <!-- Add packages with `dotnet add package` as instructed in the repo README below --> | ||
| <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.8" /> | ||
| <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.8" /> | ||
| <PackageReference Include="ModelContextProtocol" Version="0.3.0-preview.3" /> | ||
| <PackageReference Include="ModelContextProtocol.AspNetCore" Version="0.3.0-preview.3" /> | ||
| <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.8" /> | ||
| <PackageReference Include="Microsoft.Extensions.Http" Version="9.0.8" /> | ||
| </ItemGroup> | ||
| </Project> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| | ||
| Microsoft Visual Studio Solution File, Format Version 12.00 | ||
| # Visual Studio Version 17 | ||
| VisualStudioVersion = 17.0.31903.59 | ||
| MinimumVisualStudioVersion = 10.0.40219.1 | ||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MockToolingServer", "MockToolingServer.csproj", "{67BA49BD-228B-43D5-84C4-2438B9AEB45D}" | ||
| EndProject | ||
| Global | ||
| GlobalSection(SolutionConfigurationPlatforms) = preSolution | ||
| Debug|Any CPU = Debug|Any CPU | ||
| Debug|x64 = Debug|x64 | ||
| Debug|x86 = Debug|x86 | ||
| Release|Any CPU = Release|Any CPU | ||
| Release|x64 = Release|x64 | ||
| Release|x86 = Release|x86 | ||
| EndGlobalSection | ||
| GlobalSection(ProjectConfigurationPlatforms) = postSolution | ||
| {67BA49BD-228B-43D5-84C4-2438B9AEB45D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||
| {67BA49BD-228B-43D5-84C4-2438B9AEB45D}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||
| {67BA49BD-228B-43D5-84C4-2438B9AEB45D}.Debug|x64.ActiveCfg = Debug|Any CPU | ||
| {67BA49BD-228B-43D5-84C4-2438B9AEB45D}.Debug|x64.Build.0 = Debug|Any CPU | ||
| {67BA49BD-228B-43D5-84C4-2438B9AEB45D}.Debug|x86.ActiveCfg = Debug|Any CPU | ||
| {67BA49BD-228B-43D5-84C4-2438B9AEB45D}.Debug|x86.Build.0 = Debug|Any CPU | ||
| {67BA49BD-228B-43D5-84C4-2438B9AEB45D}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||
| {67BA49BD-228B-43D5-84C4-2438B9AEB45D}.Release|Any CPU.Build.0 = Release|Any CPU | ||
| {67BA49BD-228B-43D5-84C4-2438B9AEB45D}.Release|x64.ActiveCfg = Release|Any CPU | ||
| {67BA49BD-228B-43D5-84C4-2438B9AEB45D}.Release|x64.Build.0 = Release|Any CPU | ||
| {67BA49BD-228B-43D5-84C4-2438B9AEB45D}.Release|x86.ActiveCfg = Release|Any CPU | ||
| {67BA49BD-228B-43D5-84C4-2438B9AEB45D}.Release|x86.Build.0 = Release|Any CPU | ||
| EndGlobalSection | ||
| GlobalSection(SolutionProperties) = preSolution | ||
| HideSolutionNode = FALSE | ||
| EndGlobalSection | ||
| EndGlobal |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| namespace Microsoft.Agents.A365.DevTools.MockToolingServer.MockTools; | ||
|
|
||
| public class FileMockToolStore : IMockToolStore, IDisposable | ||
| { | ||
| private readonly string _filePath; | ||
| private readonly SemaphoreSlim _lock = new(1,1); | ||
| private readonly FileSystemWatcher? _watcher; | ||
| private readonly JsonSerializerOptions _jsonOptions = new() | ||
| { | ||
| WriteIndented = true, | ||
| PropertyNamingPolicy = JsonNamingPolicy.CamelCase, | ||
| ReadCommentHandling = JsonCommentHandling.Skip, | ||
| AllowTrailingCommas = true | ||
| }; | ||
|
|
||
| private readonly ConcurrentDictionary<string, MockToolDefinition> _cache = new(StringComparer.OrdinalIgnoreCase); | ||
|
|
||
| public string McpServerName { get; } | ||
|
|
||
| // Modified: now requires mcpServerName to determine file name (<mcpServerName>.json) | ||
| public FileMockToolStore(string mcpServerName, MockToolStoreOptions options) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(mcpServerName)) throw new ArgumentException("mcpServerName required", nameof(mcpServerName)); | ||
|
|
||
| McpServerName = mcpServerName; | ||
|
|
||
| // Sanitize server name for file system | ||
| var invalid = Path.GetInvalidFileNameChars(); | ||
| var safeName = new string(mcpServerName.Select(c => invalid.Contains(c) ? '_' : c).ToArray()); | ||
|
|
||
| _filePath = options.FilePath ?? Path.Combine(AppContext.BaseDirectory, "mocks", safeName + ".json"); | ||
JesuTerraz marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!); | ||
| if (!File.Exists(_filePath)) | ||
| { | ||
| File.WriteAllText(_filePath, "[]"); | ||
JesuTerraz marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| LoadInternal(); | ||
|
|
||
| try | ||
| { | ||
| _watcher = new FileSystemWatcher(Path.GetDirectoryName(_filePath)!) | ||
| { | ||
| Filter = Path.GetFileName(_filePath), | ||
| EnableRaisingEvents = true, | ||
| NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName | ||
| }; | ||
| _watcher.Changed += async (_, __) => await SafeReload(); | ||
| _watcher.Created += async (_, __) => await SafeReload(); | ||
| _watcher.Renamed += async (_, __) => await SafeReload(); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| Console.WriteLine($"Failed to initialize FileSystemWatcher: {ex.Message}"); | ||
JesuTerraz marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
|
|
||
| private async Task SafeReload() | ||
| { | ||
| try | ||
JesuTerraz marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| { | ||
| await ReloadAsync(); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| Console.WriteLine($"Failed to reload mock tools: {ex.Message}"); | ||
| } | ||
| } | ||
|
|
||
| private void LoadInternal() | ||
| { | ||
| var json = File.ReadAllText(_filePath); | ||
| var list = JsonSerializer.Deserialize<List<MockToolDefinition>>(json, _jsonOptions) ?? new(); | ||
| _cache.Clear(); | ||
| foreach(var t in list) | ||
| { | ||
| if (!string.IsNullOrWhiteSpace(t.Name)) | ||
| { | ||
| _cache[t.Name] = t; | ||
| } | ||
JesuTerraz marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
JesuTerraz marked this conversation as resolved.
Show resolved
Hide resolved
JesuTerraz marked this conversation as resolved.
Show resolved
Hide resolved
JesuTerraz marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| private async Task PersistAsync() | ||
| { | ||
| var list = _cache.Values.OrderBy(v => v.Name).ToList(); | ||
| var json = JsonSerializer.Serialize(list, _jsonOptions); | ||
| await File.WriteAllTextAsync(_filePath, json); | ||
| } | ||
|
|
||
| public async Task<IReadOnlyList<MockToolDefinition>> ListAsync() | ||
| { | ||
| await Task.CompletedTask; | ||
| return _cache.Values.OrderBy(v => v.Name).ToList(); | ||
| } | ||
|
|
||
| public async Task<MockToolDefinition?> GetAsync(string name) | ||
| { | ||
| await Task.CompletedTask; | ||
| _cache.TryGetValue(name, out var def); | ||
| return def; | ||
| } | ||
|
|
||
| public async Task UpsertAsync(MockToolDefinition def) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(def.Name)) throw new ArgumentException("Tool name required"); | ||
| await _lock.WaitAsync(); | ||
| try | ||
| { | ||
| _cache[def.Name] = def; | ||
| await PersistAsync(); | ||
| } | ||
| finally | ||
| { | ||
| _lock.Release(); | ||
| } | ||
| } | ||
|
|
||
| public async Task<bool> DeleteAsync(string name) | ||
| { | ||
| await _lock.WaitAsync(); | ||
| try | ||
| { | ||
| var removed = _cache.TryRemove(name, out _); | ||
| if (removed) | ||
| { | ||
| await PersistAsync(); | ||
| } | ||
| return removed; | ||
| } | ||
| finally | ||
| { | ||
| _lock.Release(); | ||
| } | ||
| } | ||
|
|
||
| public async Task ReloadAsync() | ||
| { | ||
| await _lock.WaitAsync(); | ||
| try { LoadInternal(); } | ||
| finally { _lock.Release(); } | ||
| } | ||
|
|
||
| public void Dispose() | ||
| { | ||
| _watcher?.Dispose(); | ||
| _lock.Dispose(); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| namespace Microsoft.Agents.A365.DevTools.MockToolingServer.MockTools; | ||
|
|
||
| public interface IMockToolStore | ||
| { | ||
| string McpServerName { get; } | ||
| Task<IReadOnlyList<MockToolDefinition>> ListAsync(); | ||
| Task<MockToolDefinition?> GetAsync(string name); | ||
| Task UpsertAsync(MockToolDefinition def); | ||
| Task<bool> DeleteAsync(string name); | ||
| Task ReloadAsync(); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| namespace Microsoft.Agents.A365.DevTools.MockToolingServer.MockTools; | ||
|
|
||
| public interface IMockToolExecutor | ||
| { | ||
| Task<object> ListToolsAsync(string mcpServerName); | ||
| Task<object> CallToolAsync(string mcpServerName, string name, IDictionary<string, object>? arguments); | ||
| } | ||
|
|
||
| public class MockToolExecutor : IMockToolExecutor | ||
| { | ||
| private readonly IReadOnlyList<IMockToolStore> _stores; | ||
| private readonly Random _rng = Random.Shared; | ||
| private static readonly Regex PlaceholderRegex = new("{{(.*?)}}", RegexOptions.Compiled); | ||
|
|
||
| // Default template constant so we can detect when user has not supplied one | ||
| private const string DefaultTemplate = "Mock response from {{name}}"; | ||
|
|
||
| public MockToolExecutor(IEnumerable<IMockToolStore> stores) | ||
| { | ||
| _stores = stores?.ToList() ?? throw new ArgumentNullException(nameof(stores)); | ||
| } | ||
|
|
||
| private IMockToolStore GetStore(string mcpServerName) | ||
| { | ||
| var store = _stores.FirstOrDefault(s => string.Equals(s.McpServerName, mcpServerName, StringComparison.OrdinalIgnoreCase)); | ||
| if (store == null) | ||
| { | ||
| throw new ArgumentException($"No mock tool store found for MCP server '{mcpServerName}'", nameof(mcpServerName)); | ||
| } | ||
| return store; | ||
| } | ||
|
|
||
| public async Task<object> ListToolsAsync(string mcpServerName) | ||
| { | ||
| var store = GetStore(mcpServerName); | ||
| var tools = await store.ListAsync(); | ||
| return new | ||
| { | ||
| tools = tools.Where(t => t.Enabled).Select(t => new | ||
| { | ||
| name = t.Name, | ||
| description = t.Description, | ||
| responseTemplate = t.ResponseTemplate, | ||
| placeholders = ExtractPlaceholders(t.ResponseTemplate), | ||
| inputSchema = t.InputSchema, | ||
| }) | ||
| }; | ||
| } | ||
|
|
||
| public async Task<object> CallToolAsync(string mcpServerName, string name, IDictionary<string, object>? arguments) | ||
| { | ||
| var store = GetStore(mcpServerName); | ||
| var tool = await store.GetAsync(name); | ||
| if (tool == null || !tool.Enabled) | ||
| { | ||
| return new | ||
| { | ||
| error = new { code = 404, message = $"Mock tool '{name}' not found" } | ||
| }; | ||
| } | ||
|
|
||
| if (tool.ErrorRate > 0 && _rng.NextDouble() < tool.ErrorRate) | ||
| { | ||
| return new | ||
| { | ||
| error = new { code = 500, message = $"Simulated error for mock tool '{name}'" } | ||
| }; | ||
| } | ||
|
|
||
| if (tool.DelayMs > 0) | ||
| { | ||
| await Task.Delay(tool.DelayMs); | ||
| } | ||
|
|
||
| // Allow callers to override the template at call time when the saved definition still uses the default | ||
| // This enables quick ad-hoc mocked responses without redefining the tool. | ||
| var effectiveTemplate = tool.ResponseTemplate; | ||
| if (string.Equals(effectiveTemplate, DefaultTemplate, StringComparison.Ordinal) && arguments != null) | ||
| { | ||
| string? overrideValue = FindDynamicTemplate(arguments); | ||
| if (!string.IsNullOrWhiteSpace(overrideValue)) | ||
| { | ||
| effectiveTemplate = overrideValue!; | ||
| } | ||
| } | ||
JesuTerraz marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| var rendered = RenderTemplate(effectiveTemplate, arguments ?? new Dictionary<string, object>()); | ||
|
|
||
| return new | ||
| { | ||
| content = new[] { new { type = "text", text = rendered } }, | ||
| isMock = true, | ||
| tool = name, | ||
| usedArguments = arguments, | ||
| template = tool.ResponseTemplate, | ||
| missingPlaceholders = ExtractPlaceholders(effectiveTemplate).Where(p => arguments == null || !arguments.Keys.Any(k => string.Equals(k, p, StringComparison.OrdinalIgnoreCase))).ToArray() | ||
| }; | ||
| } | ||
|
|
||
| private static string? FindDynamicTemplate(IDictionary<string, object> args) | ||
| { | ||
| var candidateKeys = new[] { "responseTemplate", "response", "mockResponse", "text", "value", "output" }; | ||
| foreach (var key in candidateKeys) | ||
| { | ||
| var match = args.FirstOrDefault(k => string.Equals(k.Key, key, StringComparison.OrdinalIgnoreCase)); | ||
| if (!string.IsNullOrEmpty(match.Key)) | ||
| { | ||
| var val = match.Value?.ToString(); | ||
| if (!string.IsNullOrWhiteSpace(val)) return val; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| private static string[] ExtractPlaceholders(string template) | ||
| => string.IsNullOrWhiteSpace(template) | ||
| ? Array.Empty<string>() | ||
| : PlaceholderRegex.Matches(template) | ||
| .Select(m => m.Groups[1].Value.Trim()) | ||
| .Distinct(StringComparer.OrdinalIgnoreCase) | ||
| .ToArray(); | ||
JesuTerraz marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| private static string RenderTemplate(string template, IDictionary<string, object> args) | ||
| { | ||
| if (string.IsNullOrEmpty(template)) return string.Empty; | ||
|
|
||
| // Build case-insensitive lookup (last write wins) | ||
| var lookup = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); | ||
| foreach (var kvp in args) | ||
| { | ||
| var value = kvp.Value?.ToString() ?? string.Empty; | ||
| lookup[kvp.Key] = value; | ||
| var lower = kvp.Key.ToLowerInvariant(); | ||
| if (!lookup.ContainsKey(lower)) lookup[lower] = value; | ||
| } | ||
|
|
||
| return PlaceholderRegex.Replace(template, match => | ||
| { | ||
| var key = match.Groups[1].Value.Trim(); | ||
| if (lookup.TryGetValue(key, out var val)) return val; | ||
| // Keep placeholder visible if missing so user/LLM knows what to supply | ||
| return match.Value; | ||
| }); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.