Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
778 lines
26 KiB
Go
778 lines
26 KiB
Go
// Package mcpserver exposes active Connector MCP routes through a loopback-only,
|
|
// session-bound Streamable HTTP server. Hosts own when bindings are issued and
|
|
// revoked; the server owns transport validation and bearer isolation.
|
|
package mcpserver
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
implementationhost "github.com/tutti-os/tutti/packages/connector/runtime/implementationhost"
|
|
connectorruntimemcp "github.com/tutti-os/tutti/packages/connector/runtime/mcp"
|
|
)
|
|
|
|
const (
|
|
serverName = "connector"
|
|
protocolVersion = "2026-07-28"
|
|
providerProtocolVersion = "2025-06-18"
|
|
maxRequestBytes = 2 << 20
|
|
)
|
|
|
|
type Config struct {
|
|
Registry *implementationhost.MCPRegistry
|
|
SessionRouter SessionRouter
|
|
}
|
|
|
|
// RequestScope is derived only from the bearer minted by Binding. It is never
|
|
// decoded from Agent-supplied headers or MCP arguments.
|
|
type RequestScope struct {
|
|
WorkspaceID string
|
|
AgentSessionID string
|
|
InvocationID string
|
|
InvocationGeneration uint64
|
|
}
|
|
|
|
// BindingScope is the trusted identity captured in an Agent-facing bearer.
|
|
// Invocation identity is optional for ordinary local Agent Sessions, but an
|
|
// InvocationID and InvocationGeneration must always be supplied together.
|
|
type BindingScope struct {
|
|
WorkspaceID string
|
|
AgentSessionID string
|
|
InvocationID string
|
|
InvocationGeneration uint64
|
|
}
|
|
|
|
type requestScopeContextKey struct{}
|
|
|
|
func normalizeBindingScope(scope BindingScope) BindingScope {
|
|
scope.WorkspaceID = strings.TrimSpace(scope.WorkspaceID)
|
|
scope.AgentSessionID = strings.TrimSpace(scope.AgentSessionID)
|
|
scope.InvocationID = strings.TrimSpace(scope.InvocationID)
|
|
return scope
|
|
}
|
|
|
|
func validBindingScope(scope BindingScope) bool {
|
|
if scope.WorkspaceID == "" || scope.AgentSessionID == "" ||
|
|
strings.ContainsAny(scope.WorkspaceID+scope.AgentSessionID+scope.InvocationID, "\x00\r\n") {
|
|
return false
|
|
}
|
|
return (scope.InvocationID == "") == (scope.InvocationGeneration == 0)
|
|
}
|
|
|
|
func requestScope(scope BindingScope) RequestScope {
|
|
return RequestScope(scope)
|
|
}
|
|
|
|
func validRequestScope(scope RequestScope) bool {
|
|
return validBindingScope(BindingScope(scope))
|
|
}
|
|
|
|
// RequestScopeFromContext returns the authenticated Agent Session scope for
|
|
// the current Connector MCP request.
|
|
func RequestScopeFromContext(ctx context.Context) (RequestScope, bool) {
|
|
if ctx == nil {
|
|
return RequestScope{}, false
|
|
}
|
|
scope, ok := ctx.Value(requestScopeContextKey{}).(RequestScope)
|
|
if !ok || !validRequestScope(scope) {
|
|
return RequestScope{}, false
|
|
}
|
|
return scope, true
|
|
}
|
|
|
|
// SessionRouter lets a product project and route Connector tools for one
|
|
// authenticated Agent Session. Implementations must treat scope as trusted
|
|
// transport identity and must not accept a replacement scope from tool input.
|
|
type SessionRouter interface {
|
|
Tools(context.Context, RequestScope) ([]implementationhost.MCPTool, error)
|
|
CallValidated(context.Context, RequestScope, string, map[string]any, func(implementationhost.MCPTool) error) (json.RawMessage, error)
|
|
Subscribe(RequestScope) (<-chan struct{}, func())
|
|
}
|
|
|
|
type Binding struct {
|
|
Name string
|
|
Type string
|
|
URL string
|
|
Headers map[string]string
|
|
}
|
|
|
|
type authorization struct {
|
|
scope RequestScope
|
|
revoked <-chan struct{}
|
|
}
|
|
|
|
// Server is a loopback-only, stateless Streamable HTTP MCP projection over
|
|
// Connector routes. User-configured MCP servers never enter this service.
|
|
type Server struct {
|
|
router SessionRouter
|
|
listener net.Listener
|
|
http *http.Server
|
|
baseURL string
|
|
|
|
mu sync.RWMutex
|
|
authorizations map[string]authorization
|
|
revocations map[string]chan struct{}
|
|
}
|
|
|
|
func Start(config Config) (*Server, error) {
|
|
router := config.SessionRouter
|
|
if router == nil && config.Registry != nil {
|
|
router = registrySessionRouter{registry: config.Registry}
|
|
}
|
|
if router == nil {
|
|
return nil, errors.New("connector MCP registry is required")
|
|
}
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listen for connector MCP: %w", err)
|
|
}
|
|
server := &Server{
|
|
router: router,
|
|
listener: listener,
|
|
baseURL: "http://" + listener.Addr().String() + "/mcp/connector",
|
|
authorizations: make(map[string]authorization),
|
|
revocations: make(map[string]chan struct{}),
|
|
}
|
|
server.http = &http.Server{Handler: server, ReadHeaderTimeout: 5 * time.Second}
|
|
go func() { _ = server.http.Serve(listener) }()
|
|
return server, nil
|
|
}
|
|
|
|
func (server *Server) Binding(workspaceID, agentSessionID string) (Binding, error) {
|
|
return server.Bind(BindingScope{WorkspaceID: workspaceID, AgentSessionID: agentSessionID})
|
|
}
|
|
|
|
// Bind issues a bearer for an exact trusted Session or Invocation scope. The
|
|
// legacy Binding method remains the compatibility entry point for ordinary
|
|
// non-shared Agent Sessions.
|
|
func (server *Server) Bind(bindingScope BindingScope) (Binding, error) {
|
|
if server == nil || server.listener == nil {
|
|
return Binding{}, errors.New("connector MCP server is unavailable")
|
|
}
|
|
scope := normalizeBindingScope(bindingScope)
|
|
if !validBindingScope(scope) {
|
|
return Binding{}, errors.New("connector MCP binding identity is required")
|
|
}
|
|
token, err := randomID(32)
|
|
if err != nil {
|
|
return Binding{}, err
|
|
}
|
|
server.mu.Lock()
|
|
server.revokeLocked(scope.WorkspaceID, scope.AgentSessionID)
|
|
revoked := make(chan struct{})
|
|
server.authorizations[token] = authorization{scope: requestScope(scope), revoked: revoked}
|
|
server.revocations[token] = revoked
|
|
server.mu.Unlock()
|
|
return Binding{
|
|
Name: serverName,
|
|
Type: "http",
|
|
URL: server.baseURL,
|
|
Headers: map[string]string{
|
|
"Authorization": "Bearer " + token,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (server *Server) Revoke(workspaceID, agentSessionID string) {
|
|
if server == nil {
|
|
return
|
|
}
|
|
server.mu.Lock()
|
|
server.revokeLocked(strings.TrimSpace(workspaceID), strings.TrimSpace(agentSessionID))
|
|
server.mu.Unlock()
|
|
}
|
|
|
|
func (server *Server) RevokeAll() {
|
|
if server == nil {
|
|
return
|
|
}
|
|
server.mu.Lock()
|
|
for token := range server.authorizations {
|
|
server.revokeTokenLocked(token)
|
|
}
|
|
server.mu.Unlock()
|
|
}
|
|
|
|
func (server *Server) Close(ctx context.Context) error {
|
|
if server == nil || server.http == nil {
|
|
return nil
|
|
}
|
|
server.RevokeAll()
|
|
return server.http.Shutdown(ctx)
|
|
}
|
|
|
|
func (server *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
|
if request.URL.Path != "/mcp/connector" || !validLoopbackHost(request.Host) {
|
|
http.NotFound(writer, request)
|
|
return
|
|
}
|
|
if !validLoopbackOrigin(request.Header.Get("Origin")) {
|
|
writeRPCError(writer, http.StatusForbidden, nil, -32020, "Origin is not allowed", nil)
|
|
return
|
|
}
|
|
if request.Method != http.MethodPost {
|
|
writer.Header().Set("Allow", http.MethodPost)
|
|
writeRPCError(writer, http.StatusMethodNotAllowed, nil, -32600, "Connector MCP only accepts POST", nil)
|
|
return
|
|
}
|
|
token, auth, ok := server.authorize(request)
|
|
if !ok {
|
|
writer.Header().Set("WWW-Authenticate", "Bearer")
|
|
writeRPCError(writer, http.StatusUnauthorized, nil, -32001, "Connector MCP authentication is required", nil)
|
|
return
|
|
}
|
|
server.handlePost(writer, request, token, auth)
|
|
}
|
|
|
|
type rpcRequest struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID json.RawMessage `json:"id"`
|
|
Method string `json:"method"`
|
|
Params json.RawMessage `json:"params"`
|
|
}
|
|
|
|
type rpcError struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
Data any `json:"data,omitempty"`
|
|
}
|
|
|
|
type rpcResponse struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID json.RawMessage `json:"id"`
|
|
Result any `json:"result,omitempty"`
|
|
Error *rpcError `json:"error,omitempty"`
|
|
}
|
|
|
|
type requestMetadata struct {
|
|
ProtocolVersion string `json:"io.modelcontextprotocol/protocolVersion"`
|
|
ClientInfo implementation `json:"io.modelcontextprotocol/clientInfo"`
|
|
ClientCapabilities map[string]any `json:"io.modelcontextprotocol/clientCapabilities"`
|
|
}
|
|
|
|
type implementation struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
}
|
|
|
|
func (server *Server) handlePost(writer http.ResponseWriter, request *http.Request, token string, auth authorization) {
|
|
scope := auth.scope
|
|
request = request.WithContext(context.WithValue(request.Context(), requestScopeContextKey{}, scope))
|
|
writer.Header().Set("Cache-Control", "private, no-store")
|
|
if !mediaTypeContains(request.Header.Get("Content-Type"), "application/json") ||
|
|
!mediaTypeContains(request.Header.Get("Accept"), "application/json") ||
|
|
!mediaTypeContains(request.Header.Get("Accept"), "text/event-stream") {
|
|
writeRPCError(writer, http.StatusBadRequest, nil, -32600, "MCP HTTP content negotiation is invalid", nil)
|
|
return
|
|
}
|
|
decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, maxRequestBytes))
|
|
decoder.DisallowUnknownFields()
|
|
var rpc rpcRequest
|
|
if err := decoder.Decode(&rpc); err != nil || decoder.Decode(&struct{}{}) != io.EOF || rpc.JSONRPC != "2.0" ||
|
|
strings.TrimSpace(rpc.Method) == "" {
|
|
writeRPCError(writer, http.StatusBadRequest, nullID(rpc.ID), -32600, "Invalid MCP request", nil)
|
|
return
|
|
}
|
|
if isProviderNativeMethod(request, rpc.Method) {
|
|
server.handleProviderNativePost(writer, request, token, auth, rpc)
|
|
return
|
|
}
|
|
if len(rpc.ID) == 0 {
|
|
writeRPCError(writer, http.StatusBadRequest, nil, -32600, "Invalid MCP request", nil)
|
|
return
|
|
}
|
|
params, metadata, err := decodeRequestParams(rpc.Params)
|
|
if err != nil {
|
|
writeRPCError(writer, http.StatusBadRequest, rpc.ID, -32602, "MCP request metadata is required", nil)
|
|
return
|
|
}
|
|
if validation := validateRequestHeaders(request, rpc.Method, params, metadata); validation != nil {
|
|
writeRPCError(writer, validation.status, rpc.ID, validation.code, validation.message, validation.data)
|
|
return
|
|
}
|
|
|
|
switch rpc.Method {
|
|
case "server/discover":
|
|
writeRPC(writer, http.StatusOK, rpcResponse{JSONRPC: "2.0", ID: rpc.ID, Result: map[string]any{
|
|
"resultType": "complete",
|
|
"supportedVersions": []string{protocolVersion},
|
|
"capabilities": map[string]any{"tools": map[string]any{"listChanged": true}},
|
|
"_meta": map[string]any{"io.modelcontextprotocol/serverInfo": map[string]any{
|
|
"name": serverName, "version": "1",
|
|
}},
|
|
"ttlMs": 300000, "cacheScope": "private",
|
|
}})
|
|
case "tools/list":
|
|
tools, err := server.router.Tools(request.Context(), scope)
|
|
if err != nil {
|
|
writeRegistryError(writer, rpc.ID, err)
|
|
return
|
|
}
|
|
writeRPC(writer, http.StatusOK, rpcResponse{JSONRPC: "2.0", ID: rpc.ID, Result: map[string]any{
|
|
"resultType": "complete", "tools": tools, "ttlMs": 0, "cacheScope": "private",
|
|
}})
|
|
case "tools/call":
|
|
server.handleToolCall(writer, request, scope, rpc.ID, params, true)
|
|
case "subscriptions/listen":
|
|
server.handleSubscription(writer, request, token, auth, scope, rpc.ID, params)
|
|
default:
|
|
writeRPCError(writer, http.StatusNotFound, rpc.ID, -32601, "Method not found", nil)
|
|
}
|
|
}
|
|
|
|
func isProviderNativeMethod(request *http.Request, method string) bool {
|
|
switch method {
|
|
case "initialize", "notifications/initialized", "notifications/cancelled", "ping",
|
|
"resources/list", "resources/templates/list":
|
|
return true
|
|
case "tools/list", "tools/call":
|
|
// The modern protocol always carries the method in its integrity header.
|
|
// Provider-native MCP clients do not.
|
|
return strings.TrimSpace(request.Header.Get("Mcp-Method")) == ""
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (server *Server) handleProviderNativePost(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
token string,
|
|
auth authorization,
|
|
rpc rpcRequest,
|
|
) {
|
|
_ = token
|
|
scope := auth.scope
|
|
switch rpc.Method {
|
|
case "initialize":
|
|
if len(rpc.ID) == 0 {
|
|
writeRPCError(writer, http.StatusBadRequest, nil, -32600, "Initialize request ID is required", nil)
|
|
return
|
|
}
|
|
var params struct {
|
|
ProtocolVersion string `json:"protocolVersion"`
|
|
Capabilities map[string]any `json:"capabilities"`
|
|
ClientInfo implementation `json:"clientInfo"`
|
|
}
|
|
if json.Unmarshal(rpc.Params, ¶ms) != nil || strings.TrimSpace(params.ProtocolVersion) == "" ||
|
|
strings.TrimSpace(params.ClientInfo.Name) == "" || strings.TrimSpace(params.ClientInfo.Version) == "" {
|
|
writeRPCError(writer, http.StatusBadRequest, rpc.ID, -32602, "Invalid initialize parameters", nil)
|
|
return
|
|
}
|
|
writeRPC(writer, http.StatusOK, rpcResponse{JSONRPC: "2.0", ID: rpc.ID, Result: map[string]any{
|
|
"protocolVersion": providerProtocolVersion,
|
|
"capabilities": map[string]any{"tools": map[string]any{"listChanged": false}},
|
|
"serverInfo": map[string]any{"name": serverName, "version": "1"},
|
|
}})
|
|
case "notifications/initialized", "notifications/cancelled":
|
|
writer.WriteHeader(http.StatusAccepted)
|
|
case "ping":
|
|
writeRPC(writer, http.StatusOK, rpcResponse{JSONRPC: "2.0", ID: rpc.ID, Result: map[string]any{}})
|
|
case "resources/list":
|
|
writeRPC(writer, http.StatusOK, rpcResponse{JSONRPC: "2.0", ID: rpc.ID, Result: map[string]any{"resources": []any{}}})
|
|
case "resources/templates/list":
|
|
writeRPC(writer, http.StatusOK, rpcResponse{JSONRPC: "2.0", ID: rpc.ID, Result: map[string]any{"resourceTemplates": []any{}}})
|
|
case "tools/list":
|
|
tools, err := server.router.Tools(request.Context(), scope)
|
|
if err != nil {
|
|
writeRegistryError(writer, rpc.ID, err)
|
|
return
|
|
}
|
|
writeRPC(writer, http.StatusOK, rpcResponse{JSONRPC: "2.0", ID: rpc.ID, Result: map[string]any{"tools": tools}})
|
|
case "tools/call":
|
|
var params map[string]json.RawMessage
|
|
if len(rpc.ID) == 0 || json.Unmarshal(rpc.Params, ¶ms) != nil {
|
|
writeRPCError(writer, http.StatusBadRequest, nullID(rpc.ID), -32602, "Invalid tool arguments", nil)
|
|
return
|
|
}
|
|
server.handleToolCall(writer, request, scope, rpc.ID, params, false)
|
|
default:
|
|
writeRPCError(writer, http.StatusOK, nullID(rpc.ID), -32601, "Method not found", nil)
|
|
}
|
|
}
|
|
|
|
func (server *Server) handleToolCall(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
scope RequestScope,
|
|
id json.RawMessage,
|
|
params map[string]json.RawMessage,
|
|
modern bool,
|
|
) {
|
|
var name string
|
|
var arguments map[string]any
|
|
if json.Unmarshal(params["name"], &name) != nil || strings.TrimSpace(name) == "" ||
|
|
(len(params["arguments"]) != 0 && json.Unmarshal(params["arguments"], &arguments) != nil) {
|
|
writeRPCError(writer, http.StatusBadRequest, id, -32602, "Invalid tool arguments", nil)
|
|
return
|
|
}
|
|
if arguments == nil {
|
|
arguments = map[string]any{}
|
|
}
|
|
var parameterValidation error
|
|
var validate func(implementationhost.MCPTool) error
|
|
if modern {
|
|
validate = func(tool implementationhost.MCPTool) error {
|
|
parameterValidation = validateToolParameterHeaders(request.Header, []implementationhost.MCPTool{tool}, name, params["arguments"])
|
|
return parameterValidation
|
|
}
|
|
}
|
|
raw, err := server.router.CallValidated(request.Context(), scope, name, arguments, validate)
|
|
if err != nil {
|
|
if parameterValidation != nil {
|
|
writeRPCError(writer, http.StatusBadRequest, id, -32020, parameterValidation.Error(), nil)
|
|
return
|
|
}
|
|
writeRegistryError(writer, id, err)
|
|
return
|
|
}
|
|
var result map[string]any
|
|
if json.Unmarshal(raw, &result) != nil {
|
|
writeRPCError(writer, http.StatusInternalServerError, id, -32603, "Invalid upstream tool result", nil)
|
|
return
|
|
}
|
|
if modern {
|
|
if _, exists := result["resultType"]; !exists {
|
|
result["resultType"] = "complete"
|
|
}
|
|
} else {
|
|
delete(result, "resultType")
|
|
delete(result, "ttlMs")
|
|
delete(result, "cacheScope")
|
|
}
|
|
writeRPC(writer, http.StatusOK, rpcResponse{JSONRPC: "2.0", ID: id, Result: result})
|
|
}
|
|
|
|
func writeRegistryError(writer http.ResponseWriter, id json.RawMessage, err error) {
|
|
var rpcErr *connectorruntimemcp.RPCError
|
|
if errors.As(err, &rpcErr) && (rpcErr.Code == -33001 || rpcErr.Code == -33002) {
|
|
writeRPCError(writer, http.StatusOK, id, rpcErr.Code, rpcErr.Message, nil)
|
|
return
|
|
}
|
|
writeRPCError(writer, http.StatusOK, id, -32000, err.Error(), nil)
|
|
}
|
|
|
|
func (server *Server) handleSubscription(writer http.ResponseWriter, request *http.Request, token string, auth authorization, scope RequestScope, id json.RawMessage, params map[string]json.RawMessage) {
|
|
var notifications struct {
|
|
ToolsListChanged bool `json:"toolsListChanged"`
|
|
}
|
|
if len(params["notifications"]) != 0 && json.Unmarshal(params["notifications"], ¬ifications) != nil {
|
|
writeRPCError(writer, http.StatusBadRequest, id, -32602, "Invalid subscription filter", nil)
|
|
return
|
|
}
|
|
flusher, ok := writer.(http.Flusher)
|
|
if !ok {
|
|
writeRPCError(writer, http.StatusInternalServerError, id, -32603, "Streaming is unavailable", nil)
|
|
return
|
|
}
|
|
updates, unsubscribe := server.router.Subscribe(scope)
|
|
defer unsubscribe()
|
|
writer.Header().Set("Content-Type", "text/event-stream")
|
|
writer.Header().Set("Cache-Control", "private, no-store")
|
|
writer.Header().Set("X-Accel-Buffering", "no")
|
|
acknowledged := map[string]any{}
|
|
if notifications.ToolsListChanged {
|
|
acknowledged["toolsListChanged"] = true
|
|
}
|
|
subscriptionID := cloneRawJSON(id)
|
|
writeSSE(writer, map[string]any{
|
|
"jsonrpc": "2.0", "method": "notifications/subscriptions/acknowledged",
|
|
"params": map[string]any{
|
|
"_meta": map[string]any{"io.modelcontextprotocol/subscriptionId": subscriptionID},
|
|
"notifications": acknowledged,
|
|
},
|
|
})
|
|
flusher.Flush()
|
|
for {
|
|
select {
|
|
case _, open := <-updates:
|
|
if !open {
|
|
return
|
|
}
|
|
if notifications.ToolsListChanged {
|
|
writeSSE(writer, map[string]any{
|
|
"jsonrpc": "2.0", "method": "notifications/tools/list_changed",
|
|
"params": map[string]any{"_meta": map[string]any{
|
|
"io.modelcontextprotocol/subscriptionId": subscriptionID,
|
|
}},
|
|
})
|
|
flusher.Flush()
|
|
}
|
|
case <-request.Context().Done():
|
|
return
|
|
case <-auth.revoked:
|
|
writeSSE(writer, rpcResponse{JSONRPC: "2.0", ID: id, Result: map[string]any{
|
|
"resultType": "complete", "_meta": map[string]any{"io.modelcontextprotocol/subscriptionId": subscriptionID},
|
|
}})
|
|
flusher.Flush()
|
|
server.revokeToken(token)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
type registrySessionRouter struct {
|
|
registry *implementationhost.MCPRegistry
|
|
}
|
|
|
|
func (router registrySessionRouter) Tools(ctx context.Context, _ RequestScope) ([]implementationhost.MCPTool, error) {
|
|
return router.registry.Tools(ctx)
|
|
}
|
|
|
|
func (router registrySessionRouter) CallValidated(
|
|
ctx context.Context,
|
|
_ RequestScope,
|
|
name string,
|
|
arguments map[string]any,
|
|
validate func(implementationhost.MCPTool) error,
|
|
) (json.RawMessage, error) {
|
|
return router.registry.CallValidated(ctx, name, arguments, validate)
|
|
}
|
|
|
|
func (router registrySessionRouter) Subscribe(_ RequestScope) (<-chan struct{}, func()) {
|
|
return router.registry.Subscribe()
|
|
}
|
|
|
|
func decodeRequestParams(raw json.RawMessage) (map[string]json.RawMessage, requestMetadata, error) {
|
|
var params map[string]json.RawMessage
|
|
if len(raw) == 0 || json.Unmarshal(raw, ¶ms) != nil {
|
|
return nil, requestMetadata{}, errors.New("params are required")
|
|
}
|
|
var metadata requestMetadata
|
|
if json.Unmarshal(params["_meta"], &metadata) != nil || strings.TrimSpace(metadata.ProtocolVersion) == "" ||
|
|
strings.TrimSpace(metadata.ClientInfo.Name) == "" || strings.TrimSpace(metadata.ClientInfo.Version) == "" || metadata.ClientCapabilities == nil {
|
|
return nil, requestMetadata{}, errors.New("metadata is required")
|
|
}
|
|
return params, metadata, nil
|
|
}
|
|
|
|
type requestValidationError struct {
|
|
status int
|
|
code int
|
|
message string
|
|
data any
|
|
}
|
|
|
|
func validateRequestHeaders(request *http.Request, method string, params map[string]json.RawMessage, metadata requestMetadata) *requestValidationError {
|
|
requested := strings.TrimSpace(request.Header.Get("MCP-Protocol-Version"))
|
|
if requested == "" || requested != strings.TrimSpace(metadata.ProtocolVersion) {
|
|
return &requestValidationError{status: http.StatusBadRequest, code: -32020, message: "MCP-Protocol-Version header does not match request metadata"}
|
|
}
|
|
if requested != protocolVersion {
|
|
return &requestValidationError{status: http.StatusBadRequest, code: -32022, message: "Unsupported protocol version",
|
|
data: map[string]any{"supported": []string{protocolVersion}, "requested": requested}}
|
|
}
|
|
if strings.TrimSpace(request.Header.Get("Mcp-Method")) != method {
|
|
return &requestValidationError{status: http.StatusBadRequest, code: -32020, message: "Mcp-Method header does not match request method"}
|
|
}
|
|
if method != "tools/call" {
|
|
return nil
|
|
}
|
|
var name string
|
|
if json.Unmarshal(params["name"], &name) != nil {
|
|
return &requestValidationError{status: http.StatusBadRequest, code: -32602, message: "Tool name is required"}
|
|
}
|
|
headerName, err := decodeHeaderValue(request.Header.Get("Mcp-Name"))
|
|
if err != nil || headerName != name {
|
|
return &requestValidationError{status: http.StatusBadRequest, code: -32020, message: "Mcp-Name header does not match request tool name"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateToolParameterHeaders(headers http.Header, tools []implementationhost.MCPTool, name string, rawArguments json.RawMessage) error {
|
|
var schema map[string]any
|
|
for _, tool := range tools {
|
|
if tool.Name == name {
|
|
schema = tool.InputSchema
|
|
break
|
|
}
|
|
}
|
|
if schema == nil {
|
|
return nil
|
|
}
|
|
arguments := map[string]any{}
|
|
if len(rawArguments) != 0 && json.Unmarshal(rawArguments, &arguments) != nil {
|
|
return errors.New("tool arguments are invalid")
|
|
}
|
|
for _, binding := range collectHeaderBindings(schema, nil) {
|
|
value, present := nestedValue(arguments, binding.path)
|
|
header := headers.Get("Mcp-Param-" + binding.name)
|
|
if !present || value == nil {
|
|
if header != "" {
|
|
return fmt.Errorf("Mcp-Param-%s header has no matching tool argument", binding.name)
|
|
}
|
|
continue
|
|
}
|
|
decoded, err := decodeHeaderValue(header)
|
|
if err != nil || decoded != fmt.Sprint(value) {
|
|
return fmt.Errorf("Mcp-Param-%s header does not match tool arguments", binding.name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type headerBinding struct {
|
|
name string
|
|
path []string
|
|
}
|
|
|
|
func collectHeaderBindings(schema map[string]any, path []string) []headerBinding {
|
|
properties, _ := schema["properties"].(map[string]any)
|
|
result := make([]headerBinding, 0)
|
|
for property, raw := range properties {
|
|
child, _ := raw.(map[string]any)
|
|
if child == nil {
|
|
continue
|
|
}
|
|
nextPath := append(append([]string(nil), path...), property)
|
|
if name, _ := child["x-mcp-header"].(string); strings.TrimSpace(name) != "" {
|
|
result = append(result, headerBinding{name: strings.TrimSpace(name), path: nextPath})
|
|
}
|
|
result = append(result, collectHeaderBindings(child, nextPath)...)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func nestedValue(arguments map[string]any, path []string) (any, bool) {
|
|
var current any = arguments
|
|
for _, segment := range path {
|
|
object, ok := current.(map[string]any)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
current, ok = object[segment]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
}
|
|
return current, true
|
|
}
|
|
|
|
func (server *Server) authorize(request *http.Request) (string, authorization, bool) {
|
|
header := strings.TrimSpace(request.Header.Get("Authorization"))
|
|
if !strings.HasPrefix(header, "Bearer ") {
|
|
return "", authorization{}, false
|
|
}
|
|
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
|
server.mu.RLock()
|
|
auth, ok := server.authorizations[token]
|
|
server.mu.RUnlock()
|
|
return token, auth, ok
|
|
}
|
|
|
|
func (server *Server) revokeLocked(workspaceID, agentSessionID string) {
|
|
for token, auth := range server.authorizations {
|
|
if auth.scope.WorkspaceID == workspaceID && auth.scope.AgentSessionID == agentSessionID {
|
|
server.revokeTokenLocked(token)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (server *Server) revokeToken(token string) {
|
|
server.mu.Lock()
|
|
server.revokeTokenLocked(token)
|
|
server.mu.Unlock()
|
|
}
|
|
|
|
func (server *Server) revokeTokenLocked(token string) {
|
|
delete(server.authorizations, token)
|
|
if revoked := server.revocations[token]; revoked != nil {
|
|
close(revoked)
|
|
delete(server.revocations, token)
|
|
}
|
|
}
|
|
|
|
func writeRPC(writer http.ResponseWriter, status int, response rpcResponse) {
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
writer.WriteHeader(status)
|
|
_ = json.NewEncoder(writer).Encode(response)
|
|
}
|
|
|
|
func writeRPCError(writer http.ResponseWriter, status int, id json.RawMessage, code int, message string, data any) {
|
|
writeRPC(writer, status, rpcResponse{JSONRPC: "2.0", ID: nullID(id), Error: &rpcError{Code: code, Message: message, Data: data}})
|
|
}
|
|
|
|
func writeSSE(writer io.Writer, payload any) {
|
|
raw, err := json.Marshal(payload)
|
|
if err == nil {
|
|
_, _ = fmt.Fprintf(writer, "event: message\ndata: %s\n\n", raw)
|
|
}
|
|
}
|
|
|
|
func nullID(id json.RawMessage) json.RawMessage {
|
|
if len(id) == 0 {
|
|
return json.RawMessage("null")
|
|
}
|
|
return id
|
|
}
|
|
|
|
func cloneRawJSON(raw json.RawMessage) any {
|
|
var value any
|
|
if json.Unmarshal(raw, &value) != nil {
|
|
return nil
|
|
}
|
|
return value
|
|
}
|
|
|
|
func randomID(size int) (string, error) {
|
|
buffer := make([]byte, size)
|
|
if _, err := rand.Read(buffer); err != nil {
|
|
return "", fmt.Errorf("generate connector MCP secret: %w", err)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(buffer), nil
|
|
}
|
|
|
|
func mediaTypeContains(value, expected string) bool {
|
|
for _, candidate := range strings.Split(strings.ToLower(value), ",") {
|
|
if strings.TrimSpace(strings.SplitN(candidate, ";", 2)[0]) == expected {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func decodeHeaderValue(value string) (string, error) {
|
|
value = strings.TrimSpace(value)
|
|
if !strings.HasPrefix(value, "=?base64?") || !strings.HasSuffix(value, "?=") {
|
|
return value, nil
|
|
}
|
|
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(value, "=?base64?"), "?="))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(decoded), nil
|
|
}
|
|
|
|
func validLoopbackHost(value string) bool {
|
|
host := value
|
|
if parsed, _, err := net.SplitHostPort(host); err == nil {
|
|
host = parsed
|
|
}
|
|
host = strings.Trim(strings.ToLower(strings.TrimSpace(host)), "[]")
|
|
return host == "localhost" || host == "127.0.0.1" || host == "::1"
|
|
}
|
|
|
|
func validLoopbackOrigin(value string) bool {
|
|
origin := strings.TrimSpace(value)
|
|
if origin == "" || origin == "null" {
|
|
return true
|
|
}
|
|
parsed, err := url.Parse(origin)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
host := strings.Trim(strings.ToLower(parsed.Hostname()), "[]")
|
|
return host == "localhost" || host == "127.0.0.1" || host == "::1"
|
|
}
|