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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +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 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

Expand Down
257 changes: 257 additions & 0 deletions docs/Architecture/mcp-server.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
189 changes: 189 additions & 0 deletions src/SmartHopper.Components/Mcp/SmartHopperMcpServerComponent.cs
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/lgpl-3.0.html>.
*/

using System;
using System.Diagnostics;
using Grasshopper.Kernel;
using SmartHopper.Infrastructure.Mcp;

namespace SmartHopper.Components.Mcp
{
/// <summary>
/// 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.).
/// </summary>
/// <remarks>
/// The server lifecycle is owned by <see cref="McpServerLifecycle"/>. 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 <c>docs/Architecture/mcp-server.md</c> for the full design.
/// </remarks>
public sealed class SmartHopperMcpServerComponent : GH_Component
{
private int currentPort = McpServerOptions.DefaultPort;
private bool acquired;
private string? lastStatus;

/// <summary>
/// Initializes a new instance of the <see cref="SmartHopperMcpServerComponent"/> class.
/// </summary>
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")
{
}

/// <inheritdoc/>
public override Guid ComponentGuid => new Guid("a3c4f1d0-7e2b-4c5a-9d1b-7f5e8c0a2b4d");

/// <inheritdoc/>
public override GH_Exposure Exposure => GH_Exposure.primary;

/// <inheritdoc/>
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 <token>' 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;
}

/// <inheritdoc/>
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);
}

/// <inheritdoc/>
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"));
}

/// <inheritdoc/>
public override void RemovedFromDocument(GH_Document document)
{
this.ReleaseIfHeld();
base.RemovedFromDocument(document);
}

/// <inheritdoc/>
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;
}
}
}
Loading