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