diff --git a/mcp/MockToolingServer.csproj b/mcp/MockToolingServer.csproj new file mode 100644 index 00000000..fcfe71b0 --- /dev/null +++ b/mcp/MockToolingServer.csproj @@ -0,0 +1,25 @@ + + + Exe + net8.0 + enable + enable + Microsoft.Agents.A365.DevTools.MockToolingServer + + + + + + + + + + + + + + + + + + diff --git a/mcp/MockToolingServer.sln b/mcp/MockToolingServer.sln new file mode 100644 index 00000000..c1aa9448 --- /dev/null +++ b/mcp/MockToolingServer.sln @@ -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 diff --git a/mcp/MockTools/FileMockToolStore.cs b/mcp/MockTools/FileMockToolStore.cs new file mode 100644 index 00000000..024598d9 --- /dev/null +++ b/mcp/MockTools/FileMockToolStore.cs @@ -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 _cache = new(StringComparer.OrdinalIgnoreCase); + + public string McpServerName { get; } + + // Modified: now requires mcpServerName to determine file name (.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>(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> ListAsync() + { + await Task.CompletedTask; + return _cache.Values.OrderBy(v => v.Name).ToList(); + } + + public async Task 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 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(); + } +} diff --git a/mcp/MockTools/IMockToolStore.cs b/mcp/MockTools/IMockToolStore.cs new file mode 100644 index 00000000..26bb3e12 --- /dev/null +++ b/mcp/MockTools/IMockToolStore.cs @@ -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> ListAsync(); + Task GetAsync(string name); + Task UpsertAsync(MockToolDefinition def); + Task DeleteAsync(string name); + Task ReloadAsync(); +} diff --git a/mcp/MockTools/MockToolExecutor.cs b/mcp/MockTools/MockToolExecutor.cs new file mode 100644 index 00000000..0e061741 --- /dev/null +++ b/mcp/MockTools/MockToolExecutor.cs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.DevTools.MockToolingServer.MockTools; + +public interface IMockToolExecutor +{ + Task ListToolsAsync(string mcpServerName); + Task CallToolAsync(string mcpServerName, string name, IDictionary? arguments); +} + +public class MockToolExecutor : IMockToolExecutor +{ + private readonly IReadOnlyList _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 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 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 CallToolAsync(string mcpServerName, string name, IDictionary? 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()); + + 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 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() + : PlaceholderRegex.Matches(template) + .Select(m => m.Groups[1].Value.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private static string RenderTemplate(string template, IDictionary args) + { + if (string.IsNullOrEmpty(template)) return string.Empty; + + // Build case-insensitive lookup (last write wins) + var lookup = new Dictionary(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; + }); + } +} diff --git a/mcp/MockTools/MockToolModels.cs b/mcp/MockTools/MockToolModels.cs new file mode 100644 index 00000000..1a5d1ead --- /dev/null +++ b/mcp/MockTools/MockToolModels.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.DevTools.MockToolingServer.MockTools; + +public class MockToolDefinition +{ + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; + [JsonPropertyName("description")] public string Description { get; set; } = string.Empty; + [JsonPropertyName("inputSchema")] public object InputSchema { get; set; } = new { type = "object", properties = new { }, required = Array.Empty() }; + + // Response behavior + [JsonPropertyName("responseTemplate")] public string ResponseTemplate { get; set; } = "Mock response from {{name}}"; + [JsonPropertyName("delayMs")] public int DelayMs { get; set; } = 0; + [JsonPropertyName("errorRate")] public double ErrorRate { get; set; } = 0.0; // 0-1 + [JsonPropertyName("statusCode")] public int StatusCode { get; set; } = 200; + [JsonPropertyName("enabled")] public bool Enabled { get; set; } = true; +} + +public class MockToolStoreOptions +{ + public string? FilePath { get; set; } +} diff --git a/mcp/Program.cs b/mcp/Program.cs new file mode 100644 index 00000000..28db46f9 --- /dev/null +++ b/mcp/Program.cs @@ -0,0 +1,194 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// WebApplication for SSE hosting +var builder = WebApplication.CreateBuilder(args); + +// Send logs to stderr so stdout stays clean for the protocol +builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); + +Console.WriteLine($"[Program.cs] MCP Server starting at {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff} UTC"); + +// MCP services with tools; add both HTTP and SSE transport +builder.Services + .AddMcpServer() + .WithHttpTransport() + .WithToolsFromAssembly(); + +// Get MCP server names from existing .json files in the mocks folder +var mocksDirectory = Path.Combine(AppContext.BaseDirectory, "mocks"); +Directory.CreateDirectory(mocksDirectory); // Ensure directory exists + +var mcpServerNames = Directory.Exists(mocksDirectory) + ? Directory.GetFiles(mocksDirectory, "*.json") + .Select(Path.GetFileNameWithoutExtension) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToArray() + : Array.Empty(); + +// If no existing files, fall back to configuration or default +if (mcpServerNames.Length == 0) +{ + mcpServerNames = builder.Configuration.GetSection("Mcp:ServerNames").Get() + ?? new[] { builder.Configuration["Mcp:ServerName"] ?? "MockToolingServer" }; +} + +// Mock tool stores + executor. Each server gets its own store with file name .json under /mocks +foreach (var serverName in mcpServerNames) +{ + builder.Services.AddSingleton(provider => new FileMockToolStore(serverName!, new MockToolStoreOptions())); +} + +builder.Services.AddSingleton(provider => + new MockToolExecutor(provider.GetServices())); + +var app = builder.Build(); + +// Log startup information +var logger = app.Services.GetRequiredService>(); +logger.LogInformation("===== MCP SERVER STARTING ====="); +logger.LogInformation("Startup Time: {StartupTime} UTC", DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff")); +logger.LogInformation("Server will be available on: http://localhost:5309"); +foreach (var serverName in mcpServerNames) +{ + logger.LogInformation("Mock tools file for '{ServerName}': {File}", serverName, Path.Combine(AppContext.BaseDirectory, "mocks", serverName + ".json")); +} +logger.LogInformation("===== END STARTUP INFO ====="); + +// Map MCP SSE endpoints at the default route ("/mcp") +// Available routes include: /mcp/sse (server-sent events) and /mcp/schema.json +app.MapMcp(); + +// Log that MCP is mapped +logger.LogInformation("MCP endpoints mapped: /mcp/sse, /mcp/schema.json"); + +// Optional minimal health endpoint for quick check +// app.MapGet("/", () => Results.Ok(new { status = "ok", mcp = "/mcp" })); +app.MapGet("/health", () => Results.Ok(new { status = "ok", mcp = "/mcp", mock = "/mcp-mock" })); + +// ===================== MOCK MCP ENDPOINTS ===================== +// JSON-RPC over HTTP for mock tools at /mcp-mock +app.MapPost("/agents/servers/{mcpServerName}", async (string mcpServerName, HttpRequest httpRequest, IMockToolExecutor executor, ILogger log) => +{ + try + { + using var doc = await JsonDocument.ParseAsync(httpRequest.Body); + var root = doc.RootElement; + object? idValue = null; + if (root.TryGetProperty("id", out var idProp)) + { + if (idProp.ValueKind == JsonValueKind.Number) + { + idValue = idProp.TryGetInt64(out var longVal) ? (object?)longVal : idProp.GetDouble(); + } + else if (idProp.ValueKind == JsonValueKind.String) + { + idValue = idProp.GetString(); + } + else + { + idValue = null; + } + } + + if (!root.TryGetProperty("method", out var methodProp) || methodProp.ValueKind != JsonValueKind.String) + { + return Results.BadRequest(new { error = "Invalid or missing 'method' property." }); + } + + var method = methodProp.GetString(); + + if (string.Equals(method, "initialize", StringComparison.OrdinalIgnoreCase)) + { + var initializeResult = new + { + protocolVersion = "2024-11-05", + capabilities = new + { + logging = new { }, + prompts = new + { + listChanged = true + }, + resources = new + { + subscribe = true, + listChanged = true + }, + tools = new + { + listChanged = true + } + }, + serverInfo = new + { + name = "ExampleServer", + title = "Example Server Display Name", + version = "1.0.0", + }, + instructions = "Optional instructions for the client" + }; + return Results.Json(new { jsonrpc = "2.0", id = idValue, result = initializeResult }); + } + if (string.Equals(method, "logging/setLevel", StringComparison.OrdinalIgnoreCase)) + { + // Acknowledge but do nothing + return Results.Json(new { jsonrpc = "2.0", id = idValue, result = new { } }); + } + if (string.Equals(method, "tools/list", StringComparison.OrdinalIgnoreCase)) + { + var listResult = await executor.ListToolsAsync(mcpServerName); + return Results.Json(new { jsonrpc = "2.0", id = idValue, result = listResult }); + } + if (string.Equals(method, "tools/call", StringComparison.OrdinalIgnoreCase)) + { + var name = root.GetProperty("params").GetProperty("name").GetString() ?? string.Empty; + var argsDict = new Dictionary(); + if (root.GetProperty("params").TryGetProperty("arguments", out var argsList) && argsList.ValueKind == JsonValueKind.Object) + { + foreach (var prop in argsList.EnumerateObject()) + { + object? converted = null; + switch (prop.Value.ValueKind) + { + case JsonValueKind.String: + converted = prop.Value.GetString(); + break; + case JsonValueKind.Number: + if (prop.Value.TryGetInt64(out var lnum)) converted = lnum; else converted = prop.Value.GetDouble(); + break; + case JsonValueKind.True: + converted = true; break; + case JsonValueKind.False: + converted = false; break; + case JsonValueKind.Null: + converted = null; break; + default: + converted = prop.Value.GetRawText(); + break; + } + argsDict[prop.Name] = converted; + } + } + var callResult = await executor.CallToolAsync(mcpServerName, name, argsDict!); + // Detect error shape + var errorProp = callResult.GetType().GetProperty("error"); + if (errorProp != null) + { + return Results.Json(new { jsonrpc = "2.0", id = idValue, error = errorProp.GetValue(callResult) }); + } + return Results.Json(new { jsonrpc = "2.0", id = idValue, result = callResult }); + } + + return Results.Json(new { jsonrpc = "2.0", id = idValue, error = new { code = -32601, message = $"Method ({method}) not found" } }); + } + catch (Exception ex) + { + log.LogError(ex, "Mock JSON-RPC failure"); + return Results.Json(new { jsonrpc = "2.0", id = (object?)null, error = new { code = -32603, message = ex.Message } }); + } +}); + +logger.LogInformation("[Program.cs] Starting MCP server... Watch for tool calls in the logs!"); + +await app.RunAsync(); \ No newline at end of file diff --git a/mcp/Properties/launchSettings.json b/mcp/Properties/launchSettings.json new file mode 100644 index 00000000..35732256 --- /dev/null +++ b/mcp/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "MockNotificationMCP": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5309", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 00000000..16d968e1 --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,251 @@ +# How to mock notifications for custom activities + +## Prerequisites +- .NET 8 SDK: https://dotnet.microsoft.com/download/dotnet/8.0 + +## Invoke the custom activity + +POST http://localhost:5309/agents/servers/mcp_MailTools +```json +{ +"jsonrpc":"2.0", +"id":2, +"method":"tools/call", +"params": + { + "name":"Send_Email", + "arguments": + { + "to":"user@contoso.com", + "subject":"POC", + "body":"Test" + } + } +} +``` + +Output: +```json +{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "content": [ + { + "type": "text", + "text": "Email to user@contoso.com with subject 'POC' sent (mock)." + } + ], + "isMock": true, + "tool": "sendemail3", + "usedArguments": { + "to": "user@contoso.com", + "subject": "POC", + "body": "Test" + }, + "template": "Email to {{to}} with subject '{{subject}}' sent (mock).", + "missingPlaceholders": [] + } +} +``` + +## Run the MCP server +From the `mcp` folder: + +```pwsh +dotnet build +dotnet run +``` + +The app hosts MCP over SSE and exposes default routes such as `/mcp/sse`, `/mcp/schema.json`, and `/health`. + +## Configure your MCP client (VS Code example) +Add this to your VS Code MCP configuration (as provided): + +```json +{ + "servers": { + "documentTools": { + "type": "sse", + "url": "http://localhost:5309/mcp/sse" + } + }, + "inputs": [] +} +``` + +## Available tools (high level) +This server exposes a generic mock tool system. There are no fixed domain-specific tools baked in; instead you define any number of mock tools persisted in `mocks/{serverName}.json`. They are surfaced over a JSON-RPC interface that mimics an MCP tool catalog. + +### 1. JSON-RPC tool methods (endpoint: POST /agents/servers/{mcpServerName}) +tools/list + Returns all enabled mock tools. Shape: +```json +{ +"tools": + [ + { + "name": "Send_Email", + "description": "Send an email (mock).", + "responseTemplate": "Email to {{to}} ...", + "placeholders": ["to","subject"], + "inputSchema": + { + "type": "object", + "properties": + { + "to": { "type":"string" }, + "subject": { "type":"string" }, + "body": { "type":"string" } + }, + "required": ["to","subject"] + } + } + ] +} + +tools/call + Executes a mock tool and returns a rendered response: + +```json +{ + "content": + [ + { + "type":"text", + "text":"..." + } + ], + "isMock": true, + "tool": "Send_Email", + "usedArguments": { ... }, + "template": "", + "missingPlaceholders": ["anyPlaceholderNotSupplied"] +} +``` + +File changes (including manual edits to `mocks/{serverName}.json`) are auto-reloaded via a filesystem watcher. + +### 2. Mock tool definition schema +Fields: +- name (string, required) : Unique identifier. +- description (string) : Human readable summary. +- inputSchema (array) : Describes the input schema for the tool call. +- responseTemplate (string) : Text with Handlebars-style placeholders `{{placeholder}}`. +- delayMs (int) : Artificial latency before responding. +- errorRate (double 0-1) : Probability of returning a simulated 500 error. +- statusCode (int) : Informational only (not currently enforcing an HTTP status on JSON-RPC). +- enabled (bool) : If false, tool is hidden from tools/list and cannot be called. + +### 3. Template rendering & dynamic override +- Placeholders: Any `{{key}}` is replaced with the argument value (case-insensitive). +- Unresolved placeholders are left intact and also reported in `missingPlaceholders`. +- If the stored template equals the default literal `Mock response from {{name}}`, you can override it ad-hoc per call by supplying one of these argument keys: `responseTemplate`, `response`, `mockResponse`, `text`, `value`, or `output`. +- Example override call: +```json +{ + "jsonrpc":"2.0", + "id":1, + "method":"tools/call", + "params": + { + "name":"AnyTool", + "arguments":{ "responseTemplate":"Hello {{user}}", "user":"Ada" } + } +} +``` +### 4. Error & latency simulation +- If `errorRate` > 0 and a random draw is below it, the response is: + +```json +{ + "error": + { + "code": 500, + "message": "Simulated error for mock tool 'X'" + } +} +``` +- `delayMs` awaits before forming the result, letting you test client-side spinners/timeouts. + +### 5. Example definitions +Email style tool: +```json +{ +"name": "SendEmailWithAttachmentsAsync", +"description": "Create and send an email with optional attachments. Supports both file URIs (OneDrive/SharePoint) and direct file uploads (base64-encoded). IMPORTANT: If recipient names are provided instead of email addresses, you MUST first search for users to find their email addresses.", +"inputSchema": { + "type": "object", + "properties": { + "to": { + "type": "array", + "description": "List of To recipients (MUST be email addresses - if you only have names, search for users first to get their email addresses)", + "items": { + "type": "string" + } + }, + "cc": { + "type": "array", + "description": "List of Cc recipients (MUST be email addresses - if you only have names, search for users first to get their email addresses)", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "description": "List of Bcc recipients (MUST be email addresses - if you only have names, search for users first to get their email addresses)", + "items": { + "type": "string" + } + }, + "subject": { + "type": "string", + "description": "Subject of the email" + }, + "body": { + "type": "string", + "description": "Body of the email" + }, + "attachmentUris": { + "type": "array", + "description": "List of file URIs to attach (OneDrive, SharePoint, Teams, or Graph /drives/{id}/items/{id})", + "items": { + "type": "string" + } + }, + "directAttachments": { + "type": "array", + "description": "List of direct file attachments with format: [{\"fileName\": \"file.pdf\", \"contentBase64\": \"base64data\", \"contentType\": \"application/pdf\"}]", + "items": { + "type": "object", + "properties": { + "FileName": { + "type": "string" + }, + "ContentBase64": { + "type": "string" + }, + "ContentType": { + "type": "string" + } + }, + "required": [] + } + }, + "directAttachmentFilePaths": { + "type": "array", + "description": "List of local file system paths to attach; will be read and base64 encoded automatically.", + "items": { + "type": "string" + } + } + }, + "required": [] +}, +"responseTemplate": "Email with subject '{{subject}}' sent successfully (mock).", +"delayMs": 250, +"errorRate": 0, +"statusCode": 200, +"enabled": true +} +``` \ No newline at end of file diff --git a/mcp/appsettings.json b/mcp/appsettings.json new file mode 100644 index 00000000..960be506 --- /dev/null +++ b/mcp/appsettings.json @@ -0,0 +1,7 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information" + } + } +} diff --git a/mcp/mocks/mcp_CalendarTools.json b/mcp/mocks/mcp_CalendarTools.json new file mode 100644 index 00000000..9ee448e2 --- /dev/null +++ b/mcp/mocks/mcp_CalendarTools.json @@ -0,0 +1,1068 @@ +[ + { + "name": "acceptEvent", + "description": "Accept the specified event invitation in a user's calendar.", + "inputSchema": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "The ID or userPrincipalName of the user.", + "x-ms-location": "path", + "x-ms-path": "userId" + }, + "eventId": { + "type": "string", + "description": "The ID of the event to accept.", + "x-ms-location": "path", + "x-ms-path": "eventId" + }, + "comment": { + "type": "string", + "description": "Optional text included in the response.", + "x-ms-location": "body", + "x-ms-path": "comment" + }, + "sendResponse": { + "type": "boolean", + "description": "Whether to send a response to the organizer.", + "x-ms-location": "body", + "x-ms-path": "sendResponse" + } + }, + "required": [ + "eventId" + ] + } + }, + { + "name": "cancelEvent", + "description": "Cancel an event in a specified user's calendar and notify attendees.", + "inputSchema": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "The ID or userPrincipalName of the user who owns the event.", + "x-ms-location": "path", + "x-ms-path": "userId" + }, + "eventId": { + "type": "string", + "description": "The unique identifier of the event to cancel.", + "x-ms-location": "path", + "x-ms-path": "eventId" + }, + "comment": { + "type": "string", + "description": "Optional message to include in the cancellation notification to attendees.", + "x-ms-location": "body", + "x-ms-path": "comment" + } + }, + "required": [ + "eventId" + ] + }, + "responseTemplate": "Event '{{eventId}}' cancelled (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "createEvent", + "description": "\"Use this to create a new event in current user's calendar. IMPORTANT: If recipient names are provided instead of email addresses, you MUST first search for users to find their email addresses..If time is provided, schedule the event — even if there are conflicts/ even if attendee/organizer is busy in that slot.If only a date is given, use the earliest slot where all or most attendees (> 50% of them) are free. If no date is given, schedule meeting at given time for today.If no time/date is given, use the first slot where all attendees are free.Try to create events during working hours of signed-in user (8 AM – 5 PM) if explicit time is not specified.Specify recurrence using recurrence property.Online meetings can be created by setting isOnlineMeeting to true.Default meeting duration is 30 minutes, if not specified by user.\"", + "inputSchema": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "Use current user if no organizer is specified.If not, get the organizer's user principal name first, and use that value.'", + "x-ms-location": "path", + "x-ms-path": "userId" + }, + "subject": { + "type": "string", + "description": "Non-empty event subject/title. Avoid trailing whitespace and control characters.", + "x-ms-location": "body", + "x-ms-path": "subject", + "minLength": 1 + }, + "body": { + "type": "object", + "description": "Event body/description content. Use 'html' for rich formatting or 'text' for plain.", + "x-ms-location": "body", + "x-ms-path": "body", + "properties": { + "contentType": { + "type": "string", + "enum": [ + "Text", + "HTML" + ], + "description": "Body content type. If unsure, use 'Text'.", + "x-ms-location": "body", + "x-ms-path": "body.contentType" + }, + "content": { + "type": "string", + "description": "Body content string. For 'html', provide well-formed HTML; for 'text', provide plain UTF-8 text.", + "x-ms-location": "body", + "x-ms-path": "body.content" + } + }, + "required": [ + "contentType", + "content" + ] + }, + "start": { + "type": "object", + "description": "Event start timestamp with time zone. Must be today or after.", + "x-ms-location": "body", + "x-ms-path": "start", + "properties": { + "dateTime": { + "type": "string", + "description": "Event start time in ISO 8601 without offset: 'yyyy-MM-ddTHH:mm:ss' (e.g., '2025-09-18T09:00:00').", + "x-ms-location": "body", + "x-ms-path": "start.dateTime", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}$" + }, + "timeZone": { + "type": "string", + "description": "Time zone identifier for start time. Use the system time zone if known.", + "x-ms-location": "body", + "x-ms-path": "start.timeZone" + } + }, + "required": [ + "dateTime", + "timeZone" + ] + }, + "end": { + "type": "object", + "description": "Event end timestamp with time zone. Must be after 'start'.", + "x-ms-location": "body", + "x-ms-path": "end", + "properties": { + "dateTime": { + "type": "string", + "description": "Event end time in ISO 8601 without offset: 'yyyy-MM-ddTHH:mm:ss' (e.g., '2025-09-18T10:00:00').", + "x-ms-location": "body", + "x-ms-path": "end.dateTime", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}$" + }, + "timeZone": { + "type": "string", + "description": "Time zone identifier of 'dateTime'. Use the same timeZone as 'start.timeZone' for consistency.", + "x-ms-location": "body", + "x-ms-path": "end.timeZone" + } + }, + "required": [ + "dateTime", + "timeZone" + ] + }, + "location": { + "type": "object", + "description": "Primary location for the event. Use a concise name (e.g., room name, address, or 'Microsoft Teams Meeting').If not specified, default to 'Microsoft Team Meeting'", + "x-ms-location": "body", + "x-ms-path": "location", + "properties": { + "displayName": { + "type": "string", + "description": "Human-readable location label (e.g., 'Conf Room 12A', '1 Microsoft Way, Redmond', or 'Teams Meeting').", + "x-ms-location": "body", + "x-ms-path": "location.displayName" + } + } + }, + "attendees_addresses": { + "type": "array", + "description": "Email addresses of attendees. Each must be a valid email address.", + "x-ms-location": "body", + "x-ms-path": "attendees", + "x-ms-restructure": "attendees", + "items": { + "type": "string", + "format": "email", + "pattern": "^[^\\s@]+@[^\\s@]+\\.[^\\s@]{2,}$" + } + }, + "attendees_types": { + "type": "array", + "description": "Attendee roles corresponding to each address in 'attendees_addresses'. Must appear in the same order as the email array so that each type applies to the attendee at the same index.", + "x-ms-location": "body", + "x-ms-path": "attendees", + "x-ms-restructure": "attendees", + "items": { + "type": "string", + "enum": [ + "required", + "optional", + "resource" + ] + } + }, + "attendees_names": { + "type": "array", + "description": "Display names of attendees corresponding to each address in 'attendees_addresses'. Must appear in the same order as the email array so that each name applies to the attendee at the same index.", + "x-ms-location": "body", + "x-ms-path": "attendees", + "x-ms-restructure": "attendees", + "items": { + "type": "string" + } + }, + "recurrence": { + "type": "object", + "description": "Recurrence specification. Provide both 'pattern' and 'range'. Omit for one-time events.", + "x-ms-location": "body", + "x-ms-path": "recurrence", + "properties": { + "pattern": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "daily", + "weekly", + "absoluteMonthly", + "relativeMonthly", + "absoluteYearly", + "relativeYearly" + ], + "description": "The recurrence pattern type" + }, + "interval": { + "type": "integer", + "description": "The time interval between occurrences (e.g., every 2 weeks)" + }, + "daysOfWeek": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "sunday", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday" + ] + }, + "description": "Days of the week for recurrence, if applicable" + }, + "dayOfMonth": { + "type": "integer", + "description": "Day of the month for monthly pattern" + }, + "month": { + "type": "integer", + "description": "Month for yearly pattern" + } + }, + "required": [ + "type", + "interval" + ] + }, + "range": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "endDate", + "noEnd", + "numbered" + ], + "description": "The recurrence range type" + }, + "startDate": { + "type": "string", + "description": "The start date of the recurrence (yyyy-MM-dd)" + }, + "endDate": { + "type": "string", + "description": "The end date of the recurrence (yyyy-MM-dd)" + }, + "numberOfOccurrences": { + "type": "integer", + "description": "The number of times the event occurs" + } + }, + "required": [ + "type", + "startDate" + ] + } + }, + "required": [ + "pattern", + "range" + ] + }, + "allowNewTimeProposals": { + "type": "boolean", + "description": "Whether invitees can propose a new time. Defaults to true if omitted.", + "x-ms-location": "body", + "x-ms-path": "allowNewTimeProposals" + }, + "transactionId": { + "type": "string", + "description": "Optional unique client-provided identifier to ensure idempotence", + "x-ms-location": "body", + "x-ms-path": "transactionId" + }, + "isOnlineMeeting": { + "type": "boolean", + "description": "Set to true to create an online meeting. Defaults to false.", + "x-ms-location": "body", + "x-ms-path": "isOnlineMeeting" + }, + "onlineMeetingProvider": { + "type": "string", + "enum": [ + "teamsForBusiness", + "skypeForBusiness", + "skypeForConsumer" + ], + "description": "Online meeting provider. REQUIRED when 'isOnlineMeeting' = true; omit otherwise.", + "x-ms-location": "body", + "x-ms-path": "onlineMeetingProvider" + } + }, + "required": [ + "subject", + "start", + "end", + "attendees_addresses" + ] + }, + "responseTemplate": "Event '{{subject}}' created successfully (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "declineEvent", + "description": "Decline the specified event invitation in a user's calendar.", + "inputSchema": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "The ID or userPrincipalName of the user.", + "x-ms-location": "path", + "x-ms-path": "userId" + }, + "eventId": { + "type": "string", + "description": "The ID of the event to decline.", + "x-ms-location": "path", + "x-ms-path": "eventId" + }, + "comment": { + "type": "string", + "description": "Optional text included in the response.", + "x-ms-location": "body", + "x-ms-path": "comment" + }, + "sendResponse": { + "type": "boolean", + "description": "Whether to send a response to the organizer.", + "x-ms-location": "body", + "x-ms-path": "sendResponse" + } + }, + "required": [ + "eventId" + ] + }, + "responseTemplate": "Event '{{eventId}}' declined (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "deleteEvent", + "description": "Delete an event from a specified user's calendar.", + "inputSchema": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "The ID or userPrincipalName of the user whose event is being deleted.", + "x-ms-location": "path", + "x-ms-path": "userId" + }, + "eventId": { + "type": "string", + "description": "The unique identifier of the event to delete.", + "x-ms-location": "path", + "x-ms-path": "eventId" + } + }, + "required": [ + "eventId" + ] + }, + "responseTemplate": "Event '{{eventId}}' deleted (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "findMeetingTimes", + "description": "Suggest possible meeting times and locations based on organizer and attendee availability. Use only when the user requests to find an open time slot for a new meeting (e.g., 'schedule a meeting', 'find a slot', 'when can we meet'). Do not use this tool for locating or updating existing meetings.", + "inputSchema": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "The ID or userPrincipalName of the organizer.", + "x-ms-location": "path", + "x-ms-path": "userId" + }, + "attendees_addresses": { + "type": "array", + "description": "Email addresses of attendees for meeting time suggestions. Each must be a valid email address.", + "x-ms-location": "body", + "x-ms-path": "attendees", + "x-ms-restructure": "attendees", + "items": { + "type": "string", + "format": "email", + "pattern": "^[^\\s@]+@[^\\s@]+\\.[^\\s@]{2,}$" + } + }, + "attendees_types": { + "type": "array", + "description": "Attendee types corresponding to each address in 'attendees_addresses'. Must appear in the same order as the email array so that each type applies to the attendee at the same index.", + "x-ms-location": "body", + "x-ms-path": "attendees", + "x-ms-restructure": "attendees", + "items": { + "type": "string", + "enum": [ + "required", + "optional", + "resource" + ] + } + }, + "attendees_names": { + "type": "array", + "description": "Display names of attendees corresponding to each address in 'attendees_addresses'. Must appear in the same order as the email array so that each name applies to the attendee at the same index.", + "x-ms-location": "body", + "x-ms-path": "attendees", + "x-ms-restructure": "attendees", + "items": { + "type": "string" + } + }, + "timeConstraint": { + "type": "object", + "description": "Time availability (timeslots, activityDomain).", + "x-ms-location": "body", + "x-ms-path": "timeConstraint", + "properties": { + "timeSlots": { + "type": "array", + "items": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "dateTime": { + "type": "string" + }, + "timeZone": { + "type": "string" + } + }, + "required": [ + "dateTime", + "timeZone" + ] + }, + "end": { + "type": "object", + "properties": { + "dateTime": { + "type": "string" + }, + "timeZone": { + "type": "string" + } + }, + "required": [ + "dateTime", + "timeZone" + ] + } + } + } + }, + "activityDomain": { + "type": "string", + "enum": [ + "work", + "unrestricted" + ] + } + } + }, + "meetingDuration": { + "type": "string", + "description": "Meeting duration (e.g. 'PT1H').", + "x-ms-location": "body", + "x-ms-path": "meetingDuration" + }, + "locationConstraint": { + "type": "object", + "description": "Options for meeting location.", + "x-ms-location": "body", + "x-ms-path": "locationConstraint", + "properties": { + "isRequired": { + "type": "boolean" + }, + "suggestLocation": { + "type": "boolean" + }, + "locations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "displayName": { + "type": "string" + }, + "locationEmailAddress": { + "type": "string" + }, + "resolveAvailability": { + "type": "boolean" + } + }, + "required": [ + "displayName" + ] + } + } + } + }, + "maxCandidates": { + "type": "integer" + }, + "isOrganizerOptional": { + "type": "boolean" + }, + "returnSuggestionReasons": { + "type": "boolean" + }, + "minimumAttendeePercentage": { + "type": "number", + "format": "double" + } + }, + "required": [ + "meetingDuration" + ] + }, + "responseTemplate": "Meeting time suggestions found for duration {{meetingDuration}} (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "getEvent", + "description": "Get a single calendar event from a specified user’s calendar.", + "inputSchema": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "The ID or userPrincipalName of the user whose event is being retrieved.", + "x-ms-location": "path", + "x-ms-path": "userId" + }, + "eventId": { + "type": "string", + "description": "The unique identifier of the event.", + "x-ms-location": "path", + "x-ms-path": "eventId" + }, + "select": { + "type": "string", + "description": "OData $select query parameter to specify returned properties.", + "x-ms-location": "query", + "x-ms-path": "$select" + }, + "expand": { + "type": "string", + "description": "OData $expand query parameter to include navigation properties like exceptionOccurrences.", + "x-ms-location": "query", + "x-ms-path": "$expand" + } + }, + "required": [ + "eventId" + ] + }, + "responseTemplate": "Event '{{eventId}}' retrieved (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "getOrganization", + "description": "Retrieve the properties and relationships of the specified organization (tenant). Supports $select to choose specific fields.", + "inputSchema": { + "type": "object", + "properties": { + "organizationId": { + "type": "string", + "description": "The ID of the organization (tenant) to retrieve.", + "x-ms-location": "path", + "x-ms-path": "organizationId" + }, + "select": { + "type": "string", + "description": "Comma-separated list of organization properties to return (via $select).", + "x-ms-location": "query", + "x-ms-path": "$select" + } + }, + "required": [ + "organizationId" + ] + }, + "responseTemplate": "Organization '{{organizationId}}' retrieved (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "getSchedule", + "description": "Get the free/busy schedule for a user, distribution list, or resource.", + "inputSchema": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "The ID or userPrincipalName of the user.", + "x-ms-location": "path", + "x-ms-path": "userId" + }, + "schedules": { + "type": "array", + "description": "SMTP addresses of users or resources.", + "x-ms-location": "body", + "x-ms-path": "schedules", + "items": { + "type": "string" + } + }, + "startTime": { + "type": "object", + "description": "Start time for the query. Should be today / after today. Use system timezone if not specified. ", + "x-ms-location": "body", + "x-ms-path": "startTime", + "properties": { + "dateTime": { + "type": "string" + }, + "timeZone": { + "type": "string" + } + }, + "required": [ + "dateTime", + "timeZone" + ] + }, + "endTime": { + "type": "object", + "description": "End time for the query.Should be after startTime. Use system timezone if not specified", + "x-ms-location": "body", + "x-ms-path": "endTime", + "properties": { + "dateTime": { + "type": "string" + }, + "timeZone": { + "type": "string" + } + }, + "required": [ + "dateTime", + "timeZone" + ] + }, + "availabilityViewInterval": { + "type": "integer", + "description": "Time slot length in minutes." + } + }, + "required": [ + "schedules", + "startTime", + "endTime" + ] + }, + "responseTemplate": "Schedule retrieved for {{schedules.length}} users/resources (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "listCalendarView", + "description": "Retrieve events from a user's calendar view. Use this tool whenever you need to retrieve one meeting instance of a recurrening event(not master series) occurring in a window (e.g., 'tomorrow morning' or 'between 2 PM and 4 PM') before calling any tool that modifies, updates, or cancels a meeting.", + "inputSchema": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "", + "x-ms-location": "path", + "x-ms-path": "userId" + }, + "startDateTime": { + "type": "string", + "description": "Start of the time range (ISO 8601). Should be today / after today.", + "x-ms-location": "query", + "x-ms-path": "startDateTime" + }, + "endDateTime": { + "type": "string", + "description": "End of the time range (ISO 8601).should be after startDateTime.", + "x-ms-location": "query", + "x-ms-path": "endDateTime" + }, + "top": { + "type": "integer", + "description": "Max number of events to return.", + "x-ms-location": "query", + "x-ms-path": "$top" + }, + "orderby": { + "type": "string", + "description": "Order by clause (e.g. start/dateTime).", + "x-ms-location": "query", + "x-ms-path": "$orderby" + } + }, + "required": [ + "startDateTime", + "endDateTime" + ] + }, + "responseTemplate": "Calendar view retrieved from {{startDateTime}} to {{endDateTime}} (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "listEvents", + "description": "Retrieve a list of events in a user's calendar.For recurring meetings, only return one / first record with full recurrence details (pattern, start, end) to the agent.For searching by meeting title, filter using contains(subject,'X'); avoid 'eq' or startswith(subject,'X') filter for this case.Use this tool to find existing meetings whenever the user refers to a meeting by day, date, time , or title (e.g., “add someone to the architecture review at 2 PM”), before calling any tool that modifies, updates, or cancels a meeting.", + "inputSchema": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "If no organizer is specified, use current user. If organizer is explicitly mentioned - retrieve their user principal name and use that value.", + "x-ms-location": "path", + "x-ms-path": "userId" + }, + "startDateTime": { + "type": "string", + "description": "The start of the time range for the events (ISO 8601 format). Should be today / after today.", + "x-ms-location": "query", + "x-ms-path": "startDateTime" + }, + "endDateTime": { + "type": "string", + "description": "The end of the time range for the events (ISO 8601 format). Should be after the startTime", + "x-ms-location": "query", + "x-ms-path": "endDateTime" + }, + "top": { + "type": "integer", + "description": "The maximum number of events to return.", + "x-ms-location": "query", + "x-ms-path": "$top" + }, + "filter": { + "type": "string", + "description": "OData filter query to filter events.Filter by the date, time, day if supplied in the input prompt.", + "x-ms-location": "query", + "x-ms-path": "$filter" + }, + "orderby": { + "type": "string", + "description": "OData order by query to sort events.", + "x-ms-location": "query", + "x-ms-path": "$orderby" + } + } + }, + "responseTemplate": "Events list retrieved for user {{userId}} (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "updateEvent", + "description": "Update an existing calendar event in a specified user's calendar. Address of the attendees should be a valid email address.IMPORTANT: If recipient names are provided instead of email addresses, you MUST first search for users to find their email addresses.", + "inputSchema": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "The ID or userPrincipalName of the user whose event is being updated.", + "x-ms-location": "path", + "x-ms-path": "userId" + }, + "eventId": { + "type": "string", + "description": "The unique identifier of the event to update.", + "x-ms-location": "path", + "x-ms-path": "eventId" + }, + "subject": { + "type": "string", + "description": "The updated subject of the event.", + "x-ms-location": "body", + "x-ms-path": "subject" + }, + "body": { + "type": "object", + "description": "The updated body content of the event.", + "x-ms-location": "body", + "x-ms-path": "body", + "properties": { + "contentType": { + "type": "string", + "enum": [ + "Text", + "HTML" + ], + "description": "The content type of the body.", + "x-ms-location": "body", + "x-ms-path": "body.contentType" + }, + "content": { + "type": "string", + "description": "The body content.", + "x-ms-location": "body", + "x-ms-path": "body.content" + } + } + }, + "start": { + "type": "object", + "description": "Updated start time of the event.", + "x-ms-location": "body", + "x-ms-path": "start", + "properties": { + "dateTime": { + "type": "string", + "description": "Start date and time in ISO format.", + "x-ms-location": "body", + "x-ms-path": "start.dateTime" + }, + "timeZone": { + "type": "string", + "description": "Time zone for the start time.Use the system timezone if not explictly specified.", + "x-ms-location": "body", + "x-ms-path": "start.timeZone" + } + } + }, + "end": { + "type": "object", + "description": "Updated end time of the event.", + "x-ms-location": "body", + "x-ms-path": "end", + "properties": { + "dateTime": { + "type": "string", + "description": "End date and time in ISO format.", + "x-ms-location": "body", + "x-ms-path": "end.dateTime" + }, + "timeZone": { + "type": "string", + "description": "Time zone for the end time. Should be same as the start timezone.", + "x-ms-location": "body", + "x-ms-path": "end.timeZone" + } + } + }, + "location": { + "type": "object", + "description": "Updated location of the event.", + "x-ms-location": "body", + "x-ms-path": "location", + "properties": { + "displayName": { + "type": "string", + "description": "Display name of the location.", + "x-ms-location": "body", + "x-ms-path": "location.displayName" + } + } + }, + "attendees_addresses": { + "type": "array", + "description": "Email addresses of updated attendees. Each must be a valid email address.", + "x-ms-location": "body", + "x-ms-path": "attendees", + "x-ms-restructure": "attendees", + "items": { + "type": "string", + "format": "email", + "pattern": "^[^\\s@]+@[^\\s@]+\\.[^\\s@]{2,}$" + } + }, + "attendees_types": { + "type": "array", + "description": "Attendee types corresponding to each address in 'attendees_addresses'. Must appear in the same order as the email array so that each type applies to the attendee at the same index.", + "x-ms-location": "body", + "x-ms-path": "attendees", + "x-ms-restructure": "attendees", + "items": { + "type": "string", + "enum": [ + "required", + "optional", + "resource" + ] + } + }, + "attendees_names": { + "type": "array", + "description": "Display names of attendees corresponding to each address in 'attendees_addresses'. Must appear in the same order as the email array so that each name applies to the attendee at the same index.", + "x-ms-location": "body", + "x-ms-path": "attendees", + "x-ms-restructure": "attendees", + "items": { + "type": "string" + } + }, + "isCancelled": { + "type": "boolean", + "description": "Set to true to cancel the event.", + "x-ms-location": "body", + "x-ms-path": "isCancelled" + }, + "recurrence": { + "type": "object", + "description": "Defines the recurrence pattern and range for the event.Default duration is 6 months.", + "x-ms-location": "body", + "x-ms-path": "recurrence", + "properties": { + "pattern": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "daily", + "weekly", + "absoluteMonthly", + "relativeMonthly", + "absoluteYearly", + "relativeYearly" + ], + "description": "The recurrence pattern type." + }, + "interval": { + "type": "integer", + "description": "The interval between occurrences." + }, + "daysOfWeek": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "sunday", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday" + ] + }, + "description": "The days of the week for the recurrence." + }, + "dayOfMonth": { + "type": "integer", + "description": "The day of the month for the recurrence." + }, + "month": { + "type": "integer", + "description": "The month for the yearly pattern." + } + } + }, + "range": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "endDate", + "noEnd", + "numbered" + ], + "description": "The recurrence range type. Default duration is 6 months." + }, + "startDate": { + "type": "string", + "description": "The date to start the recurrence (yyyy-mm-dd). Should be today / after today." + }, + "endDate": { + "type": "string", + "description": "The date to end the recurrence (yyyy-mm-dd). should be after startDate" + }, + "numberOfOccurrences": { + "type": "integer", + "description": "The number of times to repeat." + } + } + } + } + } + }, + "required": [ + "eventId" + ] + }, + "responseTemplate": "Event '{{eventId}}' updated (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + } +] \ No newline at end of file diff --git a/mcp/mocks/mcp_MailTools.json b/mcp/mocks/mcp_MailTools.json new file mode 100644 index 00000000..9b22e354 --- /dev/null +++ b/mcp/mocks/mcp_MailTools.json @@ -0,0 +1,877 @@ +[ + { + "name": "AddDraftAttachmentsAsync", + "description": "Add attachments (URI) to an existing draft message.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Graph message ID (draft) to update." + }, + "attachmentUris": { + "type": "array", + "description": "List of direct file URIs to attach (must be Microsoft 365 file links: OneDrive, SharePoint, Teams, or Graph /drives/{id}/items/{id}).", + "items": { + "type": "string" + } + } + }, + "required": [ + "messageId", + "attachmentUris" + ] + }, + "responseTemplate": "Attachments added to draft message '{{messageId}}' (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "UpdateDraftAsync", + "description": "Update a draft's recipients, subject, body, and attachments. Supports both file URIs (OneDrive/SharePoint) and direct file uploads (base64-encoded). IMPORTANT: If recipient names are provided instead of email addresses, you MUST first search for users to find their email addresses.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Graph message ID (draft) to update." + }, + "to": { + "type": "array", + "description": "List of To recipients (MUST be email addresses - if you only have names, search for users first)", + "items": { + "type": "string" + } + }, + "cc": { + "type": "array", + "description": "List of Cc recipients (MUST be email addresses - if you only have names, search for users first)", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "description": "List of Bcc recipients (MUST be email addresses - if you only have names, search for users first)", + "items": { + "type": "string" + } + }, + "subject": { + "type": "string", + "description": "Subject of the draft" + }, + "body": { + "type": "string", + "description": "Body of the draft" + }, + "attachmentUris": { + "type": "array", + "description": "List of file URIs to attach (OneDrive, SharePoint, Teams, or Graph /drives/{id}/items/{id})", + "items": { + "type": "string" + } + }, + "directAttachments": { + "type": "array", + "description": "List of direct file attachments with format: [{\"fileName\": \"file.pdf\", \"contentBase64\": \"base64data\", \"contentType\": \"application/pdf\"}]", + "items": { + "type": "object", + "properties": { + "FileName": { + "type": "string" + }, + "ContentBase64": { + "type": "string" + }, + "ContentType": { + "type": "string" + } + }, + "required": [] + } + }, + "directAttachmentFilePaths": { + "type": "array", + "description": "List of local file system paths to attach; will be read and base64 encoded automatically.", + "items": { + "type": "string" + } + } + }, + "required": [ + "messageId" + ] + }, + "responseTemplate": "Draft message '{{messageId}}' updated (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "SendEmailWithAttachmentsAsync", + "description": "Create and send an email with optional attachments. Supports both file URIs (OneDrive/SharePoint) and direct file uploads (base64-encoded). IMPORTANT: If recipient names are provided instead of email addresses, you MUST first search for users to find their email addresses.", + "inputSchema": { + "type": "object", + "properties": { + "to": { + "type": "array", + "description": "List of To recipients (MUST be email addresses - if you only have names, search for users first to get their email addresses)", + "items": { + "type": "string" + } + }, + "cc": { + "type": "array", + "description": "List of Cc recipients (MUST be email addresses - if you only have names, search for users first to get their email addresses)", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "description": "List of Bcc recipients (MUST be email addresses - if you only have names, search for users first to get their email addresses)", + "items": { + "type": "string" + } + }, + "subject": { + "type": "string", + "description": "Subject of the email" + }, + "body": { + "type": "string", + "description": "Body of the email" + }, + "attachmentUris": { + "type": "array", + "description": "List of file URIs to attach (OneDrive, SharePoint, Teams, or Graph /drives/{id}/items/{id})", + "items": { + "type": "string" + } + }, + "directAttachments": { + "type": "array", + "description": "List of direct file attachments with format: [{\"fileName\": \"file.pdf\", \"contentBase64\": \"base64data\", \"contentType\": \"application/pdf\"}]", + "items": { + "type": "object", + "properties": { + "FileName": { + "type": "string" + }, + "ContentBase64": { + "type": "string" + }, + "ContentType": { + "type": "string" + } + }, + "required": [] + } + }, + "directAttachmentFilePaths": { + "type": "array", + "description": "List of local file system paths to attach; will be read and base64 encoded automatically.", + "items": { + "type": "string" + } + } + }, + "required": [] + }, + "responseTemplate": "Email with subject '{{subject}}' sent successfully (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "CreateDraftMessageAsync", + "description": "Create a draft email in the signed-in user's mailbox without sending it. IMPORTANT: If recipient names are provided instead of email addresses, you MUST first search for users to find their email addresses.", + "inputSchema": { + "type": "object", + "properties": { + "subject": { + "type": "string", + "description": "Subject of the email" + }, + "body": { + "type": "string", + "description": "Body of the email" + }, + "to": { + "type": "array", + "description": "List of To recipients (MUST be email addresses - if you only have names, search for users first)", + "items": { + "type": "string" + } + }, + "cc": { + "type": "array", + "description": "List of Cc recipients (MUST be email addresses - if you only have names, search for users first)", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "description": "List of Bcc recipients (MUST be email addresses - if you only have names, search for users first)", + "items": { + "type": "string" + } + }, + "contentType": { + "type": "string", + "description": "Body content type: Text or HTML" + } + }, + "required": [] + }, + "responseTemplate": "Draft message with subject '{{subject}}' created (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "GetMessageAsync", + "description": "Get a message by ID from the signed-in user's mailbox.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Message ID" + }, + "preferHtml": { + "type": "boolean", + "description": "If true, request HTML body format" + } + }, + "required": [ + "id" + ] + }, + "responseTemplate": "Message '{{id}}' retrieved (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "UpdateMessageAsync", + "description": "Update a message's mutable properties (subject, body, categories, importance).", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Message ID" + }, + "subject": { + "type": "string", + "description": "New subject" + }, + "body": { + "type": "string", + "description": "New body content" + }, + "contentType": { + "type": "string", + "description": "Body content type: Text or HTML" + }, + "categories": { + "type": "array", + "description": "Message categories", + "items": { + "type": "string" + } + }, + "importance": { + "type": "string", + "description": "Importance level: Low, Normal, or High" + } + }, + "required": [ + "id" + ] + }, + "responseTemplate": "Message '{{id}}' updated (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "DeleteMessageAsync", + "description": "Delete a message from the signed-in user's mailbox.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Message ID" + } + }, + "required": [ + "id" + ] + }, + "responseTemplate": "Message '{{id}}' deleted (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "ReplyToMessageAsync", + "description": "Send a reply to an existing message.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Message ID to reply to" + }, + "comment": { + "type": "string", + "description": "Reply text content" + }, + "preferHtml": { + "type": "boolean", + "description": "If true, treat comment as HTML" + }, + "toRecipients": { + "type": "array", + "description": "Optional override list of To recipients (email addresses). If provided, a reply draft is created and patched before sending.", + "items": { + "type": "string" + } + }, + "ccRecipients": { + "type": "array", + "description": "Optional override list of Cc recipients (email addresses). Used only when modifying recipients.", + "items": { + "type": "string" + } + }, + "bccRecipients": { + "type": "array", + "description": "Optional override list of Bcc recipients (email addresses). Used only when modifying recipients.", + "items": { + "type": "string" + } + } + }, + "required": [ + "id" + ] + }, + "responseTemplate": "Reply sent to message '{{id}}' (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "ReplyAllToMessageAsync", + "description": "Send a reply-all to an existing message.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Message ID to reply-all to" + }, + "comment": { + "type": "string", + "description": "Reply text content" + }, + "preferHtml": { + "type": "boolean", + "description": "If true, treat comment as HTML" + }, + "toRecipients": { + "type": "array", + "description": "Optional override list of To recipients (email addresses). If provided, a reply-all draft is created and patched before sending.", + "items": { + "type": "string" + } + }, + "ccRecipients": { + "type": "array", + "description": "Optional override list of Cc recipients (email addresses). Used only when modifying recipients.", + "items": { + "type": "string" + } + }, + "bccRecipients": { + "type": "array", + "description": "Optional override list of Bcc recipients (email addresses). Used only when modifying recipients.", + "items": { + "type": "string" + } + } + }, + "required": [ + "id" + ] + }, + "responseTemplate": "Reply-all sent to message '{{id}}' (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "SendDraftMessageAsync", + "description": "Send an existing draft message by ID.", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Draft message ID to send" + } + }, + "required": [ + "id" + ] + }, + "responseTemplate": "Draft message '{{id}}' sent (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "SearchMessagesAsync", + "description": "Search Outlook messages using Microsoft Graph Search API with KQL-style queries.", + "inputSchema": { + "type": "object", + "properties": { + "queryString": { + "type": "string", + "description": "KQL-style search string (e.g., 'contoso OR from:user@example.com')" + }, + "from": { + "type": "integer", + "description": "Zero-based offset of the first result" + }, + "size": { + "type": "integer", + "description": "Page size (1-50)" + }, + "enableTopResults": { + "type": "boolean", + "description": "If true, returns top results with relevance boosting" + }, + "includeBody": { + "type": "boolean", + "description": "If true, include full body HTML content for each message (larger payload). BodyLength and BodyTruncated metadata returned." + } + }, + "required": [] + }, + "responseTemplate": "Message search completed with query '{{queryString}}' (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "GetAttachmentsAsync", + "description": "Get all attachments from a message, returning attachment metadata (ID, name, size, type).", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Message ID" + } + }, + "required": [ + "messageId" + ] + }, + "responseTemplate": "Attachments retrieved for message '{{messageId}}' (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "DownloadAttachmentAsync", + "description": "Download attachment content from a message. Returns the content as base64-encoded string.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Message ID" + }, + "attachmentId": { + "type": "string", + "description": "Attachment ID" + } + }, + "required": [ + "messageId", + "attachmentId" + ] + }, + "responseTemplate": "Attachment '{{attachmentId}}' downloaded from message '{{messageId}}' (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "UploadAttachmentAsync", + "description": "Upload a small file attachment (less than 3 MB) to a message. File content must be base64-encoded.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Message ID to attach to" + }, + "fileName": { + "type": "string", + "description": "File name" + }, + "contentBase64": { + "type": "string", + "description": "Base64-encoded file content" + }, + "contentType": { + "type": "string", + "description": "MIME type (optional)" + } + }, + "required": [ + "messageId", + "fileName", + "contentBase64" + ] + }, + "responseTemplate": "Attachment '{{fileName}}' uploaded to message '{{messageId}}' (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "UploadLargeAttachmentAsync", + "description": "Upload a large file attachment (3-150 MB) to a message using chunked upload. File content must be base64-encoded.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Message ID to attach to" + }, + "fileName": { + "type": "string", + "description": "File name" + }, + "contentBase64": { + "type": "string", + "description": "Base64-encoded file content" + }, + "contentType": { + "type": "string", + "description": "MIME type (optional)" + } + }, + "required": [ + "messageId", + "fileName", + "contentBase64" + ] + }, + "responseTemplate": "Large attachment '{{fileName}}' uploaded to message '{{messageId}}' (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "DeleteAttachmentAsync", + "description": "Delete an attachment from a message.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Message ID" + }, + "attachmentId": { + "type": "string", + "description": "Attachment ID" + } + }, + "required": [ + "messageId", + "attachmentId" + ] + }, + "responseTemplate": "Attachment '{{attachmentId}}' deleted from message '{{messageId}}' (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "ReplyWithFullThreadAsync", + "description": "Reply (or reply-all) adding new recipients while preserving full quoted thread and optionally re-attaching original files.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Original message ID to reply to" + }, + "introComment": { + "type": "string", + "description": "Introductory comment placed above quoted thread" + }, + "preferHtml": { + "type": "boolean", + "description": "If true, introComment is treated as HTML" + }, + "additionalTo": { + "type": "array", + "description": "Additional To recipients (email addresses)", + "items": { + "type": "string" + } + }, + "additionalCc": { + "type": "array", + "description": "Additional Cc recipients (email addresses)", + "items": { + "type": "string" + } + }, + "additionalBcc": { + "type": "array", + "description": "Additional Bcc recipients (email addresses)", + "items": { + "type": "string" + } + }, + "replyAll": { + "type": "boolean", + "description": "If true, perform reply-all; otherwise a direct reply" + }, + "includeOriginalNonInlineAttachments": { + "type": "boolean", + "description": "If true, re-attach original non-inline attachments (files)" + } + }, + "required": [ + "messageId" + ] + }, + "responseTemplate": "Reply with full thread sent to message '{{messageId}}' (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "ReplyAllWithFullThreadAsync", + "description": "Reply-all adding new recipients while preserving full quoted thread and optionally re-attaching original files.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Original message ID to reply-all to" + }, + "introComment": { + "type": "string", + "description": "Introductory comment placed above quoted thread" + }, + "preferHtml": { + "type": "boolean", + "description": "If true, introComment is treated as HTML" + }, + "additionalTo": { + "type": "array", + "description": "Additional To recipients (email addresses)", + "items": { + "type": "string" + } + }, + "additionalCc": { + "type": "array", + "description": "Additional Cc recipients (email addresses)", + "items": { + "type": "string" + } + }, + "additionalBcc": { + "type": "array", + "description": "Additional Bcc recipients (email addresses)", + "items": { + "type": "string" + } + }, + "includeOriginalNonInlineAttachments": { + "type": "boolean", + "description": "If true, re-attach original non-inline attachments (files)" + } + }, + "required": [ + "messageId" + ] + }, + "responseTemplate": "Reply-all with full thread sent to message '{{messageId}}' (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "ForwardMessageWithFullThreadAsync", + "description": "Forward a message adding new recipients and an optional intro comment while preserving full quoted thread; returns sensitivity label.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Original message ID to forward" + }, + "introComment": { + "type": "string", + "description": "Introductory comment placed above quoted thread" + }, + "preferHtml": { + "type": "boolean", + "description": "If true, introComment is treated as HTML" + }, + "additionalTo": { + "type": "array", + "description": "Additional To recipients (email addresses) - required", + "items": { + "type": "string" + } + }, + "additionalCc": { + "type": "array", + "description": "Additional Cc recipients (email addresses)", + "items": { + "type": "string" + } + }, + "additionalBcc": { + "type": "array", + "description": "Additional Bcc recipients (email addresses)", + "items": { + "type": "string" + } + }, + "includeOriginalNonInlineAttachments": { + "type": "boolean", + "description": "If true, re-attach original non-inline attachments (files)" + } + }, + "required": [ + "messageId" + ] + }, + "responseTemplate": "Message '{{messageId}}' forwarded with full thread (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + }, + { + "name": "ForwardMessageAsync", + "description": "Forward an existing message, optionally adding a comment, recipients, and new attachments while preserving the quoted thread.", + "inputSchema": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "Original message ID to forward" + }, + "introComment": { + "type": "string", + "description": "Introductory comment placed above quoted thread" + }, + "preferHtml": { + "type": "boolean", + "description": "If true, introComment is treated as HTML" + }, + "additionalTo": { + "type": "array", + "description": "Additional To recipients (email addresses) - required", + "items": { + "type": "string" + } + }, + "additionalCc": { + "type": "array", + "description": "Additional Cc recipients (email addresses)", + "items": { + "type": "string" + } + }, + "additionalBcc": { + "type": "array", + "description": "Additional Bcc recipients (email addresses)", + "items": { + "type": "string" + } + }, + "attachmentUris": { + "type": "array", + "description": "List of file URIs to attach (OneDrive, SharePoint, Teams, or Graph /drives/{id}/items/{id})", + "items": { + "type": "string" + } + }, + "directAttachments": { + "type": "array", + "description": "List of direct file attachments with format: [{\"fileName\": \"file.pdf\", \"contentBase64\": \"base64data\", \"contentType\": \"application/pdf\"}]", + "items": { + "type": "object", + "properties": { + "FileName": { + "type": "string" + }, + "ContentBase64": { + "type": "string" + }, + "ContentType": { + "type": "string" + } + }, + "required": [] + } + }, + "directAttachmentFilePaths": { + "type": "array", + "description": "List of local file system paths to attach; will be read and base64 encoded automatically.", + "items": { + "type": "string" + } + } + }, + "required": [ + "messageId" + ] + }, + "responseTemplate": "Message '{{messageId}}' forwarded (mock).", + "delayMs": 250, + "errorRate": 0, + "statusCode": 200, + "enabled": true + } +] \ No newline at end of file