diff --git a/Packages/OsaurusCore/Managers/MCPProviderManager.swift b/Packages/OsaurusCore/Managers/MCPProviderManager.swift index 1cdc7bd5..0c90c71a 100644 --- a/Packages/OsaurusCore/Managers/MCPProviderManager.swift +++ b/Packages/OsaurusCore/Managers/MCPProviderManager.swift @@ -323,9 +323,7 @@ public final class MCPProviderManager: ObservableObject { /// Test connection to a provider without persisting public func testConnection(url: String, token: String?, headers: [String: String]) async throws -> Int { - guard let endpoint = URL(string: url) else { - throw MCPProviderError.invalidURL - } + let endpoint = try Self.validatedHTTPSEndpoint(from: url) // Create temporary transport let configuration = URLSessionConfiguration.default @@ -361,10 +359,22 @@ public final class MCPProviderManager: ObservableObject { // MARK: - Private Helpers - private func createTransport(for provider: MCPProvider) throws -> HTTPClientTransport { - guard let endpoint = URL(string: provider.url) else { - throw MCPProviderError.invalidURL + nonisolated public static func validatedHTTPSEndpoint(from url: String) throws -> URL { + let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) + guard + let endpoint = URL(string: trimmedURL), + let scheme = endpoint.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let host = endpoint.host, + !host.isEmpty + else { + throw MCPProviderError.unsupportedTransport } + return endpoint + } + + private func createTransport(for provider: MCPProvider) throws -> HTTPClientTransport { + let endpoint = try Self.validatedHTTPSEndpoint(from: provider.url) let urlConfig = URLSessionConfiguration.default @@ -454,6 +464,7 @@ public enum MCPProviderError: LocalizedError { case providerDisabled case notConnected case invalidURL + case unsupportedTransport case timeout case toolExecutionFailed(String) case connectionFailed(String) @@ -468,6 +479,8 @@ public enum MCPProviderError: LocalizedError { return "Not connected to provider" case .invalidURL: return "Invalid server URL" + case .unsupportedTransport: + return "Remote MCP providers support HTTP/SSE endpoints only. Command-based stdio servers are not supported yet." case .timeout: return "Request timed out" case .toolExecutionFailed(let message): diff --git a/Packages/OsaurusCore/Tests/MCP/MCPProviderEndpointValidationTests.swift b/Packages/OsaurusCore/Tests/MCP/MCPProviderEndpointValidationTests.swift new file mode 100644 index 00000000..4f1fee0e --- /dev/null +++ b/Packages/OsaurusCore/Tests/MCP/MCPProviderEndpointValidationTests.swift @@ -0,0 +1,44 @@ +// +// MCPProviderEndpointValidationTests.swift +// osaurusTests +// +// Pins the supported transport for remote MCP providers so command-based +// stdio provider strings fail with a clear message instead of looking like +// malformed HTTP endpoints. +// + +import Foundation +import Testing + +@testable import OsaurusCore + +struct MCPProviderEndpointValidationTests { + @Test func httpEndpointIsAccepted() throws { + let url = try MCPProviderManager.validatedHTTPSEndpoint(from: "http://127.0.0.1:3000/mcp") + #expect(url.absoluteString == "http://127.0.0.1:3000/mcp") + } + + @Test func httpsSSEEndpointIsAccepted() throws { + let url = try MCPProviderManager.validatedHTTPSEndpoint(from: " https://mcp.example.com/sse ") + #expect(url.absoluteString == "https://mcp.example.com/sse") + } + + @Test func stdioCommandIsRejectedWithTransportMessage() throws { + expectUnsupportedTransport(from: "python -m some_mcp.server") + } + + @Test func nonHTTPURLIsRejectedWithTransportMessage() throws { + expectUnsupportedTransport(from: "stdio://some_mcp.server") + } + + private func expectUnsupportedTransport(from endpoint: String) { + do { + _ = try MCPProviderManager.validatedHTTPSEndpoint(from: endpoint) + Issue.record("Expected unsupported transport for \(endpoint)") + } catch MCPProviderError.unsupportedTransport { + // Expected. + } catch { + Issue.record("Expected unsupported transport, got \(error)") + } + } +} diff --git a/Packages/OsaurusCore/Views/Settings/ProvidersView.swift b/Packages/OsaurusCore/Views/Settings/ProvidersView.swift index 9576205c..5b63dcc3 100644 --- a/Packages/OsaurusCore/Views/Settings/ProvidersView.swift +++ b/Packages/OsaurusCore/Views/Settings/ProvidersView.swift @@ -526,12 +526,18 @@ private struct ProviderEditSheet: View { ) MCPStyledTextField( - label: "URL", - placeholder: "https://mcp.example.com", + label: "HTTP/SSE URL", + placeholder: "https://mcp.example.com/sse", text: $url, isMonospaced: true ) + if let urlValidationMessage { + Text(verbatim: urlValidationMessage) + .font(.system(size: 11)) + .foregroundColor(themeManager.currentTheme.errorColor) + } + MCPStyledSecureField( label: "Bearer Token", placeholder: "Optional - stored securely in Keychain", @@ -875,6 +881,18 @@ private struct ProviderEditSheet: View { private var canSave: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty && !url.trimmingCharacters(in: .whitespaces).isEmpty + && urlValidationMessage == nil + } + + private var urlValidationMessage: String? { + let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedURL.isEmpty else { return nil } + do { + _ = try MCPProviderManager.validatedHTTPSEndpoint(from: trimmedURL) + return nil + } catch { + return error.localizedDescription + } } private func loadProvider() { diff --git a/README.md b/README.md index 47a81498..d6f6a7ff 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ Osaurus is a full MCP (Model Context Protocol) server. Give any MCP-compatible c } ``` -Also an MCP client -- aggregate tools from remote MCP servers into Osaurus. See the [Remote MCP Providers Guide](docs/REMOTE_MCP_PROVIDERS.md) for details. +Also an HTTP/SSE MCP client -- aggregate tools from remote MCP servers into Osaurus. Command-based stdio providers are not supported by that remote-provider path yet. See the [Remote MCP Providers Guide](docs/REMOTE_MCP_PROVIDERS.md) for details. ## Tools & Plugins diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 5e5cf86f..cd09992c 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -205,7 +205,7 @@ See [INFERENCE_RUNTIME.md](./INFERENCE_RUNTIME.md) for the full runtime architec ### Remote MCP Providers -**Purpose:** Connect to external MCP servers and aggregate their tools. +**Purpose:** Connect to external MCP servers over HTTP/SSE and aggregate their tools. **Components:** @@ -220,6 +220,7 @@ See [INFERENCE_RUNTIME.md](./INFERENCE_RUNTIME.md) for the full runtime architec - Configurable discovery and execution timeouts - Tool namespacing (prefixed with provider name) - Streaming support (optional) +- Command-based stdio providers are not supported by this remote-provider path yet --- diff --git a/docs/REMOTE_MCP_PROVIDERS.md b/docs/REMOTE_MCP_PROVIDERS.md index a071cfaf..3057e5fd 100644 --- a/docs/REMOTE_MCP_PROVIDERS.md +++ b/docs/REMOTE_MCP_PROVIDERS.md @@ -2,13 +2,15 @@ Remote MCP Providers allow you to connect Osaurus to external MCP (Model Context Protocol) servers, aggregating their tools into your Osaurus instance. +Remote MCP Providers currently support HTTP/SSE endpoints only. Command-based stdio MCP servers, such as `python -m some_mcp.server`, are not supported as remote providers yet. + --- ## Overview With Remote MCP Providers, you can: -- Connect to any MCP server over HTTP/SSE +- Connect to MCP servers exposed over HTTP/SSE - Automatically discover and register remote tools - Use remote tools alongside local plugins - Aggregate tools from multiple MCP servers @@ -25,7 +27,7 @@ This is different from Remote Providers (which provide inference endpoints). Rem 2. Click **Providers** in the sidebar 3. Scroll to the **MCP Providers** section 4. Click **Add MCP Provider** -5. Enter the MCP server URL +5. Enter the MCP server HTTP/SSE URL 6. Configure authentication if required 7. Click **Save** @@ -38,7 +40,7 @@ This is different from Remote Providers (which provide inference endpoints). Rem | Setting | Description | | ----------- | ----------------------------------- | | **Name** | Display name for the provider | -| **URL** | Full URL to the MCP server endpoint | +| **URL** | Full HTTP/SSE URL to the MCP server endpoint | | **Enabled** | Whether the provider is active | ### Authentication @@ -165,6 +167,12 @@ Before saving a provider, you can test the connection: - Check the URL is correct (including protocol and port) - Ensure no firewall is blocking the connection +**"Remote MCP providers support HTTP/SSE endpoints only"** + +- Use an `http://` or `https://` MCP endpoint, such as `https://mcp.example.com/sse` +- Do not paste a local stdio command into the URL field +- To expose Osaurus itself to a stdio MCP client, use `osaurus mcp` from that client configuration; this is separate from connecting Osaurus to remote providers + **"Authentication failed"** - Verify your token is correct