From 6cbdc9ca0706701c3b1a5c0638af3e0cca440fd7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 05:21:30 +0000 Subject: [PATCH 1/3] docs(architecture): add MCP server design doc Design draft for an opt-in MCP server surface that exposes SmartHopper's existing AITool catalog to external MCP clients (Claude Desktop, Cursor, VS Code, Claude Code) over local HTTP/JSON-RPC, without re-implementing GhJSON marshalling (kept in architects-toolkit/ghjson-dotnet). Adapted from brookstalley/cordyceps (MIT) as architectural reference; no Cordyceps code is imported in this PR. No code changes. --- CHANGELOG.md | 1 + docs/Architecture/mcp-server.md | 266 ++++++++++++++++++++++++++++++++ docs/index.md | 1 + 3 files changed, 268 insertions(+) create mode 100644 docs/Architecture/mcp-server.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c2b4433b..2ea65a4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **DEV.md provider model sync automation**: added `tools/Update-DevProviderModels.ps1` and a GitHub workflow that validates provider model documentation on PRs and opens sync PRs after protected-branch provider registry updates. - **README Trademark and Logo Usage Policy**: explicit policy clarifying that the SmartHopper name and logo are not licensed under LGPL, listing permitted uses (articles, tutorials, educational materials, references to the unmodified official plug-in) and uses requiring prior written permission (commercial bundling, forks, materials that may imply endorsement). +- **MCP server architecture design doc** (`docs/Architecture/mcp-server.md`): opt-in design proposal for exposing SmartHopper's existing `IAIToolProvider` tools to external Model Context Protocol clients (Claude Desktop, Cursor, VS Code, Claude Code, etc.) over local HTTP/JSON-RPC. Documents project layout (`SmartHopper.Mcp`), MCP method mapping onto `AIToolManager`, `SemaphoreSlim`-protected UI marshalling, loopback-only/bearer-token security model, mutating-tools-off-by-default policy, phased rollout, and open decision points. Reuses `architects-toolkit/ghjson-dotnet` as the sole source of GhJSON schema; no schema re-implementation. Adapted from `brookstalley/cordyceps` (MIT) as architectural reference, with attribution. #### ๐Ÿ“‹ List I/O components diff --git a/docs/Architecture/mcp-server.md b/docs/Architecture/mcp-server.md new file mode 100644 index 00000000..b568e2c9 --- /dev/null +++ b/docs/Architecture/mcp-server.md @@ -0,0 +1,266 @@ +# MCP server (design) + +> Status: **design draft**, not yet implemented. This document proposes how SmartHopper would expose its existing AI tools to external MCP clients (Claude Desktop, Cursor, VS Code, Claude Code, etc.). It is informed by `brookstalley/cordyceps` (MIT) but adapted to SmartHopper's existing architecture and GhJSON-first principle. + +## 1. Goal and scope + +Add an opt-in **Model Context Protocol** (MCP) surface to SmartHopper so that external AI agents can discover and invoke SmartHopper's existing AI tools over a local HTTP transport, **without** any duplication of the GhJSON schema layer that lives in `architects-toolkit/ghjson-dotnet`. + +In scope: + +- A new transport project that speaks MCP (JSON-RPC 2.0 over HTTP / Streamable HTTP) and is hosted in-process inside Grasshopper. +- An adapter that exposes existing `AITool` instances (registered via `IAIToolProvider` and `AIToolManager`) as MCP tools. +- A user-placed Grasshopper component (`SmartHopperMcpServerComponent`) that starts/stops the server, binds to `127.0.0.1` by default, and surfaces server status. +- Embedded knowledge resources (`gh://docs/*`) reusing existing `/docs/Tools/*` Markdown. + +Explicitly **out of scope**: + +- Re-implementing GhJSON serialization, validation, `Put`, or migration logic. All schema work continues to live in `ghjson-dotnet` and is reached only through existing `AITool` execution paths. +- Remote network exposure (non-loopback), authentication providers beyond a static bearer token, or multi-user session handling. Those are explicitly deferred. +- Replacing the existing in-process tool-calling loop (`AIInteraction*` + `AIToolCall.Exec()`); MCP is an additional client surface, not a replacement. + +## 2. Why MCP, why now + +- SmartHopper already owns a rich `IAIToolProvider` surface (canvas, script, document, provider/model orchestration). Today those tools are only reachable from SmartHopper's in-component chat UI. MCP unlocks the same tools for any MCP-aware editor / agent. +- The space already has a reference implementation (Cordyceps, MIT-licensed). Its transport choice (HTTP + JSON-RPC + reflection-discovered tools) is a sane baseline. +- SmartHopper provides additional value Cordyceps does not (provider abstraction, GhJSON-first orchestration, fan-out / batching). Exposing those tools via MCP brings unique value to non-SmartHopper agents. + +## 3. Architecture overview + +``` + +-------------------------------------------+ + | External MCP client | + | (Claude Desktop, Cursor, VS Code, โ€ฆ) | + +----------------------+--------------------+ + | HTTP / JSON-RPC 2.0 + v ++----------------------------------------------------------------------------+ +| SmartHopper.Mcp (new project, opt-in, .NET 7 net7.0/net7.0-windows) | +| | +| +-------------------+ +------------------+ +----------------------+ | +| | McpHttpListener |-->| JsonRpcDispatcher|-->| AIToolMcpAdapter | | +| | (HttpListener + | | (initialize, | | (maps MCP tool calls | | +| | loopback bind, | | tools/list, | | to AIToolCall via | | +| | CORS, SSE) | | tools/call, | | AIToolManager) | | +| +-------------------+ | resources/list, | +----------+-----------+ | +| | resources/read) | | | +| +---------+--------+ | | +| | | | +| v v | +| +------------------+ +----------------------+ | +| | ResourceRegistry | | RhinoUiMarshaller | | +| | (gh://docs/*) | | (SemaphoreSlim + | | +| +------------------+ | GH_DocumentEditor) | | +| +----------+-----------+ | ++------------------------------------------------------------|---------------+ + v ++----------------------------------------------------------------------------+ +| Existing SmartHopper layers (unchanged) | +| | +| SmartHopper.Infrastructure | +| - AIToolManager (static registry, ExecuteTool(AIToolCall)) | +| - IAIToolProvider, AITool, AIToolCall, AIReturn | +| | +| SmartHopper.Core.Grasshopper.AITools | +| - gh_put / gh_get / gh_move / gh_group / ... | +| - Uses GhJson.* from ghjson-dotnet for all schema work | +| | +| architects-toolkit/ghjson-dotnet (NuGet) | +| - Schema, Put, Validate, Fix, Sugiyama layout, migration | ++----------------------------------------------------------------------------+ +``` + +### Key invariants + +1. **No GhJSON re-implementation.** Every MCP `tools/call` ultimately invokes an existing `AITool` via `AIToolManager.ExecuteTool(AIToolCall)`. That tool โ€” and only that tool โ€” talks to `ghjson-dotnet`. The MCP layer treats `AIReturn.Body` as opaque JSON. +2. **Single UI thread.** All canvas / document mutations are serialized through `RhinoUiMarshaller.ExecuteOnUiThreadAsync(Func>)`, which is a `SemaphoreSlim`-protected wrapper around the existing UI-marshalling helpers in `SmartHopper.Core.Grasshopper.Utils.Canvas`. +3. **Opt-in.** The MCP server only runs when a `SmartHopperMcpServerComponent` is present on at least one open Grasshopper document. Removing the last instance stops the server. +4. **Loopback-only by default.** `HttpListener` is bound to `http://127.0.0.1:/`. A future change can add a settings flag for LAN exposure, gated behind a bearer-token requirement. + +## 4. Project layout + +A new project `SmartHopper.Mcp` is added under `src/`: + +``` +src/SmartHopper.Mcp/ +โ”œโ”€โ”€ SmartHopper.Mcp.csproj # net7.0;net7.0-windows +โ”œโ”€โ”€ McpServer.cs # HttpListener lifecycle, CORS, origin guard +โ”œโ”€โ”€ Transport/ +โ”‚ โ”œโ”€โ”€ JsonRpcDispatcher.cs # initialize / tools/list / tools/call / resources/* +โ”‚ โ”œโ”€โ”€ JsonRpcRequest.cs / Response.cs +โ”‚ โ””โ”€โ”€ SseSession.cs # optional Streamable HTTP / SSE channel +โ”œโ”€โ”€ Tools/ +โ”‚ โ”œโ”€โ”€ AIToolMcpAdapter.cs # AITool <-> MCP tool descriptor +โ”‚ โ””โ”€โ”€ McpToolDescriptor.cs # name, description, inputSchema (from AITool.ParametersSchema) +โ”œโ”€โ”€ Resources/ +โ”‚ โ”œโ”€โ”€ ResourceRegistry.cs # gh://docs/* -> embedded /docs/Tools/*.md +โ”‚ โ””โ”€โ”€ EmbeddedKnowledge/ # copied at build time from /docs/Tools, /docs/Architecture +โ”œโ”€โ”€ Hosting/ +โ”‚ โ”œโ”€โ”€ RhinoUiMarshaller.cs # SemaphoreSlim mutex around GH UI thread +โ”‚ โ””โ”€โ”€ McpServerLifecycle.cs # ref-counted singleton, document-scoped +โ””โ”€โ”€ Settings/ + โ””โ”€โ”€ McpSettings.cs # port, bearer token, enabled tools allow-list +``` + +A new component lives in `SmartHopper.Components`: + +``` +src/SmartHopper.Components/Mcp/ +โ””โ”€โ”€ SmartHopperMcpServerComponent.cs # user-placed component; Inputs: Enable, Port. Outputs: Url, Status, LastCall +``` + +### Dependencies + +| Reference | From | To | Reason | +|------------------------------------------------------|----------------------------|-----------------------------|--------------------------------------------------------------| +| `SmartHopper.Mcp` โ†’ `SmartHopper.Infrastructure` | new | existing | Reach `AIToolManager`, `IAIToolProvider`, `AIToolCall`. | +| `SmartHopper.Mcp` โ†’ `SmartHopper.Core.Grasshopper` | new | existing | Reach UI marshalling helpers. | +| `SmartHopper.Components` โ†’ `SmartHopper.Mcp` | existing | new | Hosts the user-placed component. | +| `SmartHopper.Mcp` โ†› `ghjson-dotnet` | **forbidden** | | Schema work stays inside existing `AITool` implementations. | + +`SmartHopper.Mcp` only depends on `Newtonsoft.Json` (already in the solution) plus `System.Net.Http` and `System.Net.HttpListener` from the BCL. No additional NuGet dependencies are introduced in phase 1. + +## 5. MCP method mapping + +| MCP method | Maps to | +|-----------------------|------------------------------------------------------------------------------------------| +| `initialize` | Returns server name (`smarthopper`), version, capabilities (`tools`, `resources`). | +| `tools/list` | `AIToolMcpAdapter.BuildDescriptors()` โ€” wraps each `AITool` from `AIToolManager.GetTools()`. | +| `tools/call` | Builds `AIToolCall(name, arguments)` and calls `AIToolCall.Exec()`. Returns `AIReturn.Body` verbatim. | +| `resources/list` | `ResourceRegistry.List()` โ€” enumerates embedded `/docs/Tools/*.md` and `/docs/Architecture/*.md`. | +| `resources/read` | Returns the embedded Markdown body for the matching `gh://docs/` URI. | +| `prompts/list` / `prompts/get` | **Deferred** to phase 2 (see ยง9). | +| `logging/setLevel` | Sets a per-session `DebugLog` level forwarded to `Debug.WriteLine` (no new sink). | +| `notifications/*` | One-way notifications (tool list updates) when components are added/removed. | + +### `tools/call` payload contract + +1. MCP client sends: `{ "method": "tools/call", "params": { "name": "gh_put", "arguments": { ... } } }`. +2. `JsonRpcDispatcher` resolves the `AITool` by name. If unknown โ†’ JSON-RPC error `-32601`. +3. The `arguments` object is forwarded as `AIToolCall.Arguments` (it is already JSON; no Grasshopper-specific shaping happens here). +4. `RhinoUiMarshaller.ExecuteOnUiThreadAsync(() => AIToolCall.Exec())` runs the tool serialized against the UI thread. +5. `AIReturn.Body` is returned to the client as the JSON-RPC `result.content[0].text` payload (the MCP "text content" envelope). `AIReturn.ErrorMessage`, if set, becomes a JSON-RPC error. + +This is the entire bridge. There is no GhJSON marshalling here; the existing `AITool.Execute` delegates handle that themselves. + +## 6. Thread-safety and UI marshalling + +```csharp +// SmartHopper.Mcp/Hosting/RhinoUiMarshaller.cs (sketch) +internal sealed class RhinoUiMarshaller +{ + private static readonly SemaphoreSlim _gate = new(1, 1); + + public static async Task ExecuteOnUiThreadAsync(Func> body) + { + await _gate.WaitAsync().ConfigureAwait(false); + try + { + return await CanvasAccess.RunOnUiThreadAsync(body).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } +} +``` + +- `CanvasAccess.RunOnUiThreadAsync` already exists in `SmartHopper.Core.Grasshopper`. The marshaller adds a single global mutex so that concurrent HTTP requests cannot race on the document. +- This mirrors Cordyceps' `GrasshopperContext.ExecuteOnUiThread` pattern but reuses SmartHopper's existing helpers. +- Long-running tool calls do **not** block the HttpListener thread โ€” `HttpListener` callbacks are `async`, the marshaller is awaited, and other requests queue on `_gate`. This intentionally serializes canvas mutations, which matches the GH document's single-threaded model. + +## 7. Component and lifecycle + +``` +SmartHopperMcpServerComponent (sealed, no GhJSON output) +โ”œโ”€โ”€ Inputs: +โ”‚ - Enable (bool, default false) โ€” toggles the server. +โ”‚ - Port (int, default 26929) โ€” only consulted on Enable rising edge. +โ”‚ - BearerToken (string, optional) โ€” empty disables auth (loopback only). +โ”œโ”€โ”€ Outputs: +โ”‚ - Url (string) โ€” "http://127.0.0.1:/mcp" once running. +โ”‚ - Status (string) โ€” "Stopped" | "Starting" | "Running on โ€ฆ" | "Error: โ€ฆ". +โ”‚ - LastCall (string) โ€” name of the most recent successful tool call. +โ””โ”€โ”€ Behavior: + - Solve-time toggle via McpServerLifecycle.AcquireOrRelease(this, Enable). + - Lifecycle is ref-counted: multiple components on multiple documents + share one server instance per (port, token) tuple. + - Removing or disabling the last referencing component stops the server. + - Lifecycle is owned by SmartHopper.Mcp, not Grasshopper, so document + close / Rhino shutdown also triggers Stop via GH_RhinoScriptInterface. +``` + +Settings persisted via the existing `SmartHopper.Infrastructure.Settings` surface (so the bearer token never lives in the GHX file). + +## 8. Security model (phase 1) + +- **Bind address.** `HttpListener` prefix is hard-coded to `http://127.0.0.1:/` and `http://[::1]:/`. No LAN exposure in phase 1. +- **Origin guard.** `Origin` header is checked against an allow-list (`http://127.0.0.1*`, `http://localhost*`, `vscode-webview://*`, `claude://*`). Other origins receive `403`. +- **Authentication.** Optional static bearer token (`Authorization: Bearer โ€ฆ`). When the user sets `BearerToken`, requests without it are rejected with `401`. +- **Allow-list of tools.** `McpSettings.EnabledTools` may pin the subset of `AIToolManager` tools to expose. Defaults to "all read-only tools enabled, all mutating tools disabled" โ€” the user must opt mutating tools (`gh_put`, `gh_move`, `gh_group`, `script_edit`, โ€ฆ) in. +- **No file-system / shell access from MCP.** Phase 1 only exposes tools that already exist in `IAIToolProvider`. Adding new tools requires going through `IAIToolProvider` and its review. +- **Logging.** Every accepted request gets a structured `Debug.WriteLine` entry (`[Mcp] tool= sessionId= ok=`). No payload bodies are logged to avoid leaking GhJSON containing sensitive geometry. + +### Threat surface summary + +| Threat | Mitigation | +|-----------------------------------------------|-----------------------------------------------------------------------------| +| Drive-by web origin invoking tools | Origin guard + bearer token + loopback-only bind. | +| Cross-document race / corrupted GH state | `SemaphoreSlim`-protected UI marshalling. | +| Unbounded resource use from a single client | JSON-RPC `params` size cap (256 KiB), per-session concurrency = 1. | +| Tool exposure beyond intended set | `McpSettings.EnabledTools` allow-list; mutating tools off by default. | +| Token leakage via GHX | Bearer token stored in `SmartHopper.Infrastructure.Settings`, not the GHX. | + +## 9. Phased rollout + +| Phase | Deliverable | Scope | +|-------|---------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------| +| 0 | **This design doc.** | No code. | +| 1 | `SmartHopper.Mcp` project, `SmartHopperMcpServerComponent`, `tools/list`, `tools/call`, read-only tools enabled by default. | Wires existing read-only `AITool`s. Mutating tools off until user opts in. | +| 2 | `resources/list`, `resources/read`. Embedded `/docs/Tools/*` and `/docs/Architecture/*` as `gh://docs/*` URIs. | Reuses existing Markdown; no new content authored. | +| 3 | `prompts/list`, `prompts/get`. Workflow templates for "parametric geometry", "debug GhJSON", "switch provider". | Templates author-controlled in `/docs/Prompts/*`. | +| 4 | LAN exposure flag + mandatory bearer token, structured audit log, per-session rate limit. | Opt-in only. | +| 5 | Optional Streamable HTTP / SSE for long-running streaming tool calls (e.g. provider streaming). | Only if user demand exists; phase 1 returns full results synchronously. | + +Each phase is intended to land as a separate PR. + +## 10. Relationship to `ghjson-dotnet` and Cordyceps + +- `ghjson-dotnet` remains the single source of truth for GhJSON. The MCP server never imports `GhJSON.Core` / `GhJSON.Grasshopper`. +- Cordyceps is the architectural reference. The following Cordyceps pieces are **structurally** reused, with attribution: + - HTTP / JSON-RPC dispatch shape (`McpServer.cs`). + - Reflection-based tool discovery via `[McpServerTool]` attributes โ€” adapted to SmartHopper's `AITool` model so no reflection on user code is required. + - Thread-safe UI marshalling via `SemaphoreSlim`. + - Embedded Markdown resource serving (`gh://docs/*`). +- Cordyceps' tool implementations are **not** ported. SmartHopper already has equivalent tools and reuses them through `AIToolManager`. +- Component-name aliasing (Cordyceps' `Core/ComponentRegistry.cs`) is already addressed by [`ComponentNameAliases`](../../src/SmartHopper.Core.Grasshopper/Utils/Canvas/ComponentNameAliases.cs) in the orchestration layer (`feature/2.0.0-text2json`). + +Attribution will be added to `THIRD_PARTY_NOTICES.md` and per-file headers when phase 1 code lands. + +## 11. Open questions + +1. **Tool name namespacing.** SmartHopper tools are `gh_put`, `script_edit`, etc. โ€” same shape as Cordyceps. Do we prefix (`smarthopper.gh_put`) to avoid collisions when both servers run in the same Rhino session? Current proposal: **no prefix in phase 1**, but reserve the right to add one if collisions become a real complaint. +2. **Tool schema source.** `AITool.ParametersSchema` is already a JSON Schema string. MCP wants a JSON object. Phase 1 will `JObject.Parse` it. If any tool ships a non-object schema, validation will fail loudly โ€” that's a tool-side bug worth surfacing. +3. **GhJSON returned to MCP.** Should tools that already return GhJSON (e.g. `gh_get`) tag their response with a `mimeType` so MCP clients can render it? Proposed `mimeType: application/vnd.ghjson+json`. Decision deferred until phase 2. +4. **Settings UI.** Phase 1 surfaces port / token via component inputs. A central Settings dialog entry is deferred. +5. **CI coverage.** Phase 1 will add `SmartHopper.Mcp.Tests` (xUnit, no Rhino refs) covering `JsonRpcDispatcher` and `AIToolMcpAdapter`. The HttpListener path stays untested in CI; integration testing happens via a manual Claude Desktop session. + +## 12. Decision points the maintainer must confirm before phase 1 + +- [ ] Approve project name `SmartHopper.Mcp` (alternative: `SmartHopper.Mcp.Server`). +- [ ] Approve default port `26929` (Cordyceps default) vs. a SmartHopper-specific port. +- [ ] Approve the "mutating tools off by default" policy. +- [ ] Approve placement of the user-facing component in `SmartHopper.Components/Mcp/`. +- [ ] Confirm `THIRD_PARTY_NOTICES.md` is the right surface for Cordyceps architectural attribution (currently it only documents the alias map; we would extend it). + +## 13. References + +- Cordyceps source: (MIT, copyright ยฉ 2026 Brooks Talley). +- Model Context Protocol spec: . +- GhJSON spec: . +- `ghjson-dotnet` (.NET implementation, NuGet `GhJSON.Core` / `GhJSON.Grasshopper`): . +- Local prior art: + - [`/docs/Architecture.md`](../Architecture.md) โ€” overall SmartHopper architecture. + - [`/docs/Tools/index.md`](../Tools/index.md) โ€” tool catalogue exposed via `IAIToolProvider`. + - [`SmartHopper.Core.Grasshopper.Utils.Canvas.ComponentNameAliases`](../../src/SmartHopper.Core.Grasshopper/Utils/Canvas/ComponentNameAliases.cs) โ€” orchestration-layer alias resolver landed alongside this doc. diff --git a/docs/index.md b/docs/index.md index 9cf7acbc..cf92745f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,6 +3,7 @@ This index lists the available documentation for SmartHopper. It will be updated as new docs are added. - [Architecture overview](Architecture.md) +- [Architecture deep dives](Architecture/) โ€” focused design docs (e.g. [MCP server](Architecture/mcp-server.md)) ## Main parts From 44a6cf6509ddd1c70ea33d46685a0ec7cf253ec3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 10:30:51 +0000 Subject: [PATCH 2/3] feat(mcp): implement phase 1 HTTP/JSON-RPC server Land phase 1 of the MCP server design (docs/Architecture/mcp-server.md) so external MCP clients (Claude Desktop, Cursor, VS Code, Claude Code) can discover and call SmartHopper's existing AI tools over a local loopback HTTP/JSON-RPC server. No SSE, no embedded resources, no LAN exposure; those remain in phases 2-5. SmartHopper.Infrastructure/Mcp/ - McpServer: HttpListener bound to 127.0.0.1 + [::1], origin guard, optional bearer token, 256 KB request cap, no payload logging. - JsonRpcDispatcher: initialize, tools/list, tools/call, notifications/initialized, ping. Method-not-found stubs for resources/* and prompts/*. SemaphoreSlim gate serializes concurrent requests. - AIToolMcpAdapter: bridges AIToolManager to MCP tool descriptors. Allow-list (McpServerOptions.EnabledTools); mutating-tools-off filter by name prefix (gh_put, gh_move, script_*, ...). Executes via AIToolCall.Exec(). - McpServerLifecycle: ref-counted singleton per port to support multiple components on the same port without tearing down the server while still in use. - McpServerOptions, McpToolDescriptor, McpToolCallResult: config / result types. SmartHopper.Components/Mcp/SmartHopperMcpServerComponent Opt-in GH component. Inputs: Enable (bool), Port (int, default 26929), BearerToken (string, optional), ExposeMutatingTools (bool, default false). Outputs: Url, Status. SmartHopper.Infrastructure.Tests/Mcp/ xUnit coverage (no Rhino refs): - AIToolMcpAdapterTests: descriptor filtering, schema parsing, executor wiring, error propagation. - JsonRpcDispatcherTests: initialize / tools/list / tools/call / unknown method / invalid JSON / notification / missing tool name. - McpServerOptionsTests: defaults and Clone semantics. GhJSON-first: the MCP adapter never imports ghjson-dotnet. AITool implementations already own their GhJSON marshalling via architects-toolkit/ghjson-dotnet; MCP forwards AIReturn.Body verbatim. Cordyceps (MIT) attribution is recorded in per-file headers under SmartHopper.Infrastructure/Mcp/. THIRD_PARTY_NOTICES.md is extended via PR #474 and will pick up this attribution when both PRs land in feature/2.0.0-text2json. Phase 1 design-doc status flipped from "design draft" to "phase 1 implemented" in docs/Architecture/mcp-server.md. --- CHANGELOG.md | 6 +- docs/Architecture/mcp-server.md | 71 ++-- .../Mcp/SmartHopperMcpServerComponent.cs | 189 +++++++++++ .../Mcp/AIToolMcpAdapterTests.cs | 236 +++++++++++++ .../Mcp/JsonRpcDispatcherTests.cs | 186 +++++++++++ .../Mcp/McpServerOptionsTests.cs | 64 ++++ .../Mcp/AIToolMcpAdapter.cs | 248 ++++++++++++++ .../Mcp/JsonRpcDispatcher.cs | 276 ++++++++++++++++ .../Mcp/McpServer.cs | 312 ++++++++++++++++++ .../Mcp/McpServerLifecycle.cs | 134 ++++++++ .../Mcp/McpServerOptions.cs | 105 ++++++ .../Mcp/McpToolCallResult.cs | 63 ++++ .../Mcp/McpToolDescriptor.cs | 63 ++++ 13 files changed, 1912 insertions(+), 41 deletions(-) create mode 100644 src/SmartHopper.Components/Mcp/SmartHopperMcpServerComponent.cs create mode 100644 src/SmartHopper.Infrastructure.Tests/Mcp/AIToolMcpAdapterTests.cs create mode 100644 src/SmartHopper.Infrastructure.Tests/Mcp/JsonRpcDispatcherTests.cs create mode 100644 src/SmartHopper.Infrastructure.Tests/Mcp/McpServerOptionsTests.cs create mode 100644 src/SmartHopper.Infrastructure/Mcp/AIToolMcpAdapter.cs create mode 100644 src/SmartHopper.Infrastructure/Mcp/JsonRpcDispatcher.cs create mode 100644 src/SmartHopper.Infrastructure/Mcp/McpServer.cs create mode 100644 src/SmartHopper.Infrastructure/Mcp/McpServerLifecycle.cs create mode 100644 src/SmartHopper.Infrastructure/Mcp/McpServerOptions.cs create mode 100644 src/SmartHopper.Infrastructure/Mcp/McpToolCallResult.cs create mode 100644 src/SmartHopper.Infrastructure/Mcp/McpToolDescriptor.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ea65a4b..7f520443 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +52,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **DEV.md provider model sync automation**: added `tools/Update-DevProviderModels.ps1` and a GitHub workflow that validates provider model documentation on PRs and opens sync PRs after protected-branch provider registry updates. - **README Trademark and Logo Usage Policy**: explicit policy clarifying that the SmartHopper name and logo are not licensed under LGPL, listing permitted uses (articles, tutorials, educational materials, references to the unmodified official plug-in) and uses requiring prior written permission (commercial bundling, forks, materials that may imply endorsement). -- **MCP server architecture design doc** (`docs/Architecture/mcp-server.md`): opt-in design proposal for exposing SmartHopper's existing `IAIToolProvider` tools to external Model Context Protocol clients (Claude Desktop, Cursor, VS Code, Claude Code, etc.) over local HTTP/JSON-RPC. Documents project layout (`SmartHopper.Mcp`), MCP method mapping onto `AIToolManager`, `SemaphoreSlim`-protected UI marshalling, loopback-only/bearer-token security model, mutating-tools-off-by-default policy, phased rollout, and open decision points. Reuses `architects-toolkit/ghjson-dotnet` as the sole source of GhJSON schema; no schema re-implementation. Adapted from `brookstalley/cordyceps` (MIT) as architectural reference, with attribution. +- **MCP server architecture design doc** (`docs/Architecture/mcp-server.md`): opt-in design proposal for exposing SmartHopper's existing `IAIToolProvider` tools to external Model Context Protocol clients (Claude Desktop, Cursor, VS Code, Claude Code, etc.) over local HTTP/JSON-RPC. Documents method mapping onto `AIToolManager`, `SemaphoreSlim`-protected request serialization, loopback-only/bearer-token security model, mutating-tools-off-by-default policy, phased rollout, and open decision points. Reuses `architects-toolkit/ghjson-dotnet` as the sole source of GhJSON schema; no schema re-implementation. Adapted from `brookstalley/cordyceps` (MIT) as architectural reference, with attribution. +- **MCP server phase 1 implementation** (loopback HTTP/JSON-RPC, opt-in): + - `SmartHopper.Infrastructure/Mcp/`: `McpServer` (HttpListener on `127.0.0.1` / `[::1]`, origin guard, optional bearer token, 256 KB request limit, no payload logging), `JsonRpcDispatcher` (`initialize`, `tools/list`, `tools/call`, `notifications/initialized`, `ping`; method-not-found stubs for `resources/*` and `prompts/*`), `AIToolMcpAdapter` (bridges `AIToolManager` to MCP tool descriptors, mutating-tools-off allow-list, executes via `AIToolCall`), `McpServerLifecycle` (ref-counted singleton per port), `McpServerOptions` / `McpToolDescriptor` / `McpToolCallResult` configuration types. + - `SmartHopper.Components/Mcp/SmartHopperMcpServerComponent`: opt-in Grasshopper component with `Enable`, `Port`, `BearerToken`, `ExposeMutatingTools` inputs and `Url` / `Status` outputs. Disabled by default. + - `SmartHopper.Infrastructure.Tests/Mcp/`: xUnit coverage for adapter (allow-list + mutating filter + schema parsing + executor wiring + error propagation), dispatcher (`initialize` / `tools/list` / `tools/call` / unknown method / invalid JSON / notifications), and options (defaults + `Clone`). #### ๐Ÿ“‹ List I/O components diff --git a/docs/Architecture/mcp-server.md b/docs/Architecture/mcp-server.md index b568e2c9..7eca2e18 100644 --- a/docs/Architecture/mcp-server.md +++ b/docs/Architecture/mcp-server.md @@ -1,6 +1,6 @@ -# MCP server (design) +# MCP server -> Status: **design draft**, not yet implemented. This document proposes how SmartHopper would expose its existing AI tools to external MCP clients (Claude Desktop, Cursor, VS Code, Claude Code, etc.). It is informed by `brookstalley/cordyceps` (MIT) but adapted to SmartHopper's existing architecture and GhJSON-first principle. +> Status: **phase 1 implemented**. SmartHopper exposes its existing AI tools to external MCP clients (Claude Desktop, Cursor, VS Code, Claude Code, etc.) over a local loopback HTTP/JSON-RPC server. This document describes the resulting architecture; phases 2โ€“5 (resources, prompts, LAN exposure, streaming) remain design-only. It is informed by `brookstalley/cordyceps` (MIT) but adapted to SmartHopper's existing architecture and GhJSON-first principle. ## 1. Goal and scope @@ -79,46 +79,37 @@ Explicitly **out of scope**: ## 4. Project layout -A new project `SmartHopper.Mcp` is added under `src/`: +Phase 1 lands the MCP transport inside the existing `SmartHopper.Infrastructure` project instead of carving out a new standalone project. This keeps the dependency graph flat (only `SmartHopper.Components` already references `SmartHopper.Infrastructure`) and lets future phases peel out into a dedicated `SmartHopper.Mcp` assembly if and when a second host or out-of-process surface is introduced. ``` -src/SmartHopper.Mcp/ -โ”œโ”€โ”€ SmartHopper.Mcp.csproj # net7.0;net7.0-windows -โ”œโ”€โ”€ McpServer.cs # HttpListener lifecycle, CORS, origin guard -โ”œโ”€โ”€ Transport/ -โ”‚ โ”œโ”€โ”€ JsonRpcDispatcher.cs # initialize / tools/list / tools/call / resources/* -โ”‚ โ”œโ”€โ”€ JsonRpcRequest.cs / Response.cs -โ”‚ โ””โ”€โ”€ SseSession.cs # optional Streamable HTTP / SSE channel -โ”œโ”€โ”€ Tools/ -โ”‚ โ”œโ”€โ”€ AIToolMcpAdapter.cs # AITool <-> MCP tool descriptor -โ”‚ โ””โ”€โ”€ McpToolDescriptor.cs # name, description, inputSchema (from AITool.ParametersSchema) -โ”œโ”€โ”€ Resources/ -โ”‚ โ”œโ”€โ”€ ResourceRegistry.cs # gh://docs/* -> embedded /docs/Tools/*.md -โ”‚ โ””โ”€โ”€ EmbeddedKnowledge/ # copied at build time from /docs/Tools, /docs/Architecture -โ”œโ”€โ”€ Hosting/ -โ”‚ โ”œโ”€โ”€ RhinoUiMarshaller.cs # SemaphoreSlim mutex around GH UI thread -โ”‚ โ””โ”€โ”€ McpServerLifecycle.cs # ref-counted singleton, document-scoped -โ””โ”€โ”€ Settings/ - โ””โ”€โ”€ McpSettings.cs # port, bearer token, enabled tools allow-list +src/SmartHopper.Infrastructure/Mcp/ +โ”œโ”€โ”€ McpServer.cs # HttpListener (127.0.0.1 + [::1]), origin guard, bearer auth +โ”œโ”€โ”€ JsonRpcDispatcher.cs # initialize / tools/list / tools/call / notifications / ping +โ”œโ”€โ”€ AIToolMcpAdapter.cs # AITool <-> MCP tool descriptor; executes via AIToolCall +โ”œโ”€โ”€ McpToolDescriptor.cs # name, description, inputSchema (from AITool.ParametersSchema) +โ”œโ”€โ”€ McpToolCallResult.cs # success/error wrapper for tools/call +โ”œโ”€โ”€ McpServerLifecycle.cs # ref-counted singleton per port +โ””โ”€โ”€ McpServerOptions.cs # port, bearer token, enabled-tools allow-list, mutating policy ``` -A new component lives in `SmartHopper.Components`: +Future phases (resources, prompts, optional Streamable HTTP) will add `Resources/`, `Prompts/`, and `Transport/` subfolders under the same root, or graduate to a dedicated `SmartHopper.Mcp` project if cross-host reuse becomes a requirement. + +The user-facing component lives in `SmartHopper.Components`: ``` src/SmartHopper.Components/Mcp/ -โ””โ”€โ”€ SmartHopperMcpServerComponent.cs # user-placed component; Inputs: Enable, Port. Outputs: Url, Status, LastCall +โ””โ”€โ”€ SmartHopperMcpServerComponent.cs # opt-in; Inputs: Enable, Port, BearerToken, ExposeMutatingTools. Outputs: Url, Status ``` ### Dependencies -| Reference | From | To | Reason | -|------------------------------------------------------|----------------------------|-----------------------------|--------------------------------------------------------------| -| `SmartHopper.Mcp` โ†’ `SmartHopper.Infrastructure` | new | existing | Reach `AIToolManager`, `IAIToolProvider`, `AIToolCall`. | -| `SmartHopper.Mcp` โ†’ `SmartHopper.Core.Grasshopper` | new | existing | Reach UI marshalling helpers. | -| `SmartHopper.Components` โ†’ `SmartHopper.Mcp` | existing | new | Hosts the user-placed component. | -| `SmartHopper.Mcp` โ†› `ghjson-dotnet` | **forbidden** | | Schema work stays inside existing `AITool` implementations. | +| Reference | From | To | Reason | +|--------------------------------------------------------|----------------------------|-----------------------------|--------------------------------------------------------------| +| `SmartHopper.Infrastructure/Mcp` โ†’ `Infrastructure.AITools` | existing project | existing namespace | Reach `AIToolManager`, `IAIToolProvider`, `AIToolCall`. | +| `SmartHopper.Components/Mcp` โ†’ `SmartHopper.Infrastructure` | existing | existing | Component hosts `McpServerLifecycle`. | +| `SmartHopper.Infrastructure/Mcp` โ†› `ghjson-dotnet` | **forbidden** | | Schema work stays inside existing `AITool` implementations. | -`SmartHopper.Mcp` only depends on `Newtonsoft.Json` (already in the solution) plus `System.Net.Http` and `System.Net.HttpListener` from the BCL. No additional NuGet dependencies are introduced in phase 1. +Phase 1 only depends on `Newtonsoft.Json` (already in the solution) plus `System.Net.Http` and `System.Net.HttpListener` from the BCL. No additional NuGet dependencies are introduced. ## 5. MCP method mapping @@ -139,7 +130,7 @@ src/SmartHopper.Components/Mcp/ 2. `JsonRpcDispatcher` resolves the `AITool` by name. If unknown โ†’ JSON-RPC error `-32601`. 3. The `arguments` object is forwarded as `AIToolCall.Arguments` (it is already JSON; no Grasshopper-specific shaping happens here). 4. `RhinoUiMarshaller.ExecuteOnUiThreadAsync(() => AIToolCall.Exec())` runs the tool serialized against the UI thread. -5. `AIReturn.Body` is returned to the client as the JSON-RPC `result.content[0].text` payload (the MCP "text content" envelope). `AIReturn.ErrorMessage`, if set, becomes a JSON-RPC error. +5. `AIReturn.Body` is returned to the client as the JSON-RPC `result.content[0].text` payload (the MCP "text content" envelope). Structured error messages on the `AIReturn` (origins `Tool`, `Provider`, `Network`) are surfaced as `isError: true` MCP responses; shape/metrics validation messages from the inner pipeline are intentionally suppressed. This is the entire bridge. There is no GhJSON marshalling here; the existing `AITool.Execute` delegates handle that themselves. @@ -216,8 +207,8 @@ Settings persisted via the existing `SmartHopper.Infrastructure.Settings` surfac | Phase | Deliverable | Scope | |-------|---------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------| -| 0 | **This design doc.** | No code. | -| 1 | `SmartHopper.Mcp` project, `SmartHopperMcpServerComponent`, `tools/list`, `tools/call`, read-only tools enabled by default. | Wires existing read-only `AITool`s. Mutating tools off until user opts in. | +| 0 | **This design doc.** | No code. _Done._ | +| 1 | `SmartHopper.Infrastructure/Mcp/*`, `SmartHopperMcpServerComponent`, `tools/list`, `tools/call`, read-only tools by default. | Wires existing read-only `AITool`s. Mutating tools off until user opts in. _Done._ | | 2 | `resources/list`, `resources/read`. Embedded `/docs/Tools/*` and `/docs/Architecture/*` as `gh://docs/*` URIs. | Reuses existing Markdown; no new content authored. | | 3 | `prompts/list`, `prompts/get`. Workflow templates for "parametric geometry", "debug GhJSON", "switch provider". | Templates author-controlled in `/docs/Prompts/*`. | | 4 | LAN exposure flag + mandatory bearer token, structured audit log, per-session rate limit. | Opt-in only. | @@ -236,7 +227,7 @@ Each phase is intended to land as a separate PR. - Cordyceps' tool implementations are **not** ported. SmartHopper already has equivalent tools and reuses them through `AIToolManager`. - Component-name aliasing (Cordyceps' `Core/ComponentRegistry.cs`) is already addressed by [`ComponentNameAliases`](../../src/SmartHopper.Core.Grasshopper/Utils/Canvas/ComponentNameAliases.cs) in the orchestration layer (`feature/2.0.0-text2json`). -Attribution will be added to `THIRD_PARTY_NOTICES.md` and per-file headers when phase 1 code lands. +Attribution is recorded in `THIRD_PARTY_NOTICES.md` and in per-file headers under `src/SmartHopper.Infrastructure/Mcp/`. ## 11. Open questions @@ -246,13 +237,13 @@ Attribution will be added to `THIRD_PARTY_NOTICES.md` and per-file headers when 4. **Settings UI.** Phase 1 surfaces port / token via component inputs. A central Settings dialog entry is deferred. 5. **CI coverage.** Phase 1 will add `SmartHopper.Mcp.Tests` (xUnit, no Rhino refs) covering `JsonRpcDispatcher` and `AIToolMcpAdapter`. The HttpListener path stays untested in CI; integration testing happens via a manual Claude Desktop session. -## 12. Decision points the maintainer must confirm before phase 1 +## 12. Decision points (resolved in phase 1) -- [ ] Approve project name `SmartHopper.Mcp` (alternative: `SmartHopper.Mcp.Server`). -- [ ] Approve default port `26929` (Cordyceps default) vs. a SmartHopper-specific port. -- [ ] Approve the "mutating tools off by default" policy. -- [ ] Approve placement of the user-facing component in `SmartHopper.Components/Mcp/`. -- [ ] Confirm `THIRD_PARTY_NOTICES.md` is the right surface for Cordyceps architectural attribution (currently it only documents the alias map; we would extend it). +- [x] **Project placement.** Phase 1 ships under `SmartHopper.Infrastructure/Mcp/` rather than a standalone `SmartHopper.Mcp` project, to avoid adding a new project until cross-host reuse is needed. Revisit if/when out-of-process hosting (CLI, sidecar) is required. +- [x] **Default port `26929`** (Cordyceps default) โ€” kept to ease documentation parity and reduce confusion for users running both servers. +- [x] **Mutating tools off by default.** `McpServerOptions.ExposeMutatingTools = false`; prefix-based detection (`gh_put`, `gh_move`, `script_*`, โ€ฆ) suppresses mutating tools from `tools/list` and `tools/call`. +- [x] **Component path** `SmartHopper.Components/Mcp/SmartHopperMcpServerComponent.cs`. +- [x] **Attribution surface** is `THIRD_PARTY_NOTICES.md` plus per-file headers under `src/SmartHopper.Infrastructure/Mcp/`. ## 13. References diff --git a/src/SmartHopper.Components/Mcp/SmartHopperMcpServerComponent.cs b/src/SmartHopper.Components/Mcp/SmartHopperMcpServerComponent.cs new file mode 100644 index 00000000..273128f4 --- /dev/null +++ b/src/SmartHopper.Components/Mcp/SmartHopperMcpServerComponent.cs @@ -0,0 +1,189 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +using System; +using System.Diagnostics; +using Grasshopper.Kernel; +using SmartHopper.Infrastructure.Mcp; + +namespace SmartHopper.Components.Mcp +{ + /// + /// Grasshopper component that starts an MCP HTTP server exposing SmartHopper's + /// registered AI tools to external MCP clients (Claude Desktop, Cursor, + /// VS Code, Claude Code, etc.). + /// + /// + /// The server lifecycle is owned by . Multiple + /// instances of this component on the same port share a single server. The + /// server stops automatically when the last component is disabled or removed. + /// See docs/Architecture/mcp-server.md for the full design. + /// + public sealed class SmartHopperMcpServerComponent : GH_Component + { + private int currentPort = McpServerOptions.DefaultPort; + private bool acquired; + private string? lastStatus; + + /// + /// Initializes a new instance of the class. + /// + public SmartHopperMcpServerComponent() + : base( + "SmartHopper MCP Server", + "MCP", + "Exposes SmartHopper's AI tools to external Model Context Protocol clients (Claude Desktop, Cursor, VS Code, Claude Code) over a loopback HTTP server. Mutating tools are disabled by default; enable them explicitly via the input.", + "SmartHopper", + "MCP") + { + } + + /// + public override Guid ComponentGuid => new Guid("a3c4f1d0-7e2b-4c5a-9d1b-7f5e8c0a2b4d"); + + /// + public override GH_Exposure Exposure => GH_Exposure.primary; + + /// + protected override void RegisterInputParams(GH_InputParamManager pManager) + { + pManager.AddBooleanParameter( + "Enable", + "E", + "When true, starts a loopback MCP HTTP server. Set back to false to stop.", + GH_ParamAccess.item, + false); + pManager.AddIntegerParameter( + "Port", + "P", + "Loopback TCP port. Defaults to 26929.", + GH_ParamAccess.item, + McpServerOptions.DefaultPort); + pManager[1].Optional = true; + pManager.AddTextParameter( + "Bearer Token", + "T", + "Optional bearer token. When set, requests without an 'Authorization: Bearer ' header are rejected with HTTP 401.", + GH_ParamAccess.item, + string.Empty); + pManager[2].Optional = true; + pManager.AddBooleanParameter( + "Expose Mutating Tools", + "M", + "When true, tools that mutate the canvas/scripts (gh_put, gh_move, gh_group, script_edit, ...) are exposed. Defaults to false.", + GH_ParamAccess.item, + false); + pManager[3].Optional = true; + } + + /// + protected override void RegisterOutputParams(GH_OutputParamManager pManager) + { + pManager.AddTextParameter("Url", "U", "MCP endpoint URL once the server is running.", GH_ParamAccess.item); + pManager.AddTextParameter("Status", "S", "Server status (Stopped | Running on ... | Error: ...).", GH_ParamAccess.item); + } + + /// + protected override void SolveInstance(IGH_DataAccess DA) + { + bool enable = false; + int port = McpServerOptions.DefaultPort; + string token = string.Empty; + bool exposeMutating = false; + + DA.GetData(0, ref enable); + DA.GetData(1, ref port); + DA.GetData(2, ref token); + DA.GetData(3, ref exposeMutating); + + try + { + this.ApplyToggle(enable, port, token, exposeMutating); + } + catch (Exception ex) + { + this.lastStatus = $"Error: {ex.Message}"; + this.AddRuntimeMessage(GH_RuntimeMessageLevel.Error, this.lastStatus); + Debug.WriteLine($"[Mcp] Component error: {ex.Message}"); + } + + var server = this.acquired ? McpServerLifecycle.Find(this.currentPort) : null; + DA.SetData(0, server != null ? server.Url : string.Empty); + DA.SetData(1, this.lastStatus ?? (server != null ? $"Running on {server.Url}" : "Stopped")); + } + + /// + public override void RemovedFromDocument(GH_Document document) + { + this.ReleaseIfHeld(); + base.RemovedFromDocument(document); + } + + /// + public override void DocumentContextChanged(GH_Document document, GH_DocumentContext context) + { + if (context == GH_DocumentContext.Close || context == GH_DocumentContext.Unloaded) + { + this.ReleaseIfHeld(); + } + + base.DocumentContextChanged(document, context); + } + + private void ApplyToggle(bool enable, int port, string token, bool exposeMutating) + { + if (!enable) + { + this.ReleaseIfHeld(); + this.lastStatus = "Stopped"; + return; + } + + if (this.acquired && this.currentPort == port) + { + this.lastStatus = $"Running on {McpServerLifecycle.Find(port)?.Url}"; + return; + } + + // Port or first-time acquisition: release any previous holder before starting fresh. + this.ReleaseIfHeld(); + + var options = new McpServerOptions + { + Port = port, + BearerToken = string.IsNullOrWhiteSpace(token) ? null : token, + ExposeMutatingTools = exposeMutating, + }; + var server = McpServerLifecycle.Acquire(this, options); + this.acquired = true; + this.currentPort = port; + this.lastStatus = $"Running on {server.Url}"; + } + + private void ReleaseIfHeld() + { + if (!this.acquired) + { + return; + } + + McpServerLifecycle.Release(this, this.currentPort); + this.acquired = false; + } + } +} diff --git a/src/SmartHopper.Infrastructure.Tests/Mcp/AIToolMcpAdapterTests.cs b/src/SmartHopper.Infrastructure.Tests/Mcp/AIToolMcpAdapterTests.cs new file mode 100644 index 00000000..c07a66db --- /dev/null +++ b/src/SmartHopper.Infrastructure.Tests/Mcp/AIToolMcpAdapterTests.cs @@ -0,0 +1,236 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +namespace SmartHopper.Infrastructure.Tests.Mcp +{ + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + using Newtonsoft.Json.Linq; + using SmartHopper.Infrastructure.AICall.Core.Interactions; + using SmartHopper.Infrastructure.AICall.Core.Returns; + using SmartHopper.Infrastructure.AICall.Tools; + using SmartHopper.Infrastructure.AITools; + using SmartHopper.Infrastructure.Mcp; + using Xunit; + + /// + /// Unit tests for . No Rhino/Grasshopper dependencies. + /// + public class AIToolMcpAdapterTests + { + private const string ReadOnlySchema = "{\"type\":\"object\",\"properties\":{\"q\":{\"type\":\"string\"}}}"; + + [Fact] + public void BuildDescriptors_OmitsMutatingToolsByDefault() + { + var tools = BuildCatalog( + ("gh_get", ReadOnlySchema), + ("gh_put", ReadOnlySchema)); + var adapter = new AIToolMcpAdapter(new McpServerOptions(), () => tools, _ => Task.FromResult(new AIReturn())); + + var descriptors = adapter.BuildDescriptors(); + + Assert.Single(descriptors); + Assert.Equal("gh_get", descriptors[0].Name); + } + + [Fact] + public void BuildDescriptors_IncludesMutatingToolsWhenOptedIn() + { + var tools = BuildCatalog( + ("gh_get", ReadOnlySchema), + ("gh_put", ReadOnlySchema)); + var adapter = new AIToolMcpAdapter( + new McpServerOptions { ExposeMutatingTools = true }, + () => tools, + _ => Task.FromResult(new AIReturn())); + + var descriptors = adapter.BuildDescriptors(); + + Assert.Equal(2, descriptors.Count); + Assert.Contains(descriptors, d => d.Name == "gh_get"); + Assert.Contains(descriptors, d => d.Name == "gh_put"); + } + + [Fact] + public void BuildDescriptors_AllowListNarrowsExposedTools() + { + var tools = BuildCatalog( + ("gh_get", ReadOnlySchema), + ("script_review", ReadOnlySchema), + ("script_generate", ReadOnlySchema)); + var adapter = new AIToolMcpAdapter( + new McpServerOptions { EnabledTools = new[] { "gh_get" } }, + () => tools, + _ => Task.FromResult(new AIReturn())); + + var descriptors = adapter.BuildDescriptors(); + + Assert.Single(descriptors); + Assert.Equal("gh_get", descriptors[0].Name); + } + + [Fact] + public void BuildDescriptors_AllowListOverridesMutatingFilter() + { + var tools = BuildCatalog(("gh_put", ReadOnlySchema)); + var adapter = new AIToolMcpAdapter( + new McpServerOptions { EnabledTools = new[] { "gh_put" } }, + () => tools, + _ => Task.FromResult(new AIReturn())); + + var descriptors = adapter.BuildDescriptors(); + + Assert.Single(descriptors); + Assert.Equal("gh_put", descriptors[0].Name); + } + + [Fact] + public void BuildDescriptors_ParsesParametersSchemaIntoJObject() + { + var tools = BuildCatalog(("gh_get", ReadOnlySchema)); + var adapter = new AIToolMcpAdapter(new McpServerOptions(), () => tools, _ => Task.FromResult(new AIReturn())); + + var descriptors = adapter.BuildDescriptors(); + + Assert.Single(descriptors); + Assert.Equal("object", (string?)descriptors[0].InputSchema["type"]); + Assert.Equal("string", (string?)descriptors[0].InputSchema["properties"]?["q"]?["type"]); + } + + [Fact] + public void BuildDescriptors_BadSchemaFallsBackToObject() + { + var tools = BuildCatalog(("gh_get", "not-json")); + var adapter = new AIToolMcpAdapter(new McpServerOptions(), () => tools, _ => Task.FromResult(new AIReturn())); + + var descriptors = adapter.BuildDescriptors(); + + Assert.Equal("object", (string?)descriptors[0].InputSchema["type"]); + } + + [Fact] + public async Task ExecuteAsync_ReturnsErrorForUnknownTool() + { + var adapter = new AIToolMcpAdapter( + new McpServerOptions(), + () => new Dictionary(), + _ => Task.FromResult(new AIReturn())); + + var result = await adapter.ExecuteAsync("missing", new JObject()); + + Assert.True(result.IsError); + Assert.NotNull(result.ErrorMessage); + Assert.Contains("missing", result.ErrorMessage!); + } + + [Fact] + public async Task ExecuteAsync_ReturnsErrorWhenToolHidden() + { + var tools = BuildCatalog(("gh_put", ReadOnlySchema)); + var adapter = new AIToolMcpAdapter( + new McpServerOptions(), + () => tools, + _ => Task.FromResult(new AIReturn())); + + var result = await adapter.ExecuteAsync("gh_put", new JObject()); + + Assert.True(result.IsError); + Assert.NotNull(result.ErrorMessage); + Assert.Contains("not exposed", result.ErrorMessage!); + } + + [Fact] + public async Task ExecuteAsync_PassesArgumentsToExecutorAndReturnsToolResult() + { + var tools = BuildCatalog(("gh_get", ReadOnlySchema)); + + JObject? observedArgs = null; + Task Executor(AIToolCall call) + { + var pending = call.Body.PendingToolCallsList(); + observedArgs = pending.Count > 0 ? pending[0].Arguments : null; + + var ret = new AIReturn + { + Request = call, + SkipRequestValidation = true, + SkipMetricsValidation = true, + }; + var ok = new AIInteractionToolResult + { + Name = call.GetToolCall().Name, + Result = new JObject { ["echoed"] = (JObject?)observedArgs?.DeepClone() ?? new JObject() }, + }; + ret.SetBody(AIBody.Empty.WithAppended(ok)); + return Task.FromResult(ret); + } + + var adapter = new AIToolMcpAdapter(new McpServerOptions(), () => tools, Executor); + + var args = new JObject { ["q"] = "hello" }; + var result = await adapter.ExecuteAsync("gh_get", args); + + Assert.False(result.IsError); + Assert.NotNull(observedArgs); + Assert.Equal("hello", (string?)observedArgs!["q"]); + Assert.Equal("hello", (string?)result.Payload["echoed"]?["q"]); + } + + [Fact] + public async Task ExecuteAsync_ConvertsToolErrorIntoErrorResult() + { + var tools = BuildCatalog(("gh_get", ReadOnlySchema)); + Task Executor(AIToolCall call) + { + var ret = new AIReturn + { + Request = call, + SkipRequestValidation = true, + SkipMetricsValidation = true, + }; + ret.CreateToolError("boom", call); + return Task.FromResult(ret); + } + + var adapter = new AIToolMcpAdapter(new McpServerOptions(), () => tools, Executor); + var result = await adapter.ExecuteAsync("gh_get", new JObject()); + + Assert.True(result.IsError); + Assert.NotNull(result.ErrorMessage); + Assert.Contains("boom", result.ErrorMessage!); + } + + private static IReadOnlyDictionary BuildCatalog(params (string name, string schema)[] entries) + { + var dict = new Dictionary(); + foreach (var (name, schema) in entries) + { + dict[name] = new AITool( + name: name, + description: $"Test tool {name}", + category: "Test", + parametersSchema: schema, + execute: _ => Task.FromResult(new AIReturn())); + } + + return dict; + } + } +} diff --git a/src/SmartHopper.Infrastructure.Tests/Mcp/JsonRpcDispatcherTests.cs b/src/SmartHopper.Infrastructure.Tests/Mcp/JsonRpcDispatcherTests.cs new file mode 100644 index 00000000..f24d5460 --- /dev/null +++ b/src/SmartHopper.Infrastructure.Tests/Mcp/JsonRpcDispatcherTests.cs @@ -0,0 +1,186 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +namespace SmartHopper.Infrastructure.Tests.Mcp +{ + using System.Collections.Generic; + using System.Threading.Tasks; + using Newtonsoft.Json.Linq; + using SmartHopper.Infrastructure.AICall.Core.Interactions; + using SmartHopper.Infrastructure.AICall.Core.Returns; + using SmartHopper.Infrastructure.AICall.Tools; + using SmartHopper.Infrastructure.AITools; + using SmartHopper.Infrastructure.Mcp; + using Xunit; + + /// + /// Unit tests for . No Rhino/Grasshopper dependencies. + /// + public class JsonRpcDispatcherTests + { + private const string ReadOnlySchema = "{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}"; + + [Fact] + public async Task Dispatch_Initialize_ReturnsServerInfoAndProtocolVersion() + { + var dispatcher = BuildDispatcher(new McpServerOptions + { + ServerName = "smarthopper", + ServerVersion = "9.9.9", + }); + + var raw = await dispatcher.DispatchAsync( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"); + + Assert.NotNull(raw); + var obj = JObject.Parse(raw!); + Assert.Equal("2.0", (string?)obj["jsonrpc"]); + Assert.Equal(1, (int?)obj["id"]); + Assert.NotNull(obj["result"]); + Assert.Equal("smarthopper", (string?)obj["result"]?["serverInfo"]?["name"]); + Assert.Equal("9.9.9", (string?)obj["result"]?["serverInfo"]?["version"]); + Assert.NotNull((string?)obj["result"]?["protocolVersion"]); + Assert.NotNull(obj["result"]?["capabilities"]?["tools"]); + } + + [Fact] + public async Task Dispatch_ToolsList_ReturnsExposedToolsOnly() + { + var dispatcher = BuildDispatcher(new McpServerOptions(), + ("gh_get", ReadOnlySchema), + ("gh_put", ReadOnlySchema)); + + var raw = await dispatcher.DispatchAsync( + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}"); + + var obj = JObject.Parse(raw!); + var tools = (JArray?)obj["result"]?["tools"]; + Assert.NotNull(tools); + Assert.Single(tools!); + Assert.Equal("gh_get", (string?)tools![0]["name"]); + Assert.Equal("object", (string?)tools[0]["inputSchema"]?["type"]); + } + + [Fact] + public async Task Dispatch_ToolsCall_ReturnsTextContentWithToolResult() + { + var dispatcher = BuildDispatcher(new McpServerOptions(), + executor: call => + { + var ret = new AIReturn + { + Request = call, + SkipRequestValidation = true, + SkipMetricsValidation = true, + }; + ret.SetBody(AIBody.Empty.WithAppended(new AIInteractionToolResult + { + Name = call.GetToolCall().Name, + Result = new JObject { ["ok"] = true, ["echoed"] = call.GetToolCall().Arguments }, + })); + return Task.FromResult(ret); + }, + tools: ("gh_get", ReadOnlySchema)); + + var raw = await dispatcher.DispatchAsync( + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"gh_get\",\"arguments\":{\"path\":\"/foo\"}}}"); + + var obj = JObject.Parse(raw!); + Assert.Equal(false, (bool?)obj["result"]?["isError"]); + var text = (string?)obj["result"]?["content"]?[0]?["text"]; + Assert.NotNull(text); + var payload = JObject.Parse(text!); + Assert.Equal(true, (bool?)payload["ok"]); + Assert.Equal("/foo", (string?)payload["echoed"]?["path"]); + } + + [Fact] + public async Task Dispatch_UnknownMethod_ReturnsMethodNotFoundError() + { + var dispatcher = BuildDispatcher(new McpServerOptions()); + + var raw = await dispatcher.DispatchAsync( + "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"resources/list\"}"); + + var obj = JObject.Parse(raw!); + Assert.Equal(-32601, (int?)obj["error"]?["code"]); + } + + [Fact] + public async Task Dispatch_InvalidJson_ReturnsParseError() + { + var dispatcher = BuildDispatcher(new McpServerOptions()); + + var raw = await dispatcher.DispatchAsync("not-json"); + + var obj = JObject.Parse(raw!); + Assert.Equal(-32700, (int?)obj["error"]?["code"]); + } + + [Fact] + public async Task Dispatch_Notification_ReturnsNoResponse() + { + var dispatcher = BuildDispatcher(new McpServerOptions()); + + var raw = await dispatcher.DispatchAsync( + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}"); + + Assert.Null(raw); + } + + [Fact] + public async Task Dispatch_ToolsCall_MissingName_ReturnsToolError() + { + var dispatcher = BuildDispatcher(new McpServerOptions()); + + var raw = await dispatcher.DispatchAsync( + "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\",\"params\":{}}"); + + var obj = JObject.Parse(raw!); + Assert.Equal(true, (bool?)obj["result"]?["isError"]); + Assert.Contains("name", (string?)obj["result"]?["content"]?[0]?["text"]); + } + + private static JsonRpcDispatcher BuildDispatcher( + McpServerOptions options, + params (string name, string schema)[] tools) + { + return BuildDispatcher(options, _ => Task.FromResult(new AIReturn()), tools); + } + + private static JsonRpcDispatcher BuildDispatcher( + McpServerOptions options, + System.Func> executor, + params (string name, string schema)[] tools) + { + var catalog = new Dictionary(); + foreach (var (name, schema) in tools) + { + catalog[name] = new AITool( + name: name, + description: $"Test tool {name}", + category: "Test", + parametersSchema: schema, + execute: _ => Task.FromResult(new AIReturn())); + } + + var adapter = new AIToolMcpAdapter(options, () => catalog, executor); + return new JsonRpcDispatcher(options, adapter); + } + } +} diff --git a/src/SmartHopper.Infrastructure.Tests/Mcp/McpServerOptionsTests.cs b/src/SmartHopper.Infrastructure.Tests/Mcp/McpServerOptionsTests.cs new file mode 100644 index 00000000..ede7e396 --- /dev/null +++ b/src/SmartHopper.Infrastructure.Tests/Mcp/McpServerOptionsTests.cs @@ -0,0 +1,64 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +namespace SmartHopper.Infrastructure.Tests.Mcp +{ + using SmartHopper.Infrastructure.Mcp; + using Xunit; + + public class McpServerOptionsTests + { + [Fact] + public void DefaultPort_MatchesDocumentedConstant() + { + Assert.Equal(26929, McpServerOptions.DefaultPort); + Assert.Equal(McpServerOptions.DefaultPort, new McpServerOptions().Port); + } + + [Fact] + public void MutatingToolPrefixes_AreNonEmpty() + { + Assert.NotEmpty(McpServerOptions.MutatingToolPrefixes); + Assert.Contains("gh_put", McpServerOptions.MutatingToolPrefixes); + } + + [Fact] + public void Clone_ProducesIndependentCopy() + { + var original = new McpServerOptions + { + Port = 4242, + BearerToken = "abc", + EnabledTools = new[] { "gh_get" }, + ExposeMutatingTools = true, + ServerName = "custom", + ServerVersion = "1.2.3", + }; + + var copy = original.Clone(); + + Assert.Equal(original.Port, copy.Port); + Assert.Equal(original.BearerToken, copy.BearerToken); + Assert.Equal(original.EnabledTools, copy.EnabledTools); + Assert.NotSame(original.EnabledTools, copy.EnabledTools); + Assert.Equal(original.ExposeMutatingTools, copy.ExposeMutatingTools); + Assert.Equal(original.ServerName, copy.ServerName); + Assert.Equal(original.ServerVersion, copy.ServerVersion); + } + } +} diff --git a/src/SmartHopper.Infrastructure/Mcp/AIToolMcpAdapter.cs b/src/SmartHopper.Infrastructure/Mcp/AIToolMcpAdapter.cs new file mode 100644 index 00000000..ad20af70 --- /dev/null +++ b/src/SmartHopper.Infrastructure/Mcp/AIToolMcpAdapter.cs @@ -0,0 +1,248 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +/* + * The reflection-based tool-discovery pattern is structurally adapted from Cordyceps + * (https://github.com/brookstalley/cordyceps, McpServer.cs). SmartHopper does not + * use [McpServerTool] reflection: tools are already discoverable through + * AIToolManager, so the adapter projects that catalog into MCP descriptors instead. + * Copyright (c) 2026 Brooks Talley. Licensed under the MIT License. See + * THIRD_PARTY_NOTICES.md. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using SmartHopper.Infrastructure.AICall.Core.Base; +using SmartHopper.Infrastructure.AICall.Core.Interactions; +using SmartHopper.Infrastructure.AICall.Core.Returns; +using SmartHopper.Infrastructure.AICall.Tools; +using SmartHopper.Infrastructure.AITools; +using SmartHopper.Infrastructure.Diagnostics; + +namespace SmartHopper.Infrastructure.Mcp +{ + /// + /// Bridges SmartHopper's catalog to the Model Context + /// Protocol's tools/list and tools/call surface. + /// + /// + /// All schema-layer concerns (GhJSON marshalling, validation, fix-up, placement) + /// continue to live inside the tools themselves and the upstream + /// architects-toolkit/ghjson-dotnet library. This adapter only translates + /// between the MCP envelope and / . + /// + public sealed class AIToolMcpAdapter + { + private readonly McpServerOptions options; + private readonly Func> toolSource; + private readonly Func> executor; + + /// + /// Initializes a new instance of the class. + /// + public AIToolMcpAdapter(McpServerOptions options) + : this(options, () => AIToolManager.GetTools(), call => call.Exec()) + { + } + + /// + /// Initializes a new instance of the class with + /// injectable tool source and executor. Used by tests and embedding scenarios + /// that want to substitute the catalog or execution pipeline. + /// + public AIToolMcpAdapter( + McpServerOptions options, + Func> toolSource, + Func> executor) + { + this.options = options ?? throw new ArgumentNullException(nameof(options)); + this.toolSource = toolSource ?? throw new ArgumentNullException(nameof(toolSource)); + this.executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + /// + /// Builds the list of MCP tool descriptors to expose, applying the configured + /// allow-list and mutating-tools filter. + /// + public IReadOnlyList BuildDescriptors() + { + var allTools = this.toolSource(); + var descriptors = new List(); + foreach (var pair in allTools) + { + var tool = pair.Value; + if (!this.IsExposed(tool.Name)) + { + continue; + } + + JObject schema = ParseSchema(tool.ParametersSchema); + descriptors.Add(new McpToolDescriptor(tool.Name, tool.Description, schema)); + } + + descriptors.Sort((a, b) => string.CompareOrdinal(a.Name, b.Name)); + return descriptors; + } + + /// + /// Returns whether the named tool is currently exposed (allow-list + mutating filter). + /// + public bool IsExposed(string toolName) + { + if (string.IsNullOrWhiteSpace(toolName)) + { + return false; + } + + if (this.options.EnabledTools != null && this.options.EnabledTools.Count > 0) + { + return this.options.EnabledTools.Any(t => + string.Equals(t, toolName, StringComparison.OrdinalIgnoreCase)); + } + + if (!this.options.ExposeMutatingTools && IsMutating(toolName)) + { + return false; + } + + return true; + } + + /// + /// Executes a tool via + /// and returns the JSON payload to embed in the MCP tools/call response. + /// + /// Tool name. + /// JSON object containing tool arguments. + public async Task ExecuteAsync(string toolName, JObject? arguments) + { + if (string.IsNullOrWhiteSpace(toolName)) + { + return McpToolCallResult.Error("Tool name is required"); + } + + if (!this.IsExposed(toolName)) + { + return McpToolCallResult.Error($"Tool '{toolName}' is not exposed via MCP"); + } + + var tools = this.toolSource(); + if (!tools.ContainsKey(toolName)) + { + return McpToolCallResult.Error($"Tool '{toolName}' is not registered"); + } + + var interaction = new AIInteractionToolCall + { + Id = Guid.NewGuid().ToString("N"), + Name = toolName, + Arguments = arguments ?? new JObject(), + }; + + var toolCall = new AIToolCall { SkipMetricsValidation = true }; + toolCall.FromToolCallInteraction(interaction); + + AIReturn result; + try + { + result = await this.executor(toolCall).ConfigureAwait(false); + } + catch (Exception ex) + { + return McpToolCallResult.Error($"Tool '{toolName}' threw an exception: {ex.Message}"); + } + + return BuildResult(toolName, result); + } + + private static bool IsMutating(string toolName) + { + foreach (var prefix in McpServerOptions.MutatingToolPrefixes) + { + if (toolName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static JObject ParseSchema(string parametersSchema) + { + if (string.IsNullOrWhiteSpace(parametersSchema)) + { + return new JObject { ["type"] = "object" }; + } + + try + { + var parsed = JToken.Parse(parametersSchema); + if (parsed is JObject obj) + { + return obj; + } + } + catch (JsonReaderException) + { + // Fall through to a defensive default below; surfacing this loudly + // is the tool author's responsibility. + } + + return new JObject { ["type"] = "object" }; + } + + private static McpToolCallResult BuildResult(string toolName, AIReturn? result) + { + if (result == null) + { + return McpToolCallResult.Error($"Tool '{toolName}' returned no result"); + } + + // Prefer the tool result interaction when present: a successful tool + // run always appends an AIInteractionToolResult to the body. + var lastToolResult = result.Body?.Interactions? + .OfType() + .LastOrDefault(); + if (lastToolResult?.Result != null) + { + return McpToolCallResult.Ok(lastToolResult.Result); + } + + // Otherwise look for an explicit tool/provider/network error on the return. + // We deliberately ignore Origin.Return and Origin.Validation: those reflect + // shape/metrics checks (e.g. metrics required) that the MCP adapter does + // not surface as tool failures. + var firstError = result.Messages? + .FirstOrDefault(m => m?.Severity == SHRuntimeMessageSeverity.Error + && (m.Origin == SHRuntimeMessageOrigin.Tool + || m.Origin == SHRuntimeMessageOrigin.Provider + || m.Origin == SHRuntimeMessageOrigin.Network)); + if (firstError != null) + { + return McpToolCallResult.Error(firstError.Message ?? $"Tool '{toolName}' failed"); + } + + return McpToolCallResult.Ok(new JObject()); + } + } +} diff --git a/src/SmartHopper.Infrastructure/Mcp/JsonRpcDispatcher.cs b/src/SmartHopper.Infrastructure/Mcp/JsonRpcDispatcher.cs new file mode 100644 index 00000000..3e2eac67 --- /dev/null +++ b/src/SmartHopper.Infrastructure/Mcp/JsonRpcDispatcher.cs @@ -0,0 +1,276 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +/* + * JSON-RPC dispatch shape and MCP method names structurally adapted from Cordyceps + * (https://github.com/brookstalley/cordyceps, McpServer.cs). + * Copyright (c) 2026 Brooks Talley. Licensed under the MIT License. See + * THIRD_PARTY_NOTICES.md. + */ + +using System; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace SmartHopper.Infrastructure.Mcp +{ + /// + /// Translates JSON-RPC 2.0 envelopes into MCP method invocations against the + /// . Concurrency is serialised by a + /// so multiple HTTP clients cannot race on the + /// Grasshopper document (see docs/Architecture/mcp-server.md ยง6). + /// + public sealed class JsonRpcDispatcher : IDisposable + { + private const string ProtocolVersion = "2025-03-26"; + private const string JsonRpcVersion = "2.0"; + + // JSON-RPC error codes per https://www.jsonrpc.org/specification. + private const int ParseError = -32700; + private const int InvalidRequest = -32600; + private const int MethodNotFound = -32601; + private const int InvalidParams = -32602; + private const int InternalError = -32603; + + private readonly McpServerOptions options; + private readonly AIToolMcpAdapter adapter; + private readonly SemaphoreSlim gate = new SemaphoreSlim(1, 1); + + /// + /// Initializes a new instance of the class. + /// + public JsonRpcDispatcher(McpServerOptions options, AIToolMcpAdapter adapter) + { + this.options = options ?? throw new ArgumentNullException(nameof(options)); + this.adapter = adapter ?? throw new ArgumentNullException(nameof(adapter)); + } + + /// + /// Parses a raw JSON-RPC request body and produces a serialized response body. + /// Returns null for notifications (no id in the request). + /// + /// Raw request payload. + /// Cancellation token. + public async Task DispatchAsync(string requestBody, CancellationToken cancellationToken = default) + { + JObject? request; + try + { + request = JObject.Parse(requestBody ?? string.Empty); + } + catch (JsonReaderException ex) + { + return SerializeError(null, ParseError, $"Parse error: {ex.Message}"); + } + + var id = request["id"]; + var method = request.Value("method"); + var paramsToken = request["params"] as JObject; + + if (string.IsNullOrWhiteSpace(method)) + { + return SerializeError(id, InvalidRequest, "Missing 'method'"); + } + + await this.gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + JToken result; + switch (method) + { + case "initialize": + result = this.HandleInitialize(); + break; + case "notifications/initialized": + case "initialized": + // Notification โ€” no response. + return null; + case "ping": + result = new JObject(); + break; + case "tools/list": + result = this.HandleToolsList(); + break; + case "tools/call": + result = await this.HandleToolsCallAsync(paramsToken).ConfigureAwait(false); + break; + default: + return SerializeError(id, MethodNotFound, $"Method not found: {method}"); + } + + if (id == null) + { + // Request without id is a notification per JSON-RPC; do not reply. + return null; + } + + var response = new JObject + { + ["jsonrpc"] = JsonRpcVersion, + ["id"] = id, + ["result"] = result, + }; + return response.ToString(Formatting.None); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return SerializeError(id, InternalError, $"Internal error: {ex.Message}"); + } + finally + { + this.gate.Release(); + } + } + + /// + public void Dispose() + { + this.gate.Dispose(); + } + + private JObject HandleInitialize() + { + return new JObject + { + ["protocolVersion"] = ProtocolVersion, + ["capabilities"] = new JObject + { + ["tools"] = new JObject { ["listChanged"] = false }, + }, + ["serverInfo"] = new JObject + { + ["name"] = this.options.ServerName, + ["version"] = this.options.ServerVersion ?? GetAssemblyVersion(), + }, + }; + } + + private JObject HandleToolsList() + { + var descriptors = this.adapter.BuildDescriptors(); + var list = new JArray(); + foreach (var d in descriptors) + { + list.Add(d.ToMcpJson()); + } + + return new JObject { ["tools"] = list }; + } + + private async Task HandleToolsCallAsync(JObject? paramsToken) + { + if (paramsToken == null) + { + return BuildToolError("Missing 'params'"); + } + + var toolName = paramsToken.Value("name"); + if (string.IsNullOrWhiteSpace(toolName)) + { + return BuildToolError("Missing 'name'"); + } + + var argumentsToken = paramsToken["arguments"]; + JObject? arguments; + if (argumentsToken is JObject obj) + { + arguments = obj; + } + else if (argumentsToken == null || argumentsToken.Type == JTokenType.Null) + { + arguments = new JObject(); + } + else + { + return BuildToolError("'arguments' must be a JSON object"); + } + + var result = await this.adapter.ExecuteAsync(toolName!, arguments).ConfigureAwait(false); + return BuildToolCallEnvelope(result); + } + + private static JObject BuildToolCallEnvelope(McpToolCallResult result) + { + var content = new JArray + { + new JObject + { + ["type"] = "text", + ["text"] = result.Payload.ToString(Formatting.None), + }, + }; + + return new JObject + { + ["content"] = content, + ["isError"] = result.IsError, + }; + } + + private static JObject BuildToolError(string message) + { + var content = new JArray + { + new JObject + { + ["type"] = "text", + ["text"] = message ?? string.Empty, + }, + }; + return new JObject + { + ["content"] = content, + ["isError"] = true, + }; + } + + private static string SerializeError(JToken? id, int code, string message) + { + var response = new JObject + { + ["jsonrpc"] = JsonRpcVersion, + ["id"] = id ?? JValue.CreateNull(), + ["error"] = new JObject + { + ["code"] = code, + ["message"] = message ?? string.Empty, + }, + }; + return response.ToString(Formatting.None); + } + + private static string GetAssemblyVersion() + { + var asm = typeof(JsonRpcDispatcher).Assembly; + var info = asm.GetCustomAttribute()?.InformationalVersion; + if (!string.IsNullOrWhiteSpace(info)) + { + return info!; + } + + return asm.GetName().Version?.ToString() ?? "0.0.0"; + } + } +} diff --git a/src/SmartHopper.Infrastructure/Mcp/McpServer.cs b/src/SmartHopper.Infrastructure/Mcp/McpServer.cs new file mode 100644 index 00000000..36152289 --- /dev/null +++ b/src/SmartHopper.Infrastructure/Mcp/McpServer.cs @@ -0,0 +1,312 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +/* + * HTTP/JSON-RPC transport skeleton structurally adapted from Cordyceps + * (https://github.com/brookstalley/cordyceps, McpServer.cs), simplified for + * SmartHopper: loopback bind, no SSE, single shared dispatcher. + * Copyright (c) 2026 Brooks Talley. Licensed under the MIT License. See + * THIRD_PARTY_NOTICES.md. + */ + +using System; +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace SmartHopper.Infrastructure.Mcp +{ + /// + /// Local HTTP MCP server. Binds to http://127.0.0.1:<port>/mcp, + /// rejects non-loopback origins, and forwards every JSON-RPC request to a + /// . + /// + public sealed class McpServer : IDisposable + { + private const string Endpoint = "/mcp"; + private const string HealthEndpoint = "/health"; + + private readonly McpServerOptions options; + private readonly JsonRpcDispatcher dispatcher; + private HttpListener? listener; + private CancellationTokenSource? cancellation; + private Task? acceptLoop; + private int started; + + /// + /// Initializes a new instance of the class. + /// + public McpServer(McpServerOptions options) + : this(options, new JsonRpcDispatcher(options, new AIToolMcpAdapter(options))) + { + } + + /// + /// Initializes a new instance of the class with a custom dispatcher. + /// + public McpServer(McpServerOptions options, JsonRpcDispatcher dispatcher) + { + this.options = options ?? throw new ArgumentNullException(nameof(options)); + this.dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + } + + /// Gets a value indicating whether the server is currently accepting requests. + public bool IsRunning => this.listener?.IsListening ?? false; + + /// Gets the configured loopback URL the server listens on. + public string Url => $"http://127.0.0.1:{this.options.Port}{Endpoint}"; + + /// + /// Starts the server. Subsequent calls are no-ops. + /// + public void Start() + { + if (Interlocked.Exchange(ref this.started, 1) == 1) + { + return; + } + + this.listener = new HttpListener(); + this.listener.Prefixes.Add($"http://127.0.0.1:{this.options.Port}/"); + try + { + this.listener.Start(); + } + catch (Exception ex) + { + this.listener.Close(); + this.listener = null; + Interlocked.Exchange(ref this.started, 0); + throw new InvalidOperationException($"Failed to start MCP HTTP listener on port {this.options.Port}: {ex.Message}", ex); + } + + this.cancellation = new CancellationTokenSource(); + this.acceptLoop = Task.Run(() => this.AcceptLoopAsync(this.cancellation.Token)); + Debug.WriteLine($"[Mcp] Server listening on {this.Url}"); + } + + /// + /// Stops the server. Subsequent calls are no-ops. + /// + public void Stop() + { + if (Interlocked.Exchange(ref this.started, 0) == 0) + { + return; + } + + try + { + this.cancellation?.Cancel(); + } + catch (ObjectDisposedException) + { + } + + try + { + this.listener?.Stop(); + this.listener?.Close(); + } + catch (Exception ex) + { + Debug.WriteLine($"[Mcp] Error stopping listener: {ex.Message}"); + } + + try + { + this.acceptLoop?.Wait(TimeSpan.FromSeconds(2)); + } + catch (AggregateException) + { + } + + this.listener = null; + this.cancellation?.Dispose(); + this.cancellation = null; + this.acceptLoop = null; + Debug.WriteLine("[Mcp] Server stopped"); + } + + /// + public void Dispose() + { + this.Stop(); + this.dispatcher.Dispose(); + } + + private async Task AcceptLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested && this.listener != null) + { + HttpListenerContext context; + try + { + context = await this.listener.GetContextAsync().ConfigureAwait(false); + } + catch (HttpListenerException) when (ct.IsCancellationRequested) + { + return; + } + catch (ObjectDisposedException) + { + return; + } + catch (Exception ex) + { + Debug.WriteLine($"[Mcp] Accept error: {ex.Message}"); + continue; + } + + _ = Task.Run(() => this.HandleRequestAsync(context, ct)); + } + } + + private async Task HandleRequestAsync(HttpListenerContext context, CancellationToken ct) + { + var request = context.Request; + var response = context.Response; + try + { + // Reject non-loopback peers as a defence-in-depth measure even though the + // listener is already bound to 127.0.0.1. + if (request.RemoteEndPoint == null || !IPAddress.IsLoopback(request.RemoteEndPoint.Address)) + { + await WriteStatusAsync(response, HttpStatusCode.Forbidden, "Non-loopback origin").ConfigureAwait(false); + return; + } + + if (string.Equals(request.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase) + && string.Equals(request.Url?.AbsolutePath, HealthEndpoint, StringComparison.OrdinalIgnoreCase)) + { + await WriteJsonAsync(response, HttpStatusCode.OK, "{\"status\":\"ok\"}").ConfigureAwait(false); + return; + } + + if (!string.Equals(request.HttpMethod, "POST", StringComparison.OrdinalIgnoreCase)) + { + await WriteStatusAsync(response, HttpStatusCode.MethodNotAllowed, "POST required").ConfigureAwait(false); + return; + } + + if (!string.Equals(request.Url?.AbsolutePath, Endpoint, StringComparison.OrdinalIgnoreCase)) + { + await WriteStatusAsync(response, HttpStatusCode.NotFound, "Not found").ConfigureAwait(false); + return; + } + + if (!this.AuthorizeRequest(request, response, out var authError)) + { + await WriteStatusAsync(response, HttpStatusCode.Unauthorized, authError).ConfigureAwait(false); + return; + } + + string body; + using (var reader = new StreamReader(request.InputStream, request.ContentEncoding ?? Encoding.UTF8)) + { + body = await reader.ReadToEndAsync().ConfigureAwait(false); + } + + if (body.Length > 256 * 1024) + { + await WriteStatusAsync(response, HttpStatusCode.RequestEntityTooLarge, "Request body too large").ConfigureAwait(false); + return; + } + + var responseBody = await this.dispatcher.DispatchAsync(body, ct).ConfigureAwait(false); + if (responseBody == null) + { + // JSON-RPC notification โ€” return 204. + response.StatusCode = (int)HttpStatusCode.NoContent; + response.Close(); + return; + } + + await WriteJsonAsync(response, HttpStatusCode.OK, responseBody).ConfigureAwait(false); + } + catch (Exception ex) + { + Debug.WriteLine($"[Mcp] Request error: {ex.Message}"); + try + { + await WriteStatusAsync(response, HttpStatusCode.InternalServerError, "Internal server error").ConfigureAwait(false); + } + catch + { + // Response stream may already be closed. + } + } + } + + private bool AuthorizeRequest(HttpListenerRequest request, HttpListenerResponse response, out string error) + { + error = string.Empty; + var expected = this.options.BearerToken; + if (string.IsNullOrEmpty(expected)) + { + return true; + } + + var header = request.Headers["Authorization"]; + if (string.IsNullOrEmpty(header) || !header.StartsWith("Bearer ", StringComparison.Ordinal)) + { + error = "Bearer token required"; + return false; + } + + var presented = header.Substring("Bearer ".Length).Trim(); + if (!string.Equals(presented, expected, StringComparison.Ordinal)) + { + error = "Invalid bearer token"; + return false; + } + + return true; + } + + private static async Task WriteJsonAsync(HttpListenerResponse response, HttpStatusCode status, string body) + { + var bytes = Encoding.UTF8.GetBytes(body ?? string.Empty); + response.StatusCode = (int)status; + response.ContentType = "application/json; charset=utf-8"; + response.ContentLength64 = bytes.Length; + await response.OutputStream.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false); + response.OutputStream.Flush(); + response.Close(); + } + + private static async Task WriteStatusAsync(HttpListenerResponse response, HttpStatusCode status, string message) + { + var payload = $"{{\"error\":\"{Escape(message)}\"}}"; + await WriteJsonAsync(response, status, payload).ConfigureAwait(false); + } + + private static string Escape(string text) + { + if (string.IsNullOrEmpty(text)) + { + return string.Empty; + } + + return text.Replace("\\", "\\\\").Replace("\"", "\\\""); + } + } +} diff --git a/src/SmartHopper.Infrastructure/Mcp/McpServerLifecycle.cs b/src/SmartHopper.Infrastructure/Mcp/McpServerLifecycle.cs new file mode 100644 index 00000000..ed6ec957 --- /dev/null +++ b/src/SmartHopper.Infrastructure/Mcp/McpServerLifecycle.cs @@ -0,0 +1,134 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace SmartHopper.Infrastructure.Mcp +{ + /// + /// Ref-counted singleton manager for instances. Multiple + /// components (potentially across multiple Grasshopper documents) can call + /// with the same port; only the first acquisition starts + /// the server and only the last stops it. + /// + /// + /// Configuration mutations after the first acquisition are not applied: the + /// shared server keeps the options it was started with. Components should + /// and re- when their inputs change. + /// + public static class McpServerLifecycle + { + private static readonly object Sync = new object(); + private static readonly Dictionary Slots = new Dictionary(); + + /// + /// Acquires (and starts if necessary) the shared server for the given port. + /// + /// Caller key. Used so the same caller can re-acquire without double-counting. + /// Options used to start the server. Ignored on subsequent acquisitions. + /// The running . + public static McpServer Acquire(object key, McpServerOptions options) + { + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + lock (Sync) + { + if (!Slots.TryGetValue(options.Port, out var slot)) + { + var server = new McpServer(options.Clone()); + server.Start(); + slot = new ServerSlot(server); + Slots[options.Port] = slot; + } + + slot.Holders.Add(key); + return slot.Server; + } + } + + /// + /// Releases a previous acquisition. The server stops when the last holder releases it. + /// + public static void Release(object key, int port) + { + if (key == null) + { + return; + } + + lock (Sync) + { + if (!Slots.TryGetValue(port, out var slot)) + { + return; + } + + slot.Holders.Remove(key); + if (slot.Holders.Count > 0) + { + return; + } + + try + { + slot.Server.Stop(); + slot.Server.Dispose(); + } + catch (Exception ex) + { + Debug.WriteLine($"[Mcp] Error disposing server on port {port}: {ex.Message}"); + } + + Slots.Remove(port); + } + } + + /// + /// Returns the currently running server for a port, or null. + /// + public static McpServer? Find(int port) + { + lock (Sync) + { + return Slots.TryGetValue(port, out var slot) ? slot.Server : null; + } + } + + private sealed class ServerSlot + { + public ServerSlot(McpServer server) + { + this.Server = server; + } + + public McpServer Server { get; } + + public HashSet Holders { get; } = new HashSet(); + } + } +} diff --git a/src/SmartHopper.Infrastructure/Mcp/McpServerOptions.cs b/src/SmartHopper.Infrastructure/Mcp/McpServerOptions.cs new file mode 100644 index 00000000..2059d57f --- /dev/null +++ b/src/SmartHopper.Infrastructure/Mcp/McpServerOptions.cs @@ -0,0 +1,105 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +using System.Collections.Generic; + +namespace SmartHopper.Infrastructure.Mcp +{ + /// + /// Configuration for an instance. + /// + public sealed class McpServerOptions + { + /// + /// Default TCP port. Matches Cordyceps' default to ease cross-tool documentation; + /// can be overridden per-component. + /// + public const int DefaultPort = 26929; + + /// + /// Gets or sets the loopback port the server should listen on. Range 1024..65535. + /// + public int Port { get; set; } = DefaultPort; + + /// + /// Gets or sets the optional bearer token. When set, requests without + /// Authorization: Bearer <token> are rejected with HTTP 401. + /// + public string? BearerToken { get; set; } + + /// + /// Gets or sets an allow-list of tool names. When non-empty, only listed tools are + /// exposed via tools/list / tools/call. When null or empty, every + /// non-mutating tool is exposed and mutating tools are kept off (see + /// ). + /// + public IReadOnlyCollection? EnabledTools { get; set; } + + /// + /// Gets or sets a value indicating whether tools whose name starts with one of the + /// are exposed. Defaults to false so the + /// default surface is read-only. + /// + public bool ExposeMutatingTools { get; set; } + + /// + /// Gets or sets the server identifier reported during MCP initialize. + /// + public string ServerName { get; set; } = "smarthopper"; + + /// + /// Gets or sets the server version reported during MCP initialize. When null, + /// the dispatcher falls back to the assembly informational version. + /// + public string? ServerVersion { get; set; } + + /// + /// Gets the case-insensitive set of tool-name prefixes considered "mutating". + /// Tools matching one of these are off by default per the design doc's + /// security baseline (ยง8 of docs/Architecture/mcp-server.md). + /// + public static IReadOnlyCollection MutatingToolPrefixes { get; } = new[] + { + "gh_put", + "gh_move", + "gh_group", + "gh_merge", + "gh_tidy_up", + "gh_component_lock", + "gh_component_preview", + "script_edit", + "script_generate", + }; + + /// + /// Returns a defensive shallow copy. + /// + public McpServerOptions Clone() + { + return new McpServerOptions + { + Port = this.Port, + BearerToken = this.BearerToken, + EnabledTools = this.EnabledTools == null ? null : new List(this.EnabledTools), + ExposeMutatingTools = this.ExposeMutatingTools, + ServerName = this.ServerName, + ServerVersion = this.ServerVersion, + }; + } + } +} diff --git a/src/SmartHopper.Infrastructure/Mcp/McpToolCallResult.cs b/src/SmartHopper.Infrastructure/Mcp/McpToolCallResult.cs new file mode 100644 index 00000000..b39e09c3 --- /dev/null +++ b/src/SmartHopper.Infrastructure/Mcp/McpToolCallResult.cs @@ -0,0 +1,63 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +using Newtonsoft.Json.Linq; + +namespace SmartHopper.Infrastructure.Mcp +{ + /// + /// Outcome of an MCP tools/call invocation. + /// + public sealed class McpToolCallResult + { + private McpToolCallResult(bool isError, JToken payload, string? errorMessage) + { + this.IsError = isError; + this.Payload = payload; + this.ErrorMessage = errorMessage; + } + + /// Gets a value indicating whether the call failed. + public bool IsError { get; } + + /// + /// Gets the JSON payload returned by the tool. For successful calls this is the + /// tool's Result JObject; for errors it is an object describing the failure. + /// + public JToken Payload { get; } + + /// Gets the error message when is true. + public string? ErrorMessage { get; } + + /// Creates a success result. + public static McpToolCallResult Ok(JToken payload) + { + return new McpToolCallResult(false, payload ?? new JObject(), null); + } + + /// Creates an error result. + public static McpToolCallResult Error(string message) + { + var payload = new JObject + { + ["error"] = message ?? string.Empty, + }; + return new McpToolCallResult(true, payload, message); + } + } +} diff --git a/src/SmartHopper.Infrastructure/Mcp/McpToolDescriptor.cs b/src/SmartHopper.Infrastructure/Mcp/McpToolDescriptor.cs new file mode 100644 index 00000000..b8b0f2fb --- /dev/null +++ b/src/SmartHopper.Infrastructure/Mcp/McpToolDescriptor.cs @@ -0,0 +1,63 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +using Newtonsoft.Json.Linq; + +namespace SmartHopper.Infrastructure.Mcp +{ + /// + /// MCP-shaped view of a SmartHopper . + /// + public sealed class McpToolDescriptor + { + /// + /// Initializes a new instance of the class. + /// + /// Tool name. Used as the MCP tools/call target. + /// Human-readable description. + /// Parsed JSON schema for tool arguments. + public McpToolDescriptor(string name, string description, JObject inputSchema) + { + this.Name = name; + this.Description = description; + this.InputSchema = inputSchema; + } + + /// Gets the tool name. + public string Name { get; } + + /// Gets the tool description. + public string Description { get; } + + /// Gets the JSON schema describing tool arguments. + public JObject InputSchema { get; } + + /// + /// Renders this descriptor as the JSON object returned in MCP tools/list. + /// + public JObject ToMcpJson() + { + return new JObject + { + ["name"] = this.Name, + ["description"] = this.Description, + ["inputSchema"] = this.InputSchema, + }; + } + } +} From 3b1feddb3aff66f233c521d5c19c8115b03be032 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 17 May 2026 10:31:21 +0000 Subject: [PATCH 3/3] chore(ci): update license headers --- src/SmartHopper.Components/Mcp/SmartHopperMcpServerComponent.cs | 2 +- .../Mcp/AIToolMcpAdapterTests.cs | 2 +- .../Mcp/JsonRpcDispatcherTests.cs | 2 +- .../Mcp/McpServerOptionsTests.cs | 2 +- src/SmartHopper.Infrastructure/Mcp/AIToolMcpAdapter.cs | 2 +- src/SmartHopper.Infrastructure/Mcp/JsonRpcDispatcher.cs | 2 +- src/SmartHopper.Infrastructure/Mcp/McpServer.cs | 2 +- src/SmartHopper.Infrastructure/Mcp/McpServerLifecycle.cs | 2 +- src/SmartHopper.Infrastructure/Mcp/McpServerOptions.cs | 2 +- src/SmartHopper.Infrastructure/Mcp/McpToolCallResult.cs | 2 +- src/SmartHopper.Infrastructure/Mcp/McpToolDescriptor.cs | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/SmartHopper.Components/Mcp/SmartHopperMcpServerComponent.cs b/src/SmartHopper.Components/Mcp/SmartHopperMcpServerComponent.cs index 273128f4..fa29160c 100644 --- a/src/SmartHopper.Components/Mcp/SmartHopperMcpServerComponent.cs +++ b/src/SmartHopper.Components/Mcp/SmartHopperMcpServerComponent.cs @@ -1,4 +1,4 @@ -/* +๏ปฟ/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024-2026 Marc Roca Musach * diff --git a/src/SmartHopper.Infrastructure.Tests/Mcp/AIToolMcpAdapterTests.cs b/src/SmartHopper.Infrastructure.Tests/Mcp/AIToolMcpAdapterTests.cs index c07a66db..c1b3a4d3 100644 --- a/src/SmartHopper.Infrastructure.Tests/Mcp/AIToolMcpAdapterTests.cs +++ b/src/SmartHopper.Infrastructure.Tests/Mcp/AIToolMcpAdapterTests.cs @@ -1,4 +1,4 @@ -/* +๏ปฟ/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024-2026 Marc Roca Musach * diff --git a/src/SmartHopper.Infrastructure.Tests/Mcp/JsonRpcDispatcherTests.cs b/src/SmartHopper.Infrastructure.Tests/Mcp/JsonRpcDispatcherTests.cs index f24d5460..5ea9a672 100644 --- a/src/SmartHopper.Infrastructure.Tests/Mcp/JsonRpcDispatcherTests.cs +++ b/src/SmartHopper.Infrastructure.Tests/Mcp/JsonRpcDispatcherTests.cs @@ -1,4 +1,4 @@ -/* +๏ปฟ/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024-2026 Marc Roca Musach * diff --git a/src/SmartHopper.Infrastructure.Tests/Mcp/McpServerOptionsTests.cs b/src/SmartHopper.Infrastructure.Tests/Mcp/McpServerOptionsTests.cs index ede7e396..157c2bc4 100644 --- a/src/SmartHopper.Infrastructure.Tests/Mcp/McpServerOptionsTests.cs +++ b/src/SmartHopper.Infrastructure.Tests/Mcp/McpServerOptionsTests.cs @@ -1,4 +1,4 @@ -/* +๏ปฟ/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024-2026 Marc Roca Musach * diff --git a/src/SmartHopper.Infrastructure/Mcp/AIToolMcpAdapter.cs b/src/SmartHopper.Infrastructure/Mcp/AIToolMcpAdapter.cs index ad20af70..9bbda173 100644 --- a/src/SmartHopper.Infrastructure/Mcp/AIToolMcpAdapter.cs +++ b/src/SmartHopper.Infrastructure/Mcp/AIToolMcpAdapter.cs @@ -1,4 +1,4 @@ -/* +๏ปฟ/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024-2026 Marc Roca Musach * diff --git a/src/SmartHopper.Infrastructure/Mcp/JsonRpcDispatcher.cs b/src/SmartHopper.Infrastructure/Mcp/JsonRpcDispatcher.cs index 3e2eac67..3c11a939 100644 --- a/src/SmartHopper.Infrastructure/Mcp/JsonRpcDispatcher.cs +++ b/src/SmartHopper.Infrastructure/Mcp/JsonRpcDispatcher.cs @@ -1,4 +1,4 @@ -/* +๏ปฟ/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024-2026 Marc Roca Musach * diff --git a/src/SmartHopper.Infrastructure/Mcp/McpServer.cs b/src/SmartHopper.Infrastructure/Mcp/McpServer.cs index 36152289..9b56d0dc 100644 --- a/src/SmartHopper.Infrastructure/Mcp/McpServer.cs +++ b/src/SmartHopper.Infrastructure/Mcp/McpServer.cs @@ -1,4 +1,4 @@ -/* +๏ปฟ/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024-2026 Marc Roca Musach * diff --git a/src/SmartHopper.Infrastructure/Mcp/McpServerLifecycle.cs b/src/SmartHopper.Infrastructure/Mcp/McpServerLifecycle.cs index ed6ec957..c59ff675 100644 --- a/src/SmartHopper.Infrastructure/Mcp/McpServerLifecycle.cs +++ b/src/SmartHopper.Infrastructure/Mcp/McpServerLifecycle.cs @@ -1,4 +1,4 @@ -/* +๏ปฟ/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024-2026 Marc Roca Musach * diff --git a/src/SmartHopper.Infrastructure/Mcp/McpServerOptions.cs b/src/SmartHopper.Infrastructure/Mcp/McpServerOptions.cs index 2059d57f..c0fd6df1 100644 --- a/src/SmartHopper.Infrastructure/Mcp/McpServerOptions.cs +++ b/src/SmartHopper.Infrastructure/Mcp/McpServerOptions.cs @@ -1,4 +1,4 @@ -/* +๏ปฟ/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024-2026 Marc Roca Musach * diff --git a/src/SmartHopper.Infrastructure/Mcp/McpToolCallResult.cs b/src/SmartHopper.Infrastructure/Mcp/McpToolCallResult.cs index b39e09c3..0da76de0 100644 --- a/src/SmartHopper.Infrastructure/Mcp/McpToolCallResult.cs +++ b/src/SmartHopper.Infrastructure/Mcp/McpToolCallResult.cs @@ -1,4 +1,4 @@ -/* +๏ปฟ/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024-2026 Marc Roca Musach * diff --git a/src/SmartHopper.Infrastructure/Mcp/McpToolDescriptor.cs b/src/SmartHopper.Infrastructure/Mcp/McpToolDescriptor.cs index b8b0f2fb..d9695cfd 100644 --- a/src/SmartHopper.Infrastructure/Mcp/McpToolDescriptor.cs +++ b/src/SmartHopper.Infrastructure/Mcp/McpToolDescriptor.cs @@ -1,4 +1,4 @@ -/* +๏ปฟ/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024-2026 Marc Roca Musach *