Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions mcp/MockToolingServer.csproj
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>
34 changes: 34 additions & 0 deletions mcp/MockToolingServer.sln
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
151 changes: 151 additions & 0 deletions mcp/MockTools/FileMockToolStore.cs
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");
Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!);
if (!File.Exists(_filePath))
{
File.WriteAllText(_filePath, "[]");
}
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}");
}
}

private async Task SafeReload()
{
try
{
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;
}
}
}

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();
}
}
14 changes: 14 additions & 0 deletions mcp/MockTools/IMockToolStore.cs
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();
}
148 changes: 148 additions & 0 deletions mcp/MockTools/MockToolExecutor.cs
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!;
}
}

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();

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;
});
}
}
Loading