diff --git a/Cellm.sln b/Cellm.sln index 6602820..d6c7a0e 100644 --- a/Cellm.sln +++ b/Cellm.sln @@ -14,6 +14,8 @@ Project("{B7DD6F7E-DEF8-4E67-B5B7-07EF123DB6F0}") = "Cellm.Installer", "src\Cell EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cellm.Installer.CustomActions", "src\Cellm.Installers\CustomActions\Cellm.Installer.CustomActions.csproj", "{02AB9A3F-CF5E-8F75-666D-6D8AE65C85E1}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Cellm.Mcp", "src\Cellm.Mcp\Cellm.Mcp.csproj", "{7F995EA7-C647-4323-9689-9FCBF3A2AE7A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -90,6 +92,22 @@ Global {02AB9A3F-CF5E-8F75-666D-6D8AE65C85E1}.Release|x64.Build.0 = Release|Any CPU {02AB9A3F-CF5E-8F75-666D-6D8AE65C85E1}.Release|x86.ActiveCfg = Release|Any CPU {02AB9A3F-CF5E-8F75-666D-6D8AE65C85E1}.Release|x86.Build.0 = Release|Any CPU + {7F995EA7-C647-4323-9689-9FCBF3A2AE7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7F995EA7-C647-4323-9689-9FCBF3A2AE7A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7F995EA7-C647-4323-9689-9FCBF3A2AE7A}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {7F995EA7-C647-4323-9689-9FCBF3A2AE7A}.Debug|ARM64.Build.0 = Debug|Any CPU + {7F995EA7-C647-4323-9689-9FCBF3A2AE7A}.Debug|x64.ActiveCfg = Debug|Any CPU + {7F995EA7-C647-4323-9689-9FCBF3A2AE7A}.Debug|x64.Build.0 = Debug|Any CPU + {7F995EA7-C647-4323-9689-9FCBF3A2AE7A}.Debug|x86.ActiveCfg = Debug|Any CPU + {7F995EA7-C647-4323-9689-9FCBF3A2AE7A}.Debug|x86.Build.0 = Debug|Any CPU + {7F995EA7-C647-4323-9689-9FCBF3A2AE7A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7F995EA7-C647-4323-9689-9FCBF3A2AE7A}.Release|Any CPU.Build.0 = Release|Any CPU + {7F995EA7-C647-4323-9689-9FCBF3A2AE7A}.Release|ARM64.ActiveCfg = Release|Any CPU + {7F995EA7-C647-4323-9689-9FCBF3A2AE7A}.Release|ARM64.Build.0 = Release|Any CPU + {7F995EA7-C647-4323-9689-9FCBF3A2AE7A}.Release|x64.ActiveCfg = Release|Any CPU + {7F995EA7-C647-4323-9689-9FCBF3A2AE7A}.Release|x64.Build.0 = Release|Any CPU + {7F995EA7-C647-4323-9689-9FCBF3A2AE7A}.Release|x86.ActiveCfg = Release|Any CPU + {7F995EA7-C647-4323-9689-9FCBF3A2AE7A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Cellm.Mcp/Cellm.Mcp.csproj b/src/Cellm.Mcp/Cellm.Mcp.csproj new file mode 100644 index 0000000..cf13f61 --- /dev/null +++ b/src/Cellm.Mcp/Cellm.Mcp.csproj @@ -0,0 +1,16 @@ + + + + Exe + net9.0-windows + enable + enable + true + + + + + + + + diff --git a/src/Cellm.Mcp/CellmClient.cs b/src/Cellm.Mcp/CellmClient.cs new file mode 100644 index 0000000..eec8885 --- /dev/null +++ b/src/Cellm.Mcp/CellmClient.cs @@ -0,0 +1,63 @@ +using System.IO.Pipes; +using System.Security.Principal; +using System.Text; +using System.Text.Json; + +namespace Cellm.Mcp; + +internal class CellmClient +{ + private static readonly TimeSpan _connectTimeout = TimeSpan.FromSeconds(5); + private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); + private readonly string _pipeName; + + public CellmClient() + : this(GetPipeName()) + { + } + + internal CellmClient(string pipeName) + { + _pipeName = pipeName; + } + + public async Task PromptAsync(PromptRequest request, CancellationToken cancellationToken) + { + await using var pipe = new NamedPipeClientStream( + serverName: ".", + pipeName: _pipeName, + direction: PipeDirection.InOut, + options: PipeOptions.Asynchronous); + + try + { + await pipe.ConnectAsync(_connectTimeout, cancellationToken).ConfigureAwait(false); + } + catch (TimeoutException ex) + { + throw new InvalidOperationException("Cellm is not running in Excel.", ex); + } + + using var reader = new StreamReader(pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); + using var writer = new StreamWriter(pipe, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), leaveOpen: true) + { + AutoFlush = true + }; + + await writer.WriteLineAsync(JsonSerializer.Serialize(request, _jsonOptions)).ConfigureAwait(false); + + var responseJson = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("Cellm closed the connection without returning a response."); + + return JsonSerializer.Deserialize(responseJson, _jsonOptions) + ?? throw new InvalidOperationException("Cellm returned an invalid response."); + } + + private static string GetPipeName() + { + var user = WindowsIdentity.GetCurrent().User?.Value + ?? throw new InvalidOperationException("Unable to identify the current Windows user."); + + return $"Cellm-{user}"; + } +} diff --git a/src/Cellm.Mcp/CellmTools.cs b/src/Cellm.Mcp/CellmTools.cs new file mode 100644 index 0000000..81e154d --- /dev/null +++ b/src/Cellm.Mcp/CellmTools.cs @@ -0,0 +1,40 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; + +namespace Cellm.Mcp; + +[McpServerToolType] +internal class CellmTools(CellmClient client) +{ + [McpServerTool( + Name = "cellm_prompt", + Title = "Run a Cellm prompt in Excel", + Destructive = true, + Idempotent = false, + OpenWorld = true, + UseStructuredContent = true)] + [Description("Writes a PROMPT or PROMPTMODEL formula to an Excel cell and waits for Excel to return the result.")] + public Task PromptAsync( + [Description("The name of the open Excel workbook.")] string workbook, + [Description("The name of the worksheet in that workbook.")] string worksheet, + [Description("The target cell address, for example B2.")] string cell, + [Description("The prompt text sent to the configured model.")] string prompt, + [Description("Optional cell or range addresses on the same worksheet to include as context.")] string[]? ranges = null, + [Description("Optional provider and model in provider/model form. Omit this to use Cellm's configured default.")] string? providerAndModel = null, + [Description("Whether Cellm may replace an existing value or formula in the target cell.")] bool overwrite = false, + CancellationToken cancellationToken = default) + { + return client.PromptAsync( + new PromptRequest( + Version: 1, + Method: "prompt", + Workbook: workbook, + Worksheet: worksheet, + Cell: cell, + Prompt: prompt, + Ranges: ranges ?? [], + ProviderAndModel: providerAndModel, + Overwrite: overwrite), + cancellationToken); + } +} diff --git a/src/Cellm.Mcp/Program.cs b/src/Cellm.Mcp/Program.cs new file mode 100644 index 0000000..5b4096d --- /dev/null +++ b/src/Cellm.Mcp/Program.cs @@ -0,0 +1,19 @@ +using Cellm.Mcp; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Logging.AddConsole(options => +{ + options.LogToStandardErrorThreshold = LogLevel.Trace; +}); + +builder.Services + .AddSingleton() + .AddMcpServer() + .WithStdioServerTransport() + .WithTools(); + +await builder.Build().RunAsync(); diff --git a/src/Cellm.Mcp/PromptRequest.cs b/src/Cellm.Mcp/PromptRequest.cs new file mode 100644 index 0000000..482285d --- /dev/null +++ b/src/Cellm.Mcp/PromptRequest.cs @@ -0,0 +1,12 @@ +namespace Cellm.Mcp; + +internal record PromptRequest( + int Version, + string Method, + string Workbook, + string Worksheet, + string Cell, + string Prompt, + IReadOnlyList Ranges, + string? ProviderAndModel, + bool Overwrite); diff --git a/src/Cellm.Mcp/PromptResponse.cs b/src/Cellm.Mcp/PromptResponse.cs new file mode 100644 index 0000000..d8fbe55 --- /dev/null +++ b/src/Cellm.Mcp/PromptResponse.cs @@ -0,0 +1,11 @@ +namespace Cellm.Mcp; + +public record PromptResponse( + int Version, + string Status, + string Workbook, + string Worksheet, + string Cell, + string? Formula = null, + object? Value = null, + string? Error = null); diff --git a/src/Cellm.Mcp/Properties/AssemblyInfo.cs b/src/Cellm.Mcp/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..90c7298 --- /dev/null +++ b/src/Cellm.Mcp/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Cellm.Tests")] diff --git a/src/Cellm.Mcp/README.md b/src/Cellm.Mcp/README.md new file mode 100644 index 0000000..4727320 --- /dev/null +++ b/src/Cellm.Mcp/README.md @@ -0,0 +1,14 @@ +# Cellm MCP server + +This project exposes Cellm to MCP clients over standard input/output. Excel must be running with the Cellm add-in loaded. + +For a development build, configure the client to run: + +```json +{ + "command": "dotnet", + "args": ["C:\\path\\to\\Cellm.Mcp.dll"] +} +``` + +The first tool, `cellm_prompt`, writes a normal `PROMPT` or `PROMPTMODEL` formula to an explicit workbook, worksheet, and cell. It then waits for Excel's result. Cancelling the MCP call stops waiting but does not replace Cellm's existing Excel cancellation behavior. diff --git a/src/Cellm.Mcp/packages.lock.json b/src/Cellm.Mcp/packages.lock.json new file mode 100644 index 0000000..b7bb5d7 --- /dev/null +++ b/src/Cellm.Mcp/packages.lock.json @@ -0,0 +1,363 @@ +{ + "version": 1, + "dependencies": { + "net9.0-windows7.0": { + "Microsoft.Extensions.Hosting": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "tL9FkfV64GPUDSPvwrgyw42LVzsnVAnyrqJEuZVJbODgrQ3eL63zmzEcVWoCHzfgqUhWggzbgAyUCnz/zfI3Pg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.Binder": "10.0.10", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.10", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.10", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.10", + "Microsoft.Extensions.Configuration.Json": "10.0.10", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.10", + "Microsoft.Extensions.DependencyInjection": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Diagnostics": "10.0.10", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "Microsoft.Extensions.FileProviders.Physical": "10.0.10", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging.Configuration": "10.0.10", + "Microsoft.Extensions.Logging.Console": "10.0.10", + "Microsoft.Extensions.Logging.Debug": "10.0.10", + "Microsoft.Extensions.Logging.EventLog": "10.0.10", + "Microsoft.Extensions.Logging.EventSource": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "ModelContextProtocol": { + "type": "Direct", + "requested": "[2.1.0, )", + "resolved": "2.1.0", + "contentHash": "Oa4rU7EL9C2qyFjQj1dx+ysGMzfWDRpM8RRaUMmLGs5vPvfJ9xyz4ZtyF4ychY+Nx1b/auGCqIQLqSz/IpPkKA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.10", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.10", + "ModelContextProtocol.Core": "[2.1.0]" + } + }, + "Microsoft.Extensions.AI.Abstractions": { + "type": "Transitive", + "resolved": "10.8.3", + "contentHash": "K0B05oApxmviWalNHPMBBcRC7erKiDATz3ENNR/jqTR9JwIwLRefgDhj2jCRwL1aca99pXUe0qyQC73/xIuZig==", + "dependencies": { + "System.Text.Json": "10.0.10" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "plJWK2zpWuuyxI8F8s2scx6Je7N1Ajjs6HvYUGKwRnDMWIVIz9FHwAkiT7ASgrvAOd10T0FPVlh9BzAJJME+jg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "GqmN2o1CkJvk7uWp+p4CwBYW0w/zfoEbvsiFDbO2G8l1Uz+mrDAbAcZiXhU2lufKPby1cjAUdd5GTWpebYOkOA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "33cBeR2HRbzHUTtmcmLdNOApneNGcymwwL4arHuotgVK9Frba8kcDTrvVTj7cSCmF1R9OiSbZH0KxNOwab3HUg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "KRfFSSCV58vEdU7mPED/YMzeovIWF5P0g8s9K8n9HEfy0/WzMq37SrPdXdFN5/dFT/rPMHpF7AvpoXHckbcBFg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "ZOhZYwvbXGTgGVRwswIirofEMVHuWdxjdh0JeUZXwaF9cgcjXdz/t0ELtgaevw7ezTyv47yPNCgGreWtLkn3IQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "Microsoft.Extensions.FileProviders.Physical": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "uvJ6sHwjgrkMEJOgiC76G0mcZGXerwyyWkwX34EOjCbxKG6TCtfAoqDKAMsCvEBf9HxjlGQEgqsSMOGCmGBf+A==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.10", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "System.Text.Json": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.UserSecrets": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "1s1sKFTk/Foam64JY6+m/diH8drL3Wx6V3gtSd5v1IEZtszZYyc1pW8uRnMblzpNiR0l0t8gGk7tXj3xHzFgdg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.Json": "10.0.10", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "Microsoft.Extensions.FileProviders.Physical": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "Kr/e7lUf4+N8tacbqJ2Ctwe/HarKdAc9ZkgKVVqvtJDBKbez+T/KnUwu82KSlnBp/SrpBcxc7u7xkE2oUZT/5Q==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.10", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.10" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "9uWiKpeOVac355STyChWR/pliFX/5CeLqChW9kKsaxyDH4EUTZxMkT4Jwp/J/peLm0GBFmSX5c0WCse3yCnq1Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "System.Diagnostics.DiagnosticSource": "10.0.10" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "c5zqFCY9DiIpMovLd7/d/CTiEtrMOuQ639dhv3PABtKQIKNQikSHwQt8+N679uii9q+B55lgK28Uv64FOwEu8w==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "jhJAyo38kSrH3ARvWUk0h8itogVnQu2DCZuPo+s0Z+tXes0ugTxMPaHYzap85785eHQmPFqD9TYERqBbtGxn/w==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "jSOCVxEwCd4Aq925kJVz1kSO1EpX2OHYKL04qVREXkDU7Ce3pVDdHPYm+fEy8y/th2kJf/DAstRHpJAqoNWP8w==" + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5LugpYGHk+mkn0a8IZgcyfBca8PCTAU9RQFoMrTdtOOidq88M2SI5f3px6ugnzgxC+eTkvYYJi8pzlUnG5xdAQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.10", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "System.Diagnostics.DiagnosticSource": "10.0.10" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "cLrqxkuEfcilZ8SjK+9KAnpLk9lOoMPaOokF+wRUYie+iUEcdX4/p/+gJkt0BYgWLthjpBUCkVTBI6Kxg0nsOw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.Binder": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.10" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "VIlNzPwPS0GeQVSmCqqo36ugryX3LpE9ul6gEkks5VLET3weH/XMLeWmclwfoGn4Nxi2mwVibB+OZBVJ9tDqvg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging.Configuration": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "System.Text.Json": "10.0.10" + } + }, + "Microsoft.Extensions.Logging.Debug": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "8+TZBnV5fgBXoVNJ5ROSErUwYogk4hOgV7c2HWK1u5cqKGmiUTUn7+KqZ35iQu8e/B7Ykccyz5OTjdXcidNZ9g==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Logging.EventLog": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "0RE4951AzQ+YD4gVrvbq0BhdsiBgSDo44yM7+QBZ2mrmMJeNjY+teCIYfUjqDPVYnKs0HR6SkkhgrX1YgXZq3Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "System.Diagnostics.EventLog": "10.0.10" + } + }, + "Microsoft.Extensions.Logging.EventSource": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "85SAPwXhJtdBInzN2k7SChiFiBGh3KOWay5AfoY+GREF6P7oZA98+ST2p7Z9384iLKYjkZSKIZ/FqIO5aojtNw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10", + "System.Text.Json": "10.0.10" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "tnBmu/LwF25ZQK+HBNCu2xrwnkKoB/XEbJyooGGoYxHrhvxbSKi7eOFiJ4AXBy/QU4vtCvCJfoi8k9Ej72qzOQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.Binder": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ==" + }, + "ModelContextProtocol.Core": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "cU/urrhRxE4/iSyBIJI7QOaFqSP1FOEnwEHsct9n6t6/XluCAFD9iqnrPkBAsEYr+f/G4tVQ21U+6wN/6fQvOg==", + "dependencies": { + "Microsoft.Extensions.AI.Abstractions": "10.8.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "System.Net.ServerSentEvents": "10.0.10" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "cjtKi6ERMYWp6b9UTVPcwDT29PjKDtlM3W9OwnWL5abRsI8ku42Q2wqZoLIIXJnT/XF2s2CjuK8Nl4a3mmTxQQ==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "OvGz3PrzuAI/Sj7LTcXcCe3FClRI1IyRMZjNONcZtFh+Ww7nAtSh4kh08r8KVe/xxkXJPjR0Y1jF7H+N42d4xQ==" + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "7WX0W96y3dpQdYG4sEGdh38g3/0lOD4/dKbn2rRVOVzKhzoZUn2gKNIKaFeKWs8RCbpFfmmEWsRhSy95hMpvqA==" + }, + "System.Net.ServerSentEvents": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "1m3dGOl5YI9VhOE+MPCSII+WXZcyYVr5D/UbBifOUxkrx2npczhWjdl0PYZ1tMGygVce1mIfUDhdM1LBiEQFNw==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "o16m2YpDN/pjHsnxf9pTGwkpcuvjW8v1/wGUwJtM1c3QZUKm7ZEO/eYRJg7iIx6GxS2Zv9lAMHpiQwHDdgqauA==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "bmsO6UdYtBdtn32zYXfsh7KlyTIzV/3V9hdT9RIb4pXKgYOsNxXR+VbWigNwBtNFVGYGm6Hwmqw5a+/IWFd36Q==", + "dependencies": { + "System.IO.Pipelines": "10.0.10", + "System.Text.Encodings.Web": "10.0.10" + } + } + } + } +} \ No newline at end of file diff --git a/src/Cellm.Tests/Cellm.Tests.csproj b/src/Cellm.Tests/Cellm.Tests.csproj index 20a1fba..24ac39b 100644 --- a/src/Cellm.Tests/Cellm.Tests.csproj +++ b/src/Cellm.Tests/Cellm.Tests.csproj @@ -25,6 +25,7 @@ + diff --git a/src/Cellm.Tests/Integration/McpPromptTests.cs b/src/Cellm.Tests/Integration/McpPromptTests.cs new file mode 100644 index 0000000..f5b5c59 --- /dev/null +++ b/src/Cellm.Tests/Integration/McpPromptTests.cs @@ -0,0 +1,61 @@ +using Cellm.Mcp; +using ExcelDna.Testing; +using Microsoft.Office.Interop.Excel; +using Xunit; + +namespace Cellm.Tests.Integration; + +[ExcelTestSettings(AddIn = @"..\..\..\..\Cellm\bin\Debug\net9.0-windows\Cellm-AddIn")] +[Trait("Category", "Excel")] +[Trait("Category", "Integration")] +public class McpPromptTests : IDisposable +{ + private readonly Workbook _workbook; + + public McpPromptTests() + { + _workbook = Util.Application.Workbooks.Add(); + } + + public void Dispose() + { + try + { + _workbook.Close(SaveChanges: false); + } + catch + { + // Ignore cleanup errors - COM objects may already be released + } + } + + [ExcelFact] + public void Prompt_UsesExcelFormulaPathAndReturnsOutcome() + { + var worksheet = (Worksheet)_workbook.Worksheets[1]; + var client = new CellmClient(); + var request = new PromptRequest( + 1, + "prompt", + _workbook.Name, + worksheet.Name, + "B2", + "What is 2+2?", + [], + "NotAProvider/not-a-model", + false); + + var responseTask = Task.Run(() => client.PromptAsync(request, CancellationToken.None)); + Automation.WaitFor(() => responseTask.IsCompleted, 30_000); + + Assert.True(responseTask.IsCompletedSuccessfully, responseTask.Exception?.ToString()); +#pragma warning disable VSTHRD002 // ExcelDna.Testing's wait above pumps Excel until the task completes. + var response = responseTask.GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 + + Assert.Equal("completed", response.Status); + Assert.Equal("=PROMPTMODEL(\"NotAProvider/not-a-model\", \"What is 2+2?\")", response.Formula); + Assert.Equal(response.Formula, Convert.ToString(((dynamic)worksheet.Range["B2"]).Formula2)); + Assert.Contains("Unsupported provider", response.Value?.ToString()); + } +} diff --git a/src/Cellm.Tests/Unit/Mcp/CellmClientTests.cs b/src/Cellm.Tests/Unit/Mcp/CellmClientTests.cs new file mode 100644 index 0000000..9b123ba --- /dev/null +++ b/src/Cellm.Tests/Unit/Mcp/CellmClientTests.cs @@ -0,0 +1,59 @@ +using System.IO; +using System.IO.Pipes; +using System.Text; +using System.Text.Json; +using Cellm.Mcp; +using Xunit; + +namespace Cellm.Tests.Unit.Mcp; + +public class CellmClientTests +{ + [Fact] + public async Task PromptAsync_ExchangesJsonWithCellmAsync() + { + var pipeName = $"Cellm-Test-{Guid.NewGuid():N}"; + var serverTask = RunServerAsync(pipeName); + var client = new CellmClient(pipeName); + var request = new Cellm.Mcp.PromptRequest( + 1, + "prompt", + "Book1", + "Sheet1", + "B2", + "What is 2+2?", + ["A1"], + null, + false); + + var response = await client.PromptAsync(request, CancellationToken.None); + await serverTask; + + Assert.Equal("completed", response.Status); + Assert.Equal("4", response.Value?.ToString()); + } + + private static async Task RunServerAsync(string pipeName) + { + await using var pipe = new NamedPipeServerStream( + pipeName, + PipeDirection.InOut, + 1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + + await pipe.WaitForConnectionAsync(CancellationToken.None); + + using var reader = new StreamReader(pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); + using var writer = new StreamWriter(pipe, new UTF8Encoding(false), leaveOpen: true) { AutoFlush = true }; + var requestJson = await reader.ReadLineAsync(CancellationToken.None); + using var request = JsonDocument.Parse(requestJson!); + + Assert.Equal("prompt", request.RootElement.GetProperty("method").GetString()); + Assert.Equal("Book1", request.RootElement.GetProperty("workbook").GetString()); + Assert.Equal("B2", request.RootElement.GetProperty("cell").GetString()); + + var response = new PromptResponse(1, "completed", "Book1", "Sheet1", "B2", "=PROMPT(\"What is 2+2?\")", "4"); + await writer.WriteLineAsync(JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web))); + } +} diff --git a/src/Cellm.Tests/Unit/Mcp/CellmServerTests.cs b/src/Cellm.Tests/Unit/Mcp/CellmServerTests.cs new file mode 100644 index 0000000..e33b8ed --- /dev/null +++ b/src/Cellm.Tests/Unit/Mcp/CellmServerTests.cs @@ -0,0 +1,41 @@ +using System.IO; +using ModelContextProtocol.Client; +using Xunit; + +namespace Cellm.Tests.Unit.Mcp; + +public class CellmServerTests +{ + [Fact] + public async Task Server_ExposesCellmPromptToolAsync() + { + var serverPath = Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, + "..", + "..", + "..", + "..", + "Cellm.Mcp", + "bin", + "Debug", + "net9.0-windows", + "Cellm.Mcp.dll")); + + Assert.True(File.Exists(serverPath), $"MCP server not found at {serverPath}"); + + var transport = new StdioClientTransport(new StdioClientTransportOptions + { + Name = "Cellm", + Command = "dotnet", + Arguments = [serverPath] + }); + + await using var client = await McpClient.CreateAsync( + transport, + cancellationToken: CancellationToken.None); + var tools = await client.ListToolsAsync(cancellationToken: CancellationToken.None); + + var tool = Assert.Single(tools); + Assert.Equal("cellm_prompt", tool.Name); + } +} diff --git a/src/Cellm.Tests/Unit/Mcp/PromptRequestHandlerTests.cs b/src/Cellm.Tests/Unit/Mcp/PromptRequestHandlerTests.cs new file mode 100644 index 0000000..d8d3928 --- /dev/null +++ b/src/Cellm.Tests/Unit/Mcp/PromptRequestHandlerTests.cs @@ -0,0 +1,23 @@ +using Cellm.AddIn.Control; +using Xunit; + +namespace Cellm.Tests.Unit.Mcp; + +public class PromptRequestHandlerTests +{ + [Fact] + public void BuildFormula_UsesDefaultProvider() + { + var formula = PromptRequestHandler.BuildFormula("Summarize this", ["A1:B2"], null); + + Assert.Equal("=PROMPT(\"Summarize this\", A1:B2)", formula); + } + + [Fact] + public void BuildFormula_UsesExplicitProviderAndEscapesQuotes() + { + var formula = PromptRequestHandler.BuildFormula("Say \"hello\"", [], "OpenAi/gpt-5"); + + Assert.Equal("=PROMPTMODEL(\"OpenAi/gpt-5\", \"Say \"\"hello\"\"\")", formula); + } +} diff --git a/src/Cellm.Tests/packages.lock.json b/src/Cellm.Tests/packages.lock.json index 6a0715c..26b6cd4 100644 --- a/src/Cellm.Tests/packages.lock.json +++ b/src/Cellm.Tests/packages.lock.json @@ -336,6 +336,24 @@ "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" } }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "33cBeR2HRbzHUTtmcmLdNOApneNGcymwwL4arHuotgVK9Frba8kcDTrvVTj7cSCmF1R9OiSbZH0KxNOwab3HUg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "KRfFSSCV58vEdU7mPED/YMzeovIWF5P0g8s9K8n9HEfy0/WzMq37SrPdXdFN5/dFT/rPMHpF7AvpoXHckbcBFg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" + } + }, "Microsoft.Extensions.Configuration.FileExtensions": { "type": "Transitive", "resolved": "10.0.10", @@ -348,6 +366,17 @@ "Microsoft.Extensions.Primitives": "10.0.10" } }, + "Microsoft.Extensions.Configuration.UserSecrets": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "1s1sKFTk/Foam64JY6+m/diH8drL3Wx6V3gtSd5v1IEZtszZYyc1pW8uRnMblzpNiR0l0t8gGk7tXj3xHzFgdg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.Json": "10.0.10", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "Microsoft.Extensions.FileProviders.Physical": "10.0.10" + } + }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", "resolved": "10.0.10", @@ -420,6 +449,35 @@ "resolved": "10.0.10", "contentHash": "jSOCVxEwCd4Aq925kJVz1kSO1EpX2OHYKL04qVREXkDU7Ce3pVDdHPYm+fEy8y/th2kJf/DAstRHpJAqoNWP8w==" }, + "Microsoft.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "tL9FkfV64GPUDSPvwrgyw42LVzsnVAnyrqJEuZVJbODgrQ3eL63zmzEcVWoCHzfgqUhWggzbgAyUCnz/zfI3Pg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.Binder": "10.0.10", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.10", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.10", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.10", + "Microsoft.Extensions.Configuration.Json": "10.0.10", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.10", + "Microsoft.Extensions.DependencyInjection": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Diagnostics": "10.0.10", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "Microsoft.Extensions.FileProviders.Physical": "10.0.10", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging.Configuration": "10.0.10", + "Microsoft.Extensions.Logging.Console": "10.0.10", + "Microsoft.Extensions.Logging.Debug": "10.0.10", + "Microsoft.Extensions.Logging.EventLog": "10.0.10", + "Microsoft.Extensions.Logging.EventSource": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "Transitive", "resolved": "10.0.10", @@ -521,6 +579,31 @@ "Microsoft.Extensions.Logging.Abstractions": "10.0.10" } }, + "Microsoft.Extensions.Logging.EventLog": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "0RE4951AzQ+YD4gVrvbq0BhdsiBgSDo44yM7+QBZ2mrmMJeNjY+teCIYfUjqDPVYnKs0HR6SkkhgrX1YgXZq3Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "System.Diagnostics.EventLog": "10.0.10" + } + }, + "Microsoft.Extensions.Logging.EventSource": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "85SAPwXhJtdBInzN2k7SChiFiBGh3KOWay5AfoY+GREF6P7oZA98+ST2p7Z9384iLKYjkZSKIZ/FqIO5aojtNw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10", + "System.Text.Json": "10.0.10" + } + }, "Microsoft.Extensions.ObjectPool": { "type": "Transitive", "resolved": "9.0.18", @@ -1038,8 +1121,8 @@ }, "System.Diagnostics.EventLog": { "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" + "resolved": "10.0.10", + "contentHash": "OvGz3PrzuAI/Sj7LTcXcCe3FClRI1IyRMZjNONcZtFh+Ww7nAtSh4kh08r8KVe/xxkXJPjR0Y1jF7H+N42d4xQ==" }, "System.Diagnostics.Tracing": { "type": "Transitive", @@ -1641,6 +1724,13 @@ "Svg": "[3.4.8, )", "System.Net.Http.Json": "[10.0.10, )" } + }, + "cellm.mcp": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Hosting": "[10.0.10, )", + "ModelContextProtocol": "[2.1.0, )" + } } } } diff --git a/src/Cellm/AddIn/CellmAddIn.cs b/src/Cellm/AddIn/CellmAddIn.cs index 6f7fa11..e612f80 100644 --- a/src/Cellm/AddIn/CellmAddIn.cs +++ b/src/Cellm/AddIn/CellmAddIn.cs @@ -1,5 +1,6 @@ using System.Reflection; using Cellm.AddIn.Configuration; +using Cellm.AddIn.Control; using Cellm.AddIn.Exceptions; using Cellm.Models; using Cellm.Models.Behaviors; @@ -48,6 +49,8 @@ public void AutoOpen() SentrySdk.CaptureException(e); return e.Message; }); + + Services.GetRequiredService().Start(); } public void AutoClose() @@ -178,6 +181,8 @@ internal static ServiceCollection ConfigureServices(ServiceCollection services, .AddTransient() .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton() .AddRateLimiter(resilienceConfiguration) .AddResilientHttpClient(resilienceConfiguration, cellmAddInConfiguration, Provider.Anthropic) .AddResilientHttpClient(resilienceConfiguration, cellmAddInConfiguration, Provider.Azure) diff --git a/src/Cellm/AddIn/Control/ControlServer.cs b/src/Cellm/AddIn/Control/ControlServer.cs new file mode 100644 index 0000000..9bc6d18 --- /dev/null +++ b/src/Cellm/AddIn/Control/ControlServer.cs @@ -0,0 +1,124 @@ +using System.IO.Pipes; +using System.Security.Principal; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Cellm.AddIn.Control; + +internal sealed class ControlServer(PromptRequestHandler promptRequestHandler, ILogger logger) : IDisposable +{ + private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); + private CancellationTokenSource? _cancellationTokenSource; + private Task? _serverTask; + + public void Start() + { + if (_serverTask is not null) + { + return; + } + + promptRequestHandler.Start(); + _cancellationTokenSource = new CancellationTokenSource(); + _serverTask = RunAsync(_cancellationTokenSource.Token); + } + + private async Task RunAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + var pipe = new NamedPipeServerStream( + pipeName: GetPipeName(), + direction: PipeDirection.InOut, + maxNumberOfServerInstances: NamedPipeServerStream.MaxAllowedServerInstances, + transmissionMode: PipeTransmissionMode.Byte, + options: PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + + try + { + await pipe.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false); + _ = HandleConnectionAsync(pipe, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + await pipe.DisposeAsync().ConfigureAwait(false); + break; + } + catch (Exception ex) + { + await pipe.DisposeAsync().ConfigureAwait(false); + logger.LogError(ex, "Cellm control server failed while accepting a connection"); + } + } + } + + private async Task HandleConnectionAsync(NamedPipeServerStream pipe, CancellationToken cancellationToken) + { + await using (pipe.ConfigureAwait(false)) + { + try + { + using var reader = new StreamReader(pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); + using var writer = new StreamWriter(pipe, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), leaveOpen: true) + { + AutoFlush = true + }; + + var requestJson = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The request was empty."); + var request = JsonSerializer.Deserialize(requestJson, _jsonOptions) + ?? throw new InvalidOperationException("The request was invalid."); + + PromptResponse response; + if (request.Version != 1) + { + response = Error(request, $"Unsupported control protocol version {request.Version}."); + } + else if (!string.Equals(request.Method, "prompt", StringComparison.Ordinal)) + { + response = Error(request, $"Unsupported method '{request.Method}'."); + } + else + { + response = await promptRequestHandler.HandleAsync(request, cancellationToken).ConfigureAwait(false); + } + + await writer.WriteLineAsync(JsonSerializer.Serialize(response, _jsonOptions)).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (IOException ex) + { + logger.LogDebug(ex, "Cellm MCP client disconnected"); + } + catch (Exception ex) + { + logger.LogError(ex, "Cellm control request failed"); + } + } + } + + public void Dispose() + { + _cancellationTokenSource?.Cancel(); + promptRequestHandler.Dispose(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + _serverTask = null; + } + + private static PromptResponse Error(PromptRequest request, string error) + { + return new PromptResponse(1, "error", request.Workbook, request.Worksheet, request.Cell, Error: error); + } + + private static string GetPipeName() + { + var user = WindowsIdentity.GetCurrent().User?.Value + ?? throw new InvalidOperationException("Unable to identify the current Windows user."); + + return $"Cellm-{user}"; + } +} diff --git a/src/Cellm/AddIn/Control/PromptRequest.cs b/src/Cellm/AddIn/Control/PromptRequest.cs new file mode 100644 index 0000000..f6ff63f --- /dev/null +++ b/src/Cellm/AddIn/Control/PromptRequest.cs @@ -0,0 +1,12 @@ +namespace Cellm.AddIn.Control; + +internal record PromptRequest( + int Version, + string Method, + string Workbook, + string Worksheet, + string Cell, + string Prompt, + IReadOnlyList Ranges, + string? ProviderAndModel, + bool Overwrite); diff --git a/src/Cellm/AddIn/Control/PromptRequestHandler.cs b/src/Cellm/AddIn/Control/PromptRequestHandler.cs new file mode 100644 index 0000000..c7b5eb6 --- /dev/null +++ b/src/Cellm/AddIn/Control/PromptRequestHandler.cs @@ -0,0 +1,346 @@ +using System.Runtime.InteropServices; +using ExcelDna.Integration; +using Excel = Microsoft.Office.Interop.Excel; +using Microsoft.Extensions.Logging; + +namespace Cellm.AddIn.Control; + +internal sealed class PromptRequestHandler(ILogger logger) : IDisposable +{ + private const int _excelErrorGettingData = -2146826245; + private readonly Dictionary _pending = new(StringComparer.OrdinalIgnoreCase); + private Excel.Application? _application; + private bool _started; + + public void Start() + { + if (_started) + { + return; + } + + _application = (Excel.Application)ExcelDnaUtil.Application; + ExcelAsyncUtil.CalculationEnded += OnCalculationEnded; + ExcelAsyncUtil.CalculationCanceled += OnCalculationCanceled; + _application.SheetChange += OnSheetChange; + _application.WorkbookBeforeClose += OnWorkbookBeforeClose; + _started = true; + } + + public async Task HandleAsync(PromptRequest request, CancellationToken cancellationToken) + { + PendingPrompt? pending = null; + + try + { + await ExcelAsyncUtil.QueueAsMacroTask(() => + { + var application = _application ?? throw new InvalidOperationException("Excel is not available."); + var workbook = FindWorkbook(application, request.Workbook) + ?? throw new ArgumentException($"Workbook '{request.Workbook}' is not open."); + var worksheet = FindWorksheet(workbook, request.Worksheet) + ?? throw new ArgumentException($"Worksheet '{request.Worksheet}' does not exist in '{workbook.Name}'."); + Excel.Range cell = worksheet.Range[request.Cell]; + + if (cell.CountLarge != 1) + { + throw new ArgumentException("The target must be a single cell."); + } + + if (!request.Overwrite && (cell.HasFormula || cell.Value2 is not null)) + { + throw new InvalidOperationException($"{workbook.Name}/{worksheet.Name}!{cell.Address} is not empty. Set overwrite to true to replace it."); + } + + var ranges = request.Ranges.Select(address => + { + Excel.Range range = worksheet.Range[address]; + return range.Address[RowAbsolute: false, ColumnAbsolute: false, ReferenceStyle: Excel.XlReferenceStyle.xlA1]; + }).ToArray(); + + var formula = BuildFormula(request.Prompt, ranges, request.ProviderAndModel); + var address = cell.Address[RowAbsolute: false, ColumnAbsolute: false, ReferenceStyle: Excel.XlReferenceStyle.xlA1]; + + pending = new PendingPrompt(workbook.Name, worksheet.Name, address, formula); + Add(pending); + + try + { + SetFormula(cell, formula); + TryComplete(pending); + } + catch + { + Remove(pending); + throw; + } + }).ConfigureAwait(false); + + return await pending!.Completion.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + if (pending is not null) + { + Remove(pending); + } + + throw; + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or COMException) + { + if (pending is not null) + { + Remove(pending); + } + + return Error(request, ex.Message); + } + } + + internal static string BuildFormula(string prompt, IReadOnlyList ranges, string? providerAndModel) + { + var arguments = new List(); + var function = "PROMPT"; + + if (!string.IsNullOrWhiteSpace(providerAndModel)) + { + function = "PROMPTMODEL"; + arguments.Add(Quote(providerAndModel)); + } + + arguments.Add(Quote(prompt)); + arguments.AddRange(ranges); + + return $"={function}({string.Join(", ", arguments)})"; + } + + private void OnCalculationEnded() + { + foreach (var pending in GetPending()) + { + TryComplete(pending); + } + } + + private void TryComplete(PendingPrompt pending) + { + try + { + var application = _application; + var workbook = application is null ? null : FindWorkbook(application, pending.Workbook); + var worksheet = workbook is null ? null : FindWorksheet(workbook, pending.Worksheet); + + if (worksheet is null) + { + Complete(pending, Response(pending, "workbook_closed", error: "The workbook or worksheet was closed.")); + return; + } + + Excel.Range cell = worksheet.Range[pending.Cell]; + var formula = GetFormula(cell); + + if (!string.Equals(formula, pending.Formula, StringComparison.OrdinalIgnoreCase)) + { + Complete(pending, Response(pending, string.IsNullOrEmpty(formula) ? "cancelled" : "replaced")); + return; + } + + var value = cell.Value2; + var displayedValue = Convert.ToString(cell.Text); + + if (value is null || IsPending(value)) + { + return; + } + + if (string.Equals(Convert.ToString(value), "Cancelled", StringComparison.OrdinalIgnoreCase)) + { + Complete(pending, Response(pending, "cancelled", formula)); + return; + } + + if (value is int) + { + Complete(pending, Response(pending, "error", formula, error: displayedValue ?? Convert.ToString(value))); + return; + } + + Complete(pending, Response(pending, "completed", formula, Normalize(value))); + } + catch (COMException ex) + { + logger.LogDebug(ex, "Unable to read {Workbook}/{Worksheet}!{Cell} after calculation", pending.Workbook, pending.Worksheet, pending.Cell); + } + } + + private void OnCalculationCanceled() + { + foreach (var pending in GetPending()) + { + Complete(pending, Response(pending, "cancelled")); + } + } + + private void OnSheetChange(object sheet, Excel.Range target) + { + if (sheet is not Excel.Worksheet worksheet) + { + return; + } + + foreach (var pending in GetPending() + .Where(item => string.Equals(item.Workbook, ((Excel.Workbook)worksheet.Parent).Name, StringComparison.OrdinalIgnoreCase) + && string.Equals(item.Worksheet, worksheet.Name, StringComparison.OrdinalIgnoreCase))) + { + Excel.Range cell = worksheet.Range[pending.Cell]; + var intersection = _application?.Intersect(cell, target); + + if (intersection is null) + { + continue; + } + + var formula = GetFormula(cell); + if (!string.Equals(formula, pending.Formula, StringComparison.OrdinalIgnoreCase)) + { + Complete(pending, Response(pending, string.IsNullOrEmpty(formula) ? "cancelled" : "replaced")); + } + } + } + + private void OnWorkbookBeforeClose(Excel.Workbook workbook, ref bool cancel) + { + foreach (var pending in GetPending() + .Where(item => string.Equals(item.Workbook, workbook.Name, StringComparison.OrdinalIgnoreCase))) + { + Complete(pending, Response(pending, "workbook_closed", error: "The workbook was closed before the prompt completed.")); + } + } + + public void Dispose() + { + if (!_started) + { + return; + } + + ExcelAsyncUtil.CalculationEnded -= OnCalculationEnded; + ExcelAsyncUtil.CalculationCanceled -= OnCalculationCanceled; + + if (_application is not null) + { + _application.SheetChange -= OnSheetChange; + _application.WorkbookBeforeClose -= OnWorkbookBeforeClose; + } + + foreach (var pending in GetPending()) + { + Complete(pending, Response(pending, "add_in_closed", error: "Cellm was closed before the prompt completed.")); + } + + _started = false; + } + + private void Add(PendingPrompt pending) + { + lock (_pending) + { + if (!_pending.TryAdd(pending.Key, pending)) + { + throw new InvalidOperationException($"A prompt is already running in {pending.Workbook}/{pending.Worksheet}!{pending.Cell}."); + } + } + } + + private void Remove(PendingPrompt pending) + { + lock (_pending) + { + _pending.Remove(pending.Key); + } + } + + private PendingPrompt[] GetPending() + { + lock (_pending) + { + return _pending.Values.ToArray(); + } + } + + private void Complete(PendingPrompt pending, PromptResponse response) + { + Remove(pending); + pending.Completion.TrySetResult(response); + } + + private static PromptResponse Response(PendingPrompt pending, string status, string? formula = null, object? value = null, string? error = null) + { + return new PromptResponse(1, status, pending.Workbook, pending.Worksheet, pending.Cell, formula, value, error); + } + + private static PromptResponse Error(PromptRequest request, string error) + { + return new PromptResponse(1, "error", request.Workbook, request.Worksheet, request.Cell, Error: error); + } + + private static string Quote(string value) => $"\"{value.Replace("\"", "\"\"")}\""; + + private static bool IsPending(object value) => value is int error && error == _excelErrorGettingData; + + private static object? Normalize(object? value) => value switch + { + null or string or double or bool => value, + float or decimal or int or long => Convert.ToDouble(value), + _ => Convert.ToString(value) + }; + + private static Excel.Workbook? FindWorkbook(Excel.Application application, string name) + { + return application.Workbooks + .Cast() + .FirstOrDefault(workbook => string.Equals(workbook.Name, name, StringComparison.OrdinalIgnoreCase)); + } + + private static Excel.Worksheet? FindWorksheet(Excel.Workbook workbook, string name) + { + return workbook.Worksheets + .Cast() + .FirstOrDefault(worksheet => string.Equals(worksheet.Name, name, StringComparison.OrdinalIgnoreCase)); + } + + private static void SetFormula(Excel.Range cell, string formula) + { + try + { + ((dynamic)cell).Formula2 = formula; + } + catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException) + { + cell.Formula = formula; + } + } + + private static string GetFormula(Excel.Range cell) + { + try + { + return Convert.ToString(((dynamic)cell).Formula2) ?? string.Empty; + } + catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException) + { + return Convert.ToString(cell.Formula) ?? string.Empty; + } + } + + private sealed class PendingPrompt(string workbook, string worksheet, string cell, string formula) + { + public string Workbook { get; } = workbook; + public string Worksheet { get; } = worksheet; + public string Cell { get; } = cell; + public string Formula { get; } = formula; + public string Key { get; } = $"{workbook}\n{worksheet}\n{cell}"; + public TaskCompletionSource Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + } +} diff --git a/src/Cellm/AddIn/Control/PromptResponse.cs b/src/Cellm/AddIn/Control/PromptResponse.cs new file mode 100644 index 0000000..b1e76a4 --- /dev/null +++ b/src/Cellm/AddIn/Control/PromptResponse.cs @@ -0,0 +1,11 @@ +namespace Cellm.AddIn.Control; + +internal record PromptResponse( + int Version, + string Status, + string Workbook, + string Worksheet, + string Cell, + string? Formula = null, + object? Value = null, + string? Error = null);