diff --git a/CMakeLists.txt b/CMakeLists.txt index 8ec5b76b7a73..1e84524950ea 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -308,6 +308,7 @@ ENDIF() IF(MESHLIB_BUILD_MCP AND NOT MR_EMSCRIPTEN) add_subdirectory(${PROJECT_SOURCE_DIR}/MRMcp ./MRMcp) + add_subdirectory(${PROJECT_SOURCE_DIR}/MRMCPGateway ./MRMCPGateway) ENDIF() IF(BUILD_TESTING) diff --git a/source/MRMCPGateway/CMakeLists.txt b/source/MRMCPGateway/CMakeLists.txt new file mode 100644 index 000000000000..9a3ac47bba8d --- /dev/null +++ b/source/MRMCPGateway/CMakeLists.txt @@ -0,0 +1,32 @@ +project(MRMCPGateway CXX) + +add_executable(${PROJECT_NAME} + MRMCPGateway.cpp + MRMCPGatewayBackend.cpp + MRMCPGatewayCache.cpp + MRMCPGatewayMlTransport.cpp + MRMCPGatewaySpawn.cpp +) + +# Same fastmcpp acquisition strategy as MRMcp/CMakeLists.txt — bundled subdirectory +# on Windows/vcpkg and macOS, system package on Debian/Ubuntu. +IF(MESHLIB_USE_VCPKG OR APPLE) + IF(NOT TARGET fastmcpp_core) + set(FASTMCPP_DEPS_ADD_SUBDIRECTORY ON) + add_subdirectory(../fastmcpp fastmcpp) + ENDIF() + target_link_libraries(${PROJECT_NAME} PRIVATE fastmcpp_core) + target_include_directories(${PROJECT_NAME} PRIVATE + ${MESHLIB_THIRDPARTY_DIR}/fastmcpp/include + ${MESHLIB_THIRDPARTY_DIR}/cpp-httplib + ${MESHLIB_THIRDPARTY_DIR}/nlohmann-json/include + ) +ELSE() + find_package(fastmcpp REQUIRED) + target_link_libraries(${PROJECT_NAME} PRIVATE fastmcpp::fastmcpp_core) +ENDIF() + +install( + TARGETS ${PROJECT_NAME} + RUNTIME DESTINATION "${MR_BIN_DIR}" +) diff --git a/source/MRMCPGateway/MRMCPGateway.cpp b/source/MRMCPGateway/MRMCPGateway.cpp new file mode 100644 index 000000000000..673585347a19 --- /dev/null +++ b/source/MRMCPGateway/MRMCPGateway.cpp @@ -0,0 +1,260 @@ +// Must not include any standard headers before MRFastmcpp.h (fastmcpp's macro +// shenanigans rely on it). +#include "MRMcp/MRFastmcpp.h" + +#include "MRMCPGatewayBackend.h" +#include "MRMCPGatewayCache.h" +#include "MRMCPGatewayConfig.h" +#include "MRMCPGatewayMlTransport.h" + +#include + +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#elif defined( __APPLE__ ) +#include +#include +#else +#include +#include +#endif + +namespace MR::McpGateway +{ + +namespace +{ + +// PASTED from `getExecutablePath_()` in MeshLib/source/MRMesh/MRSystemPath.cpp. +// Adapted: returns an empty path on failure instead of `Expected<>` so the +// gateway keeps its zero-MRMesh dependency footprint. +std::filesystem::path gatewayExePath() +{ +#if defined( _WIN32 ) + wchar_t path[MAX_PATH]; + auto size = GetModuleFileNameW( NULL, path, MAX_PATH ); + if ( size == 0 || size == MAX_PATH ) + return {}; + return std::filesystem::path{ path }; +#elif defined( __APPLE__ ) + char path[PATH_MAX]; + uint32_t size = PATH_MAX; + if ( _NSGetExecutablePath( path, &size ) != 0 ) + return {}; + return std::filesystem::path{ path }; +#else + char path[PATH_MAX]; + auto size = readlink( "/proc/self/exe", path, PATH_MAX ); + if ( size < 0 || size >= PATH_MAX ) + return {}; + path[size] = '\0'; + return std::filesystem::path{ path }; +#endif +} + +// Resolves `--launch-cmd` so callers can pass a bare backend name instead of +// a full path: a relative path becomes `/`, and on Windows +// a missing extension defaults to `.exe`. +std::filesystem::path resolveLaunchCommand( std::filesystem::path cmd ) +{ + if ( cmd.is_relative() ) + { + if ( auto exe = gatewayExePath(); !exe.empty() ) + cmd = exe.parent_path() / cmd; + } +#ifdef _WIN32 + if ( cmd.extension().empty() ) + cmd += ".exe"; +#endif + return cmd; +} + +void printUsage() +{ + std::cerr << + "Usage: MRMCPGateway --launch-cmd [options]\n" + " --launch-cmd Required. Backend executable launched by the 'launch' tool.\n" + " Relative paths are resolved against the gateway's own\n" + " directory; on Windows a missing extension defaults to '.exe'\n" + " (so a bare name works for a co-located binary).\n" + " Fixed at startup; not overridable via tool call.\n" + " --launch-arg Default argument forwarded to the backend (repeatable).\n" + " A 'launch' tool call may override these for that call.\n" + " --launch-timeout How long 'launch' waits for the backend (default 30).\n" + " --target-url Backend MCP server URL (default http://127.0.0.1:7887).\n" + " --sse-path SSE endpoint path (default /sse).\n" + " --messages-path POST endpoint path (default /messages).\n" + " --tools-cache-namespace \n" + " Optional sub-folder under the gateway's user-data dir,\n" + " letting multiple installations keep independent caches.\n" + " --help, -h Show this message.\n"; +} + +bool parseArgs( int argc, char** argv, Config& cfg ) +{ + for ( int i = 1; i < argc; ++i ) + { + const std::string a = argv[i]; + const auto needNext = [&]( const char* what ) -> bool + { + if ( i + 1 >= argc ) + { + std::cerr << "MRMCPGateway: " << what << " requires a value\n"; + return false; + } + return true; + }; + + if ( a == "--target-url" ) + { + if ( !needNext( "--target-url" ) ) return false; + cfg.targetUrl = argv[++i]; + } + else if ( a == "--sse-path" ) + { + if ( !needNext( "--sse-path" ) ) return false; + cfg.ssePath = argv[++i]; + } + else if ( a == "--messages-path" ) + { + if ( !needNext( "--messages-path" ) ) return false; + cfg.messagesPath = argv[++i]; + } + else if ( a == "--launch-cmd" ) + { + if ( !needNext( "--launch-cmd" ) ) return false; + cfg.launchCommand = argv[++i]; + } + else if ( a == "--launch-arg" ) + { + if ( !needNext( "--launch-arg" ) ) return false; + cfg.launchArgs.emplace_back( argv[++i] ); + } + else if ( a == "--launch-timeout" ) + { + if ( !needNext( "--launch-timeout" ) ) return false; + cfg.launchTimeout = std::chrono::seconds( std::atoi( argv[++i] ) ); + } + else if ( a == "--tools-cache-namespace" ) + { + if ( !needNext( "--tools-cache-namespace" ) ) return false; + cfg.toolsCacheNamespace = argv[++i]; + } + else if ( a == "--help" || a == "-h" ) + { + printUsage(); + std::exit( 0 ); + } + else + { + std::cerr << "MRMCPGateway: unknown argument: " << a << "\n"; + printUsage(); + return false; + } + } + + if ( cfg.launchCommand.empty() ) + { + std::cerr << "MRMCPGateway: --launch-cmd is required\n"; + printUsage(); + return false; + } + return true; +} + +} // anonymous namespace + +} // namespace MR::McpGateway + +int main( int argc, char** argv ) +{ + using namespace MR::McpGateway; + + Config cfg; + if ( !parseArgs( argc, argv, cfg ) ) + return 1; + cfg.launchCommand = resolveLaunchCommand( cfg.launchCommand ); + + // Prime the on-disk tool cache (synchronous; ~3-5 s when actually priming) and + // load the resulting JSON into memory. Failures are non-fatal: we proceed with + // an empty cache and only the local `launch`/`status` tools will be visible + // until the backend actually launches. + ensureFreshCache( cfg ); + loadCachedTools( cfg ); + + // One persistent transport for the gateway's lifetime. Holds the SSE session + // (auto-reconnects on backend restart) and serves every forwarded request via + // a plain POST that reads the JSON-RPC response from the POST body. Sidesteps + // fastmcpp's per-call SseClientTransport whose destructor blocks ~15 s/call + // joining its listener thread. + auto transport = std::make_unique( + cfg.targetUrl, cfg.ssePath, cfg.messagesPath ); + fastmcpp::client::Client templateClient( std::move( transport ) ); + + fastmcpp::ProxyApp proxy( + // Each forwarded call clones the template Client, sharing the same + // shared_ptr internally. No new connections, no thread spawn. + [&templateClient]() { return templateClient.new_client(); }, + std::string( "MRMCPGateway" ), + std::string( "0.1" ) + ); + + registerLocalTools( proxy, cfg ); + + auto inner = fastmcpp::mcp::make_mcp_handler( proxy ); + // We hand-craft the `initialize` response for two reasons: + // 1. Advertise `tools.listChanged: true` so MCP clients honour our list-changed + // notifications (fastmcpp's default initialize sets `"tools": {}` empty). + // 2. Avoid fastmcpp's initialize handler calling `proxy.list_all_resources/templates/prompts`, + // which each invoke our client factory and probe the backend — making `initialize` slow + // enough to trip `claude mcp list`'s health-check timeout when the backend is offline. + auto handler = [inner]( const fastmcpp::Json& req ) -> fastmcpp::Json + { + const std::string method = req.is_object() ? req.value( "method", std::string{} ) : std::string{}; + if ( method == "initialize" ) + { + const auto id = req.contains( "id" ) ? req.at( "id" ) : fastmcpp::Json(); + return fastmcpp::Json{ + { "jsonrpc", "2.0" }, + { "id", id }, + { "result", { + { "protocolVersion", "2024-11-05" }, + { "capabilities", { { "tools", { { "listChanged", true } } } } }, + { "serverInfo", { { "name", "MRMCPGateway" }, { "version", "0.1" } } }, + } }, + }; + } + + fastmcpp::Json resp = inner( req ); + + // When the backend is offline, fastmcpp's proxy returns only our local tools + // (`launch`, `status`). Splice in the cached schema array so the MCP client + // still sees the full proxied surface and can decide which tools to call. + const auto& cachedTools = getCachedTools(); + if ( method == "tools/list" && !getBackendAlive().load() && !cachedTools.empty() + && resp.is_object() && resp.contains( "result" ) && resp["result"].contains( "tools" ) + && resp["result"]["tools"].is_array() ) + { + auto& tools = resp["result"]["tools"]; + std::set seen; + for ( const auto& t : tools ) + if ( t.is_object() && t.contains( "name" ) && t["name"].is_string() ) + seen.insert( t["name"].get() ); + for ( const auto& cached : cachedTools ) + if ( cached.contains( "name" ) && cached["name"].is_string() + && !seen.count( cached["name"].get() ) ) + tools.push_back( cached ); + } + return resp; + }; + fastmcpp::server::StdioServerWrapper server( handler ); + return server.run() ? 0 : 1; +} diff --git a/source/MRMCPGateway/MRMCPGateway.vcxproj b/source/MRMCPGateway/MRMCPGateway.vcxproj new file mode 100644 index 000000000000..f26c92dfd7d1 --- /dev/null +++ b/source/MRMCPGateway/MRMCPGateway.vcxproj @@ -0,0 +1,106 @@ + + + + + Debug + x64 + + + Release + x64 + + + + + + + + + + + + + + + + + + + {7853aec9-a364-4587-89ae-faa9a463e6ed} + + + + 15.0 + {72A99975-E95E-40E1-9BCC-516018381BA8} + Win32Proj + MRMCPGateway + + + + Application + true + Unicode + + + Application + false + false + Unicode + + + + + + + + + + + + + + + + + + NotUsing + EnableAllWarnings + Disabled + true + _DEBUG;_CONSOLE;%(PreprocessorDefinitions) + true + true + %(AdditionalIncludeDirectories);..\..\thirdparty\fastmcpp\include;..\..\thirdparty\cpp-httplib;..\..\thirdparty\nlohmann-json\include + 4099;4100;4242;4244;4355;4456;4458;4464;4505;4702;5204;5220;5233;5245;5267;%(DisableSpecificWarnings) + + + Console + true + + + + + NotUsing + EnableAllWarnings + MaxSpeed + true + true + true + NDEBUG;_CONSOLE;%(PreprocessorDefinitions) + true + true + /bigobj %(AdditionalOptions) + %(AdditionalIncludeDirectories);..\..\thirdparty\fastmcpp\include;..\..\thirdparty\cpp-httplib;..\..\thirdparty\nlohmann-json\include + 4099;4100;4242;4244;4355;4456;4458;4464;4505;4702;5204;5220;5233;5245;5267;%(DisableSpecificWarnings) + + + Console + true + true + true + + + + + + diff --git a/source/MRMCPGateway/MRMCPGateway.vcxproj.filters b/source/MRMCPGateway/MRMCPGateway.vcxproj.filters new file mode 100644 index 000000000000..90a375c0d4fc --- /dev/null +++ b/source/MRMCPGateway/MRMCPGateway.vcxproj.filters @@ -0,0 +1,47 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hh;hpp;hxx;hm;inl;inc;ipp;xsd + + + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + diff --git a/source/MRMCPGateway/MRMCPGatewayBackend.cpp b/source/MRMCPGateway/MRMCPGatewayBackend.cpp new file mode 100644 index 000000000000..5d3bac39b82b --- /dev/null +++ b/source/MRMCPGateway/MRMCPGatewayBackend.cpp @@ -0,0 +1,152 @@ +#include "MRMCPGatewayBackend.h" + +#include "MRMCPGatewayConfig.h" +#include "MRMCPGatewaySpawn.h" + +#include +#include + +#include +#include +#include + +namespace MR::McpGateway +{ + +std::atomic& getBackendAlive() +{ + static std::atomic instance{ false }; + return instance; +} + +namespace +{ + +// Suppresses the very first probe's transition emit, so we don't spuriously fire +// `list_changed` during the client's initial `tools/list` (whose response already +// carries the right tool set anyway). +std::atomic& backendPrimed() +{ + static std::atomic instance{ false }; + return instance; +} + +void emitToolsListChanged() +{ + const nlohmann::json notif = { + { "jsonrpc", "2.0" }, + { "method", "notifications/tools/list_changed" }, + { "params", nlohmann::json::object() }, + }; + std::cout << notif.dump() << std::endl; + std::cout.flush(); +} + +nlohmann::json emptyObjectSchema() +{ + return { + { "type", "object" }, + { "properties", nlohmann::json::object() }, + { "required", nlohmann::json::array() }, + }; +} + +nlohmann::json stringSchema() +{ + return { { "type", "string" } }; +} + +nlohmann::json launchInputSchema() +{ + return { + { "type", "object" }, + { "properties", { + { "args", { + { "type", "array" }, + { "items", { { "type", "string" } } }, + { "description", "Override the args forwarded to the backend for this call. " + "Defaults to the gateway's --launch-arg values." }, + } }, + } }, + { "required", nlohmann::json::array() }, + }; +} + +} // anonymous namespace + +bool probeBackendAlive( const std::string& targetUrl ) +{ + httplib::Client cli( targetUrl ); + cli.set_connection_timeout( std::chrono::milliseconds( 500 ) ); + cli.set_read_timeout( std::chrono::milliseconds( 500 ) ); + auto res = cli.Get( "/__mrmcpgateway_probe" ); + return static_cast( res ); +} + +bool probeAndTrackBackend( const std::string& targetUrl ) +{ + const bool nowAlive = probeBackendAlive( targetUrl ); + const bool wasAlive = getBackendAlive().exchange( nowAlive ); + const bool wasPrimed = backendPrimed().exchange( true ); + if ( wasPrimed && wasAlive != nowAlive ) + emitToolsListChanged(); + return nowAlive; +} + +void registerLocalTools( fastmcpp::ProxyApp& proxy, const Config& cfg ) +{ + proxy.local_tools().register_tool( fastmcpp::tools::Tool( + std::string( "launch" ), + launchInputSchema(), + stringSchema(), + [cfg]( const fastmcpp::Json& input ) -> fastmcpp::Json + { + std::vector args = cfg.launchArgs; + if ( input.is_object() ) + { + if ( auto it = input.find( "args" ); it != input.end() && it->is_array() ) + { + args.clear(); + for ( const auto& a : *it ) + if ( a.is_string() ) + args.push_back( a.get() ); + } + } + if ( probeAndTrackBackend( cfg.targetUrl ) ) + return std::string( "already running" ); + if ( !spawnDetached( cfg.launchCommand, args ) ) + return std::string( "failed: spawn error" ); + const auto deadline = std::chrono::steady_clock::now() + cfg.launchTimeout; + while ( std::chrono::steady_clock::now() < deadline ) + { + if ( probeAndTrackBackend( cfg.targetUrl ) ) + return std::string( "started" ); + std::this_thread::sleep_for( std::chrono::milliseconds( 250 ) ); + } + return std::string( "timeout: backend not ready within " ) + + std::to_string( cfg.launchTimeout.count() ) + "s"; + }, + std::optional( "Launch backend" ), + std::optional( "Start the MCP backend application that this gateway proxies to. " + "Optionally pass 'args' to override the gateway's --launch-arg defaults for this call (the executable path itself is fixed by the gateway operator). " + "Returns 'started', 'already running', 'failed: ', or 'timeout: '." ), + std::nullopt, + std::vector{} + ) ); + + proxy.local_tools().register_tool( fastmcpp::tools::Tool( + std::string( "status" ), + emptyObjectSchema(), + stringSchema(), + [cfg]( const fastmcpp::Json& ) -> fastmcpp::Json + { + return std::string( probeAndTrackBackend( cfg.targetUrl ) ? "running" : "not started" ); + }, + std::optional( "Backend status" ), + std::optional( "Returns 'running' if the backend MCP server is reachable, 'not started' otherwise." ), + std::nullopt, + std::vector{} + ) ); +} + +} // namespace MR::McpGateway diff --git a/source/MRMCPGateway/MRMCPGatewayBackend.h b/source/MRMCPGateway/MRMCPGatewayBackend.h new file mode 100644 index 000000000000..4983cc8ee3ba --- /dev/null +++ b/source/MRMCPGateway/MRMCPGatewayBackend.h @@ -0,0 +1,29 @@ +#pragma once + +#include "MRMcp/MRFastmcpp.h" + +#include +#include + +namespace MR::McpGateway +{ + +struct Config; + +/// Single source of truth for "is the backend currently alive?". Updated by +/// `probeAndTrackBackend` and by the persistent transport's connection callbacks. +std::atomic& getBackendAlive(); + +/// Raw HTTP liveness probe. No state updates, no transition events. Used at +/// startup (before any client is connected, so transitions would be spurious). +bool probeBackendAlive( const std::string& targetUrl ); + +/// Probes the backend, updates `getBackendAlive()`, and on a state transition +/// synchronously emits `notifications/tools/list_changed` so the connected MCP +/// client surfaces / drops the proxied tool set without polling. +bool probeAndTrackBackend( const std::string& targetUrl ); + +/// Registers the gateway-local `launch` and `status` tools on @p proxy. +void registerLocalTools( fastmcpp::ProxyApp& proxy, const Config& cfg ); + +} // namespace MR::McpGateway diff --git a/source/MRMCPGateway/MRMCPGatewayCache.cpp b/source/MRMCPGateway/MRMCPGatewayCache.cpp new file mode 100644 index 000000000000..e00c080649f0 --- /dev/null +++ b/source/MRMCPGateway/MRMCPGatewayCache.cpp @@ -0,0 +1,190 @@ +#include "MRMCPGatewayCache.h" +#include "MRMCPGatewayBackend.h" +#include "MRMCPGatewayConfig.h" +#include "MRMCPGatewaySpawn.h" + +#include +#include +#include +#include + +#ifndef _WIN32 +#include +#include +#endif + +namespace MR::McpGateway +{ + +namespace +{ + +// Compile-time build stamp baked into this translation unit. Written to the +// cache file's `stamp` field after each successful prime; compared on startup +// to decide whether the existing cache is still good. To force a re-prime, +// touch this file (or just delete the cache dir). +constexpr const char* kBuildStamp = __TIMESTAMP__; + +// PASTED from MR::getUserConfigDir() in MeshLib/source/MRMesh/MRSystem.cpp. +// Adapted to: hardcode the leaf folder (no Config::instance() dep) and use +// std::cerr instead of spdlog so the gateway keeps its zero-MRMesh, +// zero-spdlog dependency footprint. +std::filesystem::path gatewayUserConfigDir() +{ +#if defined( _WIN32 ) + std::filesystem::path filepath( _wgetenv( L"APPDATA" ) ); +#else +#if defined( __EMSCRIPTEN__ ) + std::filesystem::path filepath( "/" ); +#else + std::filesystem::path filepath; + if ( const auto* pw = getpwuid( getuid() ) ) + filepath = pw->pw_dir; + else if ( const char* h = std::getenv( "HOME" ) ) + filepath = h; +#endif + filepath /= ".local"; + filepath /= "share"; +#endif + filepath /= "MRMCPGateway"; + std::error_code ec; + if ( !std::filesystem::is_directory( filepath, ec ) || ec ) + { + std::filesystem::create_directories( filepath, ec ); + if ( ec ) + std::cerr << "MRMCPGateway: cannot create " << filepath.string() + << ": " << ec.message() << "\n"; + } + return filepath; +} + +std::filesystem::path cacheDir( const Config& cfg ) +{ + auto d = gatewayUserConfigDir(); + if ( !cfg.toolsCacheNamespace.empty() ) + d /= cfg.toolsCacheNamespace; + return d; +} + +std::filesystem::path cachePath( const Config& cfg ) { return cacheDir( cfg ) / "mcp_tools_cache.json"; } + +// Reads the cache file and returns true iff its `stamp` field equals kBuildStamp. +bool cacheStampMatches( const std::filesystem::path& cache ) +{ + std::ifstream f( cache ); + if ( !f ) + return false; + nlohmann::json doc; + try { f >> doc; } catch ( ... ) { return false; } + if ( !doc.is_object() || !doc.contains( "stamp" ) || !doc["stamp"].is_string() ) + return false; + return doc["stamp"].get() == kBuildStamp; +} + +// Reads the cache, sets its `stamp` field to kBuildStamp, atomically writes it back. +bool embedStampInCache( const std::filesystem::path& cache ) +{ + std::ifstream in( cache ); + if ( !in ) + return false; + nlohmann::json doc; + try { in >> doc; } catch ( ... ) { return false; } + in.close(); + if ( !doc.is_object() ) + return false; + doc["stamp"] = kBuildStamp; + + auto tmp = cache; + tmp += ".tmp"; + { + std::ofstream out( tmp ); + if ( !out ) + return false; + out << doc.dump( 2 ); + } + std::error_code ec; + std::filesystem::rename( tmp, cache, ec ); + if ( ec ) + { + std::filesystem::remove( tmp ); + return false; + } + return true; +} + +} // anonymous namespace + +std::vector& getCachedTools() +{ + static std::vector instance; + return instance; +} + +void ensureFreshCache( const Config& cfg ) +{ + const auto cache = cachePath( cfg ); + + if ( cacheStampMatches( cache ) ) + return; // already fresh + + // Backend already running → live tools/list will be authoritative this + // session, skip the prime (and avoid port collision). + if ( probeBackendAlive( cfg.targetUrl ) ) + return; + + if ( cfg.launchCommand.empty() ) + return; + + std::error_code ec; + const auto mtimeBefore = std::filesystem::exists( cache, ec ) + ? std::filesystem::last_write_time( cache, ec ) + : std::filesystem::file_time_type{}; + + std::vector primeArgs = cfg.launchArgs; + for ( const char* flag : { "-hidden", "-noEventLoop", "-noTelemetry", "-noSplash" } ) + primeArgs.emplace_back( flag ); + primeArgs.emplace_back( "-mcpDumpFile" ); + primeArgs.emplace_back( cache.string() ); + + // Synchronous spawn: blocks until the backend exits (or times out). Avoids + // the polling-sees-stale-cache race that file-existence polling has when an + // older cache file is already on disk. + if ( !spawnAndWait( cfg.launchCommand, primeArgs, cfg.launchTimeout ) ) + { + std::cerr << "MRMCPGateway: prime spawn did not complete cleanly\n"; + return; + } + + // Confirm the backend actually rewrote the cache file before stamping it. + if ( !std::filesystem::exists( cache, ec ) ) + { + std::cerr << "MRMCPGateway: backend exited without writing cache file\n"; + return; + } + const auto mtimeAfter = std::filesystem::last_write_time( cache, ec ); + if ( mtimeAfter <= mtimeBefore ) + { + std::cerr << "MRMCPGateway: cache file unchanged after prime; not stamping\n"; + return; + } + embedStampInCache( cache ); +} + +void loadCachedTools( const Config& cfg ) +{ + auto& cachedTools = getCachedTools(); + cachedTools.clear(); + const auto cache = cachePath( cfg ); + std::ifstream f( cache ); + if ( !f ) + return; + nlohmann::json doc; + try { f >> doc; } catch ( ... ) { return; } + if ( !doc.is_object() || !doc.contains( "tools" ) || !doc["tools"].is_array() ) + return; + for ( const auto& t : doc["tools"] ) + if ( t.is_object() ) + cachedTools.push_back( t ); +} + +} // namespace MR::McpGateway diff --git a/source/MRMCPGateway/MRMCPGatewayCache.h b/source/MRMCPGateway/MRMCPGatewayCache.h new file mode 100644 index 000000000000..f7fcc746b1d9 --- /dev/null +++ b/source/MRMCPGateway/MRMCPGatewayCache.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include + +namespace MR::McpGateway +{ + +struct Config; + +/// Tool-schema entries loaded from the on-disk cache at startup. Spliced into +/// `tools/list` responses by the gateway's handler when the backend is offline. +std::vector& getCachedTools(); + +/// If the cache file is missing or its build stamp doesn't match this binary, +/// and the backend isn't already running, spawn the backend hidden so it dumps +/// its tool schemas, then embed the build stamp into the just-written file. +/// Best-effort — failures are logged to stderr but non-fatal. +void ensureFreshCache( const Config& cfg ); + +/// Parses `mcp_tools_cache.json` and populates `getCachedTools()`. Errors are +/// non-fatal (logged + leaves the cache empty). +void loadCachedTools( const Config& cfg ); + +} // namespace MR::McpGateway diff --git a/source/MRMCPGateway/MRMCPGatewayConfig.h b/source/MRMCPGateway/MRMCPGatewayConfig.h new file mode 100644 index 000000000000..896a7a8ddcc3 --- /dev/null +++ b/source/MRMCPGateway/MRMCPGatewayConfig.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include +#include + +namespace MR::McpGateway +{ + +/// Aggregates all values parsed from the gateway's command-line. Passed by const-ref +/// to the cache, backend-probe, and local-tool-registration helpers — letting them +/// read what they need without each replicating its own subset of CLI parsing. +struct Config +{ + std::string targetUrl = "http://127.0.0.1:7887"; + std::string ssePath = "/sse"; + std::string messagesPath = "/messages"; + std::filesystem::path launchCommand; + std::vector launchArgs; + std::chrono::seconds launchTimeout{ 30 }; + std::string toolsCacheNamespace; ///< --tools-cache-namespace (optional sub-folder) +}; + +} // namespace MR::McpGateway diff --git a/source/MRMCPGateway/MRMCPGatewayMlTransport.cpp b/source/MRMCPGateway/MRMCPGatewayMlTransport.cpp new file mode 100644 index 000000000000..219b85a37f15 --- /dev/null +++ b/source/MRMCPGateway/MRMCPGatewayMlTransport.cpp @@ -0,0 +1,176 @@ +#include "MRMCPGatewayMlTransport.h" +#include "MRMCPGatewayBackend.h" + +#include +#include + +namespace MR::McpGateway +{ + +namespace +{ + +constexpr auto kSseConnectTimeout = std::chrono::seconds( 10 ); +constexpr auto kSseReadTimeout = std::chrono::seconds( 300 ); +constexpr auto kPostConnectTimeout = std::chrono::seconds( 5 ); +constexpr auto kPostReadTimeout = std::chrono::seconds( 30 ); +constexpr int kReconnectIntervalMs = 1000; +// Briefly wait for the SSE listener to capture (or recapture) a session_id +// before failing a request. Covers the gap between gateway startup / `launch` +// completing / backend restarting and the next SSE reconnect arriving. +constexpr auto kSessionWaitTimeout = std::chrono::seconds( 3 ); + +} // anonymous namespace + +// Minimal "http://host:port" URL parser. Only called on `cfg.targetUrl`, so +// we don't need a full URL grammar. +MLClientTransport::HostPort MLClientTransport::parseHostPort( const std::string& url ) +{ + HostPort out; + std::string rest = url; + if ( rest.rfind( "http://", 0 ) == 0 ) + rest = rest.substr( 7 ); + else if ( rest.rfind( "https://", 0 ) == 0 ) + rest = rest.substr( 8 ); + + auto colon = rest.find( ':' ); + auto slash = rest.find( '/' ); + if ( slash == std::string::npos ) + slash = rest.size(); + + if ( colon != std::string::npos && colon < slash ) + { + out.host = rest.substr( 0, colon ); + try { out.port = std::stoi( rest.substr( colon + 1, slash - colon - 1 ) ); } + catch ( ... ) { out.port = 80; } + } + else + { + out.host = rest.substr( 0, slash ); + } + return out; +} + +MLClientTransport::MLClientTransport( const std::string& targetUrl, + const std::string& ssePath, + const std::string& messagesPath ) + : MLClientTransport( parseHostPort( targetUrl ), ssePath, messagesPath ) +{} + +MLClientTransport::MLClientTransport( const HostPort& hp, + const std::string& ssePath, + const std::string& messagesPath ) + : httpForSse_( hp.host.c_str(), hp.port ) + , sse_( httpForSse_, ssePath ) + , httpForPost_( hp.host.c_str(), hp.port ) + , messagesPath_( messagesPath ) +{ + httpForSse_.set_connection_timeout( static_cast( kSseConnectTimeout.count() ), 0 ); + httpForSse_.set_read_timeout( static_cast( kSseReadTimeout.count() ), 0 ); + httpForSse_.set_keep_alive( true ); + + httpForPost_.set_connection_timeout( static_cast( kPostConnectTimeout.count() ), 0 ); + httpForPost_.set_read_timeout( static_cast( kPostReadTimeout.count() ), 0 ); + httpForPost_.set_keep_alive( true ); + + sse_.on_event( "endpoint", [this]( const httplib::sse::SSEMessage& msg ) + { + // msg.data == "/messages?session_id=" + const std::string key = "session_id="; + auto pos = msg.data.find( key ); + if ( pos == std::string::npos ) + return; + const auto sidStart = pos + key.size(); + const auto sidEnd = msg.data.find_first_of( "&#", sidStart ); + std::string sid = msg.data.substr( sidStart, + ( sidEnd == std::string::npos ) ? std::string::npos : ( sidEnd - sidStart ) ); + // Publish backend-alive first, then the session_id, then notify. Any + // thread woken by the cv sees the up-to-date alive state immediately. + getBackendAlive().store( true ); + { + std::lock_guard lk( sessionMutex_ ); + sessionId_ = std::move( sid ); + } + sessionCv_.notify_all(); + } ); + + sse_.on_error( [this]( httplib::Error /*err*/ ) + { + // Same publish-before-notify discipline: dead state visible before any + // waiter wakes up. Wipe sessionId_ so subsequent request() blocks on the + // condition variable until the SSEClient's auto-reconnect produces a + // fresh one — without this, after a backend restart we'd send the old + // session_id and the server would 404 it. + getBackendAlive().store( false ); + { + std::lock_guard lk( sessionMutex_ ); + sessionId_.clear(); + } + sessionCv_.notify_all(); + } ); + + sse_.set_reconnect_interval( kReconnectIntervalMs ); + sse_.set_max_reconnect_attempts( 0 ); // unlimited + sse_.start_async(); +} + +MLClientTransport::~MLClientTransport() +{ + sse_.stop(); // fast: cancels pending Get + joins +} + +fastmcpp::Json MLClientTransport::request( const std::string& route, const fastmcpp::Json& payload ) +{ + std::string sid; + { + // Briefly wait for the SSEClient to (re)acquire a session_id. On a freshly-started + // gateway with the backend already up, this returns immediately; on a post-`launch` + // first call or after a backend restart, it returns as soon as the SSE on_event fires. + std::unique_lock lk( sessionMutex_ ); + sessionCv_.wait_for( lk, kSessionWaitTimeout, [this]{ return !sessionId_.empty(); } ); + sid = sessionId_; + } + if ( sid.empty() ) + throw fastmcpp::TransportError( "backend not connected" ); + + fastmcpp::Json rpc = { + { "jsonrpc", "2.0" }, + { "id", nextId_++ }, + { "method", route }, + { "params", payload }, + }; + + auto res = httpForPost_.Post( + ( messagesPath_ + "?session_id=" + sid ).c_str(), + rpc.dump(), + "application/json" ); + if ( !res ) + throw fastmcpp::TransportError( + "POST failed: " + std::to_string( static_cast( res.error() ) ) ); + + if ( res->status == 404 ) + { + // Server-side session expired. Wipe ours; SSEClient is auto-reconnecting + // and the next endpoint event will repopulate sessionId_. + { + std::lock_guard lk( sessionMutex_ ); + sessionId_.clear(); + } + throw fastmcpp::TransportError( "session expired (server restarted?)" ); + } + if ( res->status < 200 || res->status >= 300 ) + throw fastmcpp::TransportError( "HTTP " + std::to_string( res->status ) ); + + fastmcpp::Json resp; + try { resp = fastmcpp::Json::parse( res->body ); } + catch ( const std::exception& e ) + { + throw fastmcpp::TransportError( std::string( "POST body not JSON: " ) + e.what() ); + } + + if ( resp.contains( "error" ) ) + throw fastmcpp::Error( resp["error"].value( "message", std::string( "unknown error" ) ) ); + return resp.value( "result", fastmcpp::Json::object() ); +} + +} // namespace MR::McpGateway diff --git a/source/MRMCPGateway/MRMCPGatewayMlTransport.h b/source/MRMCPGateway/MRMCPGatewayMlTransport.h new file mode 100644 index 000000000000..970639bac150 --- /dev/null +++ b/source/MRMCPGateway/MRMCPGatewayMlTransport.h @@ -0,0 +1,61 @@ +#pragma once + +#include "MRMcp/MRFastmcpp.h" + +#include + +#include +#include +#include +#include + +namespace MR::McpGateway +{ + +/// fastmcpp transport that talks to a MeshLib-based MCP server using one +/// persistent SSE session for liveness/session-id tracking and a plain POST +/// per request, reading the JSON-RPC response from the POST body. +/// +/// Sidesteps fastmcpp's per-call `SseClientTransport` pattern, whose destructor +/// blocks up to one heartbeat interval (~15 s) per call to join the listener +/// thread. Auto-reconnects on backend restart via `httplib::sse::SSEClient`. +class MLClientTransport final : public fastmcpp::client::ITransport +{ +public: + /// @param targetUrl e.g. "http://127.0.0.1:7887". Parsed once into host+port. + MLClientTransport( const std::string& targetUrl, + const std::string& ssePath, + const std::string& messagesPath ); + ~MLClientTransport() override; + + fastmcpp::Json request( const std::string& route, const fastmcpp::Json& payload ) override; + +private: + struct HostPort { std::string host; int port = 80; }; + static HostPort parseHostPort( const std::string& url ); + + /// Delegated-to constructor: takes the already-parsed host/port so the + /// public constructor can parse `targetUrl` exactly once. + MLClientTransport( const HostPort& hp, + const std::string& ssePath, + const std::string& messagesPath ); + +private: + // `httpForSse_` exists only as the backing `httplib::Client` that `sse_` + // holds by reference. We never call methods on it directly after the + // constructor configures its timeouts. We keep POSTs on a separate Client + // so (a) read timeouts can differ — SSE wants ~5 min long-poll, POST wants + // ~30 s — and (b) `sse_.stop()` (which internally calls `client_.stop()`) + // doesn't yank in-flight POSTs at gateway shutdown. + httplib::Client httpForSse_; + httplib::sse::SSEClient sse_; + httplib::Client httpForPost_; + + std::string messagesPath_; + mutable std::mutex sessionMutex_; + std::condition_variable sessionCv_; + std::string sessionId_; + std::atomic nextId_{ 1 }; +}; + +} // namespace MR::McpGateway diff --git a/source/MRMCPGateway/MRMCPGatewaySpawn.cpp b/source/MRMCPGateway/MRMCPGatewaySpawn.cpp new file mode 100644 index 000000000000..ba557e88e566 --- /dev/null +++ b/source/MRMCPGateway/MRMCPGatewaySpawn.cpp @@ -0,0 +1,230 @@ +#include "MRMCPGatewaySpawn.h" + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#else +#include +#include +#include +#include +#endif + +#include +#include +#include + +namespace MR::McpGateway +{ + +#ifdef _WIN32 + +namespace +{ + +std::wstring utf8ToWide( const std::string& s ) +{ + if ( s.empty() ) + return {}; + int n = MultiByteToWideChar( CP_UTF8, 0, s.data(), (int)s.size(), nullptr, 0 ); + std::wstring out( (size_t)n, L'\0' ); + MultiByteToWideChar( CP_UTF8, 0, s.data(), (int)s.size(), out.data(), n ); + return out; +} + +// Append @p arg to @p cmdLine, quoted/escaped per CommandLineToArgvW rules. +void appendQuotedArg( std::wstring& cmdLine, const std::wstring& arg ) +{ + if ( !arg.empty() && arg.find_first_of( L" \t\n\v\"" ) == std::wstring::npos ) + { + cmdLine.append( arg ); + return; + } + cmdLine.push_back( L'"' ); + for ( auto it = arg.begin();; ) + { + unsigned numBackslashes = 0; + while ( it != arg.end() && *it == L'\\' ) + { + ++it; + ++numBackslashes; + } + if ( it == arg.end() ) + { + cmdLine.append( numBackslashes * 2, L'\\' ); + break; + } + if ( *it == L'"' ) + { + cmdLine.append( numBackslashes * 2 + 1, L'\\' ); + cmdLine.push_back( *it ); + } + else + { + cmdLine.append( numBackslashes, L'\\' ); + cmdLine.push_back( *it ); + } + ++it; + } + cmdLine.push_back( L'"' ); +} + +// Build the full Windows command line from the executable path + arg list. +std::wstring buildCmdLine( const std::filesystem::path& exe, const std::vector& args ) +{ + std::wstring cmdLine; + appendQuotedArg( cmdLine, exe.wstring() ); + for ( const auto& a : args ) + { + cmdLine.push_back( L' ' ); + appendQuotedArg( cmdLine, utf8ToWide( a ) ); + } + return cmdLine; +} + +// Launch @p exe with @p cmdLine and the given creation flags. On success fills +// @p pi (caller must CloseHandle both handles); on failure logs and returns false. +bool createProcessRaw( const std::filesystem::path& exe, std::wstring& cmdLine, + DWORD creationFlags, PROCESS_INFORMATION& pi ) +{ + STARTUPINFOW si{}; + si.cb = sizeof( si ); + BOOL ok = CreateProcessW( + exe.c_str(), + cmdLine.data(), + nullptr, nullptr, + FALSE, + creationFlags, + nullptr, nullptr, + &si, &pi ); + if ( !ok ) + { + std::cerr << "MRMCPGateway: CreateProcess failed, error " << GetLastError() << "\n"; + return false; + } + return true; +} + +} // anonymous namespace + +bool spawnDetached( const std::filesystem::path& exe, const std::vector& args ) +{ + auto cmdLine = buildCmdLine( exe, args ); + PROCESS_INFORMATION pi{}; + if ( !createProcessRaw( exe, cmdLine, DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, pi ) ) + return false; + CloseHandle( pi.hProcess ); + CloseHandle( pi.hThread ); + return true; +} + +bool spawnAndWait( const std::filesystem::path& exe, const std::vector& args, + std::chrono::seconds timeout ) +{ + auto cmdLine = buildCmdLine( exe, args ); + PROCESS_INFORMATION pi{}; + if ( !createProcessRaw( exe, cmdLine, 0, pi ) ) + return false; + const DWORD waitMs = static_cast( + std::chrono::duration_cast( timeout ).count() ); + const DWORD wait = WaitForSingleObject( pi.hProcess, waitMs ); + bool finished = ( wait == WAIT_OBJECT_0 ); + if ( !finished ) + { + std::cerr << "MRMCPGateway: prime spawn timed out after " << timeout.count() << "s, killing\n"; + TerminateProcess( pi.hProcess, 1 ); + WaitForSingleObject( pi.hProcess, 1000 ); + } + CloseHandle( pi.hProcess ); + CloseHandle( pi.hThread ); + return finished; +} + +#else // POSIX + +namespace +{ + +// Fork + exec @p exe with @p args. If @p detach, the child detaches into its own +// session and double-forks before exec so the grandchild has no parent in our +// process tree (prevents zombies after our process exits). Returns the pid of +// the immediate child the caller should waitpid() on, or -1 on failure. +pid_t forkExec( const std::filesystem::path& exe, const std::vector& args, bool detach ) +{ + pid_t first = fork(); + if ( first < 0 ) + return -1; + if ( first == 0 ) + { + if ( detach ) + { + if ( setsid() < 0 ) + _exit( 127 ); + pid_t second = fork(); + if ( second < 0 ) + _exit( 127 ); + if ( second > 0 ) + _exit( 0 ); + // grandchild falls through to exec + } + std::string exeStr = exe.string(); + std::vector argsCopy = args; + std::vector argv; + argv.reserve( argsCopy.size() + 2 ); + argv.push_back( exeStr.data() ); + for ( auto& a : argsCopy ) + argv.push_back( a.data() ); + argv.push_back( nullptr ); + execvp( argv[0], argv.data() ); + _exit( 127 ); + } + return first; +} + +// Poll waitpid(WNOHANG) until @p pid exits or @p timeout elapses; on timeout +// SIGKILL the child and reap. Returns true iff the child exited cleanly within +// the timeout. +bool waitForProcessWithTimeout( pid_t pid, std::chrono::seconds timeout ) +{ + const auto deadline = std::chrono::steady_clock::now() + timeout; + while ( std::chrono::steady_clock::now() < deadline ) + { + int status = 0; + const pid_t r = waitpid( pid, &status, WNOHANG ); + if ( r == pid ) + return true; + if ( r < 0 ) + return false; + std::this_thread::sleep_for( std::chrono::milliseconds( 100 ) ); + } + std::cerr << "MRMCPGateway: prime spawn timed out, killing pid " << pid << "\n"; + kill( pid, SIGKILL ); + waitpid( pid, nullptr, 0 ); + return false; +} + +} // anonymous namespace + +bool spawnDetached( const std::filesystem::path& exe, const std::vector& args ) +{ + pid_t first = forkExec( exe, args, /*detach=*/true ); + if ( first < 0 ) + return false; + // Reap the first child (which exits immediately after the second fork). + int status = 0; + waitpid( first, &status, 0 ); + return WIFEXITED( status ) && WEXITSTATUS( status ) == 0; +} + +bool spawnAndWait( const std::filesystem::path& exe, const std::vector& args, + std::chrono::seconds timeout ) +{ + pid_t pid = forkExec( exe, args, /*detach=*/false ); + if ( pid < 0 ) + return false; + return waitForProcessWithTimeout( pid, timeout ); +} + +#endif + +} // namespace MR::McpGateway diff --git a/source/MRMCPGateway/MRMCPGatewaySpawn.h b/source/MRMCPGateway/MRMCPGatewaySpawn.h new file mode 100644 index 000000000000..674e41300af3 --- /dev/null +++ b/source/MRMCPGateway/MRMCPGatewaySpawn.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include +#include + +namespace MR::McpGateway +{ + +/// Spawns @p exe with @p args as a detached child process and returns immediately. +/// The child survives this process exiting (Windows: DETACHED_PROCESS; POSIX: setsid + double-fork). +/// Returns true if the child was launched successfully (does not verify it then ran to completion). +bool spawnDetached( const std::filesystem::path& exe, const std::vector& args ); + +/// Spawns @p exe with @p args as an attached child process and blocks until it exits +/// or @p timeout elapses. On timeout the child is force-killed. Returns true iff the +/// child ran to completion within the timeout (regardless of its exit code — caller +/// decides what counts as success based on side effects, e.g. a written cache file). +bool spawnAndWait( const std::filesystem::path& exe, const std::vector& args, + std::chrono::seconds timeout ); + +} // namespace MR::McpGateway diff --git a/source/MRMcp/MRFastmcpp.h b/source/MRMcp/MRFastmcpp.h new file mode 100644 index 000000000000..aff1e277a29c --- /dev/null +++ b/source/MRMcp/MRFastmcpp.h @@ -0,0 +1,30 @@ +#pragma once + +// Centralised fastmcpp wrapper. Both MRMcp.cpp and MRMCPGateway.cpp must pull +// fastmcpp in before any standard library header (fastmcpp's macro tricks rely +// on that ordering). Keep `#undef _t` next to the includes: fastmcpp uses `_t` +// as a template parameter and our translation macro shadows it. + +#undef _t + +#if defined( __GNUC__ ) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-parameter" +#elif defined( _MSC_VER ) +#pragma warning( push ) +#pragma warning( disable: 4100 ) // unreferenced formal parameter +#pragma warning( disable: 4355 ) // 'this': used in base member initializer list +#endif + +#include +#include +#include +#include +#include +#include + +#if defined( __GNUC__ ) +#pragma GCC diagnostic pop +#elif defined( _MSC_VER ) +#pragma warning( pop ) +#endif diff --git a/source/MRMcp/MRMcp.cpp b/source/MRMcp/MRMcp.cpp index 238d0669b1a8..522017797b4a 100644 --- a/source/MRMcp/MRMcp.cpp +++ b/source/MRMcp/MRMcp.cpp @@ -1,35 +1,16 @@ -// Must not include any standard headers - -#undef _t // Our translation macro interefers with Fastmcpp. - -#if defined( __GNUC__ ) -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wunused-parameter" -#elif defined( _MSC_VER ) -#pragma warning( push ) -#pragma warning( disable: 4100 ) // unreferenced formal parameter -#pragma warning( disable: 4355 ) // 'this': used in base member initializer list -#endif - -// This must be included before any standard library headers, because of the macro shenanigans we added to that header. -// Those are duplicated into our PCH, so that shouldn't interfere. -#include -#include - -#if defined( __GNUC__ ) -#pragma GCC diagnostic pop -#elif defined( _MSC_VER ) -#pragma warning( pop ) -#endif +// Must not include any standard headers (fastmcpp's macro shenanigans rely on it). +#include "MRMcp/MRFastmcpp.h" #include "MRMcp.h" +#include "MRMesh/MRStringConvert.h" #include "MRMesh/MRSystem.h" #include "MRPch/MRSpdlog.h" #include "MRViewer/MRCommandLoop.h" #include "MRViewer/MRUITestEngineControl.h" #include "MRViewer/MRViewer.h" +#include #include @@ -186,6 +167,95 @@ bool Server::setRunning( bool enable ) } } +nlohmann::json Server::dumpToolsAsJson() const +{ + auto out = nlohmann::json::array(); + if ( !state_ ) + return out; + for ( const auto& name : state_->toolManager.list_names() ) + { + const auto& tool = state_->toolManager.get( name ); + nlohmann::json entry = { + { "name", tool.name() }, + { "inputSchema", tool.input_schema() }, + }; + if ( tool.title().has_value() ) + entry["title"] = *tool.title(); + if ( tool.description().has_value() ) + entry["description"] = *tool.description(); + else if ( auto it = state_->toolDescs.find( name ); it != state_->toolDescs.end() ) + entry["description"] = it->second; + const auto& outSchema = tool.output_schema(); + if ( !outSchema.is_null() && !( outSchema.is_object() && outSchema.empty() ) ) + { + // MCP requires `outputSchema.type == "object"`. fastmcpp's mcp/handler.cpp + // wraps non-object schemas at runtime; mirror the same shape here so + // cached entries match what the live server emits in `tools/list`. + const bool alreadyObject = outSchema.is_object() + && outSchema.contains( "type" ) && outSchema.at( "type" ) == "object"; + if ( alreadyObject ) + entry["outputSchema"] = outSchema; + else + entry["outputSchema"] = { + { "type", "object" }, + { "properties", { { "result", outSchema } } }, + { "required", nlohmann::json::array( { "result" } ) }, + { "x-fastmcp-wrap-result", true }, + }; + } + out.push_back( std::move( entry ) ); + } + return out; +} + +Expected Server::saveToolsCache( const std::filesystem::path& path ) const +{ + nlohmann::json envelope = { { "tools", dumpToolsAsJson() } }; + + std::error_code ec; + if ( !path.parent_path().empty() ) + { + std::filesystem::create_directories( path.parent_path(), ec ); + if ( ec ) + return unexpected( fmt::format( "cannot create directory {}: {}", + utf8string( path.parent_path() ), ec.message() ) ); + } + + // Write to a sibling .tmp first then rename: this is atomic on the filesystem, + // so a concurrent reader (e.g. the gateway polling for the cache) never sees a + // partial write or an empty file mid-flush. + auto tmp = path; + tmp += ".tmp"; + { + std::ofstream f( tmp ); + if ( !f ) + return unexpected( fmt::format( "cannot open {} for writing", utf8string( tmp ) ) ); + f << envelope.dump( 2 ); + } + std::filesystem::rename( tmp, path, ec ); + if ( ec ) + { + std::filesystem::remove( tmp ); + return unexpected( fmt::format( "cannot rename {} -> {}: {}", + utf8string( tmp ), utf8string( path ), ec.message() ) ); + } + spdlog::info( "MRMcp: dumped {} tools to {}", state_ ? state_->toolManager.list_names().size() : 0u, utf8string( path ) ); + return {}; +} + +void Server::processCmdArgs( const std::vector& commandArgs ) const +{ + for ( size_t i = 0; i + 1 < commandArgs.size(); ++i ) + { + if ( commandArgs[i] == "-mcpDumpFile" ) + { + const std::filesystem::path target = commandArgs[i + 1]; + if ( auto res = saveToolsCache( target ); !res ) + spdlog::error( "MRMcp: {}", res.error() ); + return; + } + } +} void Server::setToolValidator( ToolValidator validator ) { if ( !state_ ) diff --git a/source/MRMcp/MRMcp.h b/source/MRMcp/MRMcp.h index df5b03475a1f..6c69b985cec4 100644 --- a/source/MRMcp/MRMcp.h +++ b/source/MRMcp/MRMcp.h @@ -6,8 +6,11 @@ #include +#include #include +#include #include +#include namespace MR::Mcp { @@ -161,6 +164,20 @@ class Server /// Stopping always returns true. MRMCP_API bool setRunning( bool enable ); + /// Returns the list of currently-registered tools as a JSON array of MCP `tool` entries + /// (`name`, optional `title`/`description`, `inputSchema`, `outputSchema`). + /// Suitable for splicing into a `tools/list` response or persisting to a cache file. + [[nodiscard]] MRMCP_API nlohmann::json dumpToolsAsJson() const; + + /// Atomically writes `{ "tools": dumpToolsAsJson() }` to @p path, creating parent + /// directories as needed. Returns an error message on I/O failure. + MRMCP_API Expected saveToolsCache( const std::filesystem::path& path ) const; + + /// Processes MCP-related command-line arguments. Currently only `-mcpDumpFile `, + /// which writes the tool cache to that path. Otherwise a no-op. Intended to be called + /// once during MCP setup with the viewer's own launch arguments, after every + /// `MR_ON_INIT` tool registration has run. + MRMCP_API void processCmdArgs( const std::vector& commandArgs ) const; /// Optional predicate consulted before every tool dispatch, given the tool's id. /// Return {} to allow; return `unexpected("reason")` to block — the reason surfaces /// to the MCP client as the tool-call error. diff --git a/source/MRMcp/MRMcp.vcxproj b/source/MRMcp/MRMcp.vcxproj index f612bac12ec6..160c45f55dbd 100644 --- a/source/MRMcp/MRMcp.vcxproj +++ b/source/MRMcp/MRMcp.vcxproj @@ -12,6 +12,7 @@ + diff --git a/source/MRViewer/MRSetupViewer.cpp b/source/MRViewer/MRSetupViewer.cpp index 94dec311540d..8ee33aac5f0a 100644 --- a/source/MRViewer/MRSetupViewer.cpp +++ b/source/MRViewer/MRSetupViewer.cpp @@ -197,6 +197,7 @@ bool ViewerSetup::setupMcp() const { #ifndef MESHLIB_NO_MCP Mcp::getDefaultServer().setRunning( true ); + Mcp::getDefaultServer().processCmdArgs( getViewerInstance().commandArgs ); return true; #else return false; diff --git a/source/MeshLib.sln b/source/MeshLib.sln index 967ecf9668d2..ca5dbeaa778a 100644 --- a/source/MeshLib.sln +++ b/source/MeshLib.sln @@ -66,6 +66,8 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "fastmcpp", "fastmcpp\fastmc EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "MRMcp", "MRMcp\MRMcp.vcxproj", "{C8250F26-E01D-4A63-98CD-68069D818080}" EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "MRMCPGateway", "MRMCPGateway\MRMCPGateway.vcxproj", "{72A99975-E95E-40E1-9BCC-516018381BA8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 @@ -164,6 +166,10 @@ Global {C8250F26-E01D-4A63-98CD-68069D818080}.Debug|x64.Build.0 = Debug|x64 {C8250F26-E01D-4A63-98CD-68069D818080}.Release|x64.ActiveCfg = Release|x64 {C8250F26-E01D-4A63-98CD-68069D818080}.Release|x64.Build.0 = Release|x64 + {72A99975-E95E-40E1-9BCC-516018381BA8}.Debug|x64.ActiveCfg = Debug|x64 + {72A99975-E95E-40E1-9BCC-516018381BA8}.Debug|x64.Build.0 = Debug|x64 + {72A99975-E95E-40E1-9BCC-516018381BA8}.Release|x64.ActiveCfg = Release|x64 + {72A99975-E95E-40E1-9BCC-516018381BA8}.Release|x64.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -192,6 +198,7 @@ Global {FFB8D063-FF1E-4F18-8479-249B36714EF7} = {E0BE85ED-C366-40EF-8BDE-70E1EDC8860F} {7853AEC9-A364-4587-89AE-FAA9A463E6ED} = {AE8B4895-7920-4AD3-B554-C858A08B1680} {C8250F26-E01D-4A63-98CD-68069D818080} = {AE8B4895-7920-4AD3-B554-C858A08B1680} + {72A99975-E95E-40E1-9BCC-516018381BA8} = {E0BE85ED-C366-40EF-8BDE-70E1EDC8860F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6F7912D7-5687-4CBB-828B-1BEDD18B8249}