From 0166b172999dbf8adba256026d2cffd7c573b474 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 8 Sep 2026 13:57:21 +0000 Subject: [PATCH] feat: serve the mcp endpoint behind the licence feature --- server/cmd/main.go | 8 +++ server/go.mod | 6 ++ server/go.sum | 13 ++++ server/internal/api/handlers.go | 10 +++ server/internal/api/scopes.go | 6 ++ server/internal/api/serverscope.go | 12 ++++ server/internal/mcp/transport.go | 102 +++++++++++++++++++++++++++++ 7 files changed, 157 insertions(+) create mode 100644 server/internal/mcp/transport.go diff --git a/server/cmd/main.go b/server/cmd/main.go index 079cb7b..4c1b9bd 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -22,6 +22,7 @@ import ( "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/bus" "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db" grpcserver "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/mcp" "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/monitorsched" "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/vulnsched" @@ -196,7 +197,14 @@ func runSchemaSetup() { } +// apiVersion mirrors the @version annotation on the swagger block above, +// which is the only version string this server already establishes — there is +// no separate runtime build-version constant to reuse instead. +const apiVersion = "1.0" + func serve() { + mcp.SetVersion(apiVersion) + redisAddr := getEnv("REDIS_ADDR", "localhost:6379") redisUser := os.Getenv("REDIS_USERNAME") redisPass := os.Getenv("REDIS_PASSWORD") diff --git a/server/go.mod b/server/go.mod index 757eb27..edcc00d 100644 --- a/server/go.mod +++ b/server/go.mod @@ -23,17 +23,23 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect + github.com/modelcontextprotocol/go-sdk v1.7.0 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/samber/lo v1.50.0 // indirect github.com/samber/oops v1.18.1 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/testify v1.11.1 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.etcd.io/bbolt v1.4.3 // indirect go.opentelemetry.io/otel v1.34.0 // indirect go.opentelemetry.io/otel/trace v1.34.0 // indirect + golang.org/x/time v0.15.0 // indirect ) require ( diff --git a/server/go.sum b/server/go.sum index fc8c83a..b451d71 100644 --- a/server/go.sum +++ b/server/go.sum @@ -43,7 +43,10 @@ github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE= github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -73,6 +76,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= +github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -99,6 +104,10 @@ github.com/samber/lo v1.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY= github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc= github.com/samber/oops v1.18.1 h1:qjhZbqbdyhWBKntkY8sxrDNKA8b4c5VHlmI1rli7X7M= github.com/samber/oops v1.18.1/go.mod h1:xYqvimigkKV70HyLXiBZJFpIWi2CGcc6Xx7eV+2HycI= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -130,6 +139,8 @@ github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -181,6 +192,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 3d67a2d..be93ac3 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -8,8 +8,10 @@ import ( "strconv" "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/mcp" "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" + "gitea.hostxtra.co.uk/vantage/vantage-shared/license" "github.com/gin-gonic/gin" ) @@ -120,6 +122,14 @@ func RegisterRoutes(r *gin.Engine) { apiGroup.POST("/console/connect", RequireFeature("console"), consoleConnect) apiGroup.GET("/console/tunnel", RequireFeature("console"), consoleTunnel) + // MCP is mounted inside /api so that bearer auth, rate limiting, licence + // activity and RequireScopes all apply from where it lives rather than + // because someone remembered. The route-level scope is a floor: one route + // serves many tools, so per-tool scopes are enforced inside the handler. + mcpGroup := apiGroup.Group("/mcp", RequireFeature(license.FeatureMCP)) + mcpGroup.POST("", mcp.Handler()) + mcpGroup.GET("", mcp.Handler()) + registerWorkflowRoutes(apiGroup) registerMonitorRoutes(apiGroup) registerChannelRoutes(apiGroup) diff --git a/server/internal/api/scopes.go b/server/internal/api/scopes.go index bea5297..533c956 100644 --- a/server/internal/api/scopes.go +++ b/server/internal/api/scopes.go @@ -43,6 +43,12 @@ var routeScopes = map[string]string{ "GET /api/agent/latest-version": "servers:read", "GET /api/audit": "settings:read", + // Both MCP routes require mcp:read as a floor. Individual tools require + // their own resource scope, and write tools additionally require mcp:write, + // enforced inside the handler because one route serves many operations. + "POST /api/mcp": "mcp:read", + "GET /api/mcp": "mcp:read", + "GET /api/settings": "settings:read", "PUT /api/settings": "settings:write", "POST /api/settings/secrets-token": "settings:write", diff --git a/server/internal/api/serverscope.go b/server/internal/api/serverscope.go index 96ab319..3c0bfe2 100644 --- a/server/internal/api/serverscope.go +++ b/server/internal/api/serverscope.go @@ -66,6 +66,18 @@ var serverScopedRoutes = map[string]bool{ "GET /api/servers/tags": false, } +// POST/GET /api/mcp is deliberately absent from this map. main.go's +// serverTouchingRoutes only feeds in routes whose path contains "server" or +// "console" (or the one named workflow-run exception), and /api/mcp matches +// none of those, so it is never presented to AssertServerScopeMapComplete — +// there is nothing to declare true or false here. That is the right outcome: +// the single MCP route fronts many tools of very different shapes, several of +// which touch no server data at all, so a route-level entry could not say +// anything meaningful about tag scoping. Each tool that does read or act on +// server data applies auth.ServerScope itself, the same selector the REST +// handlers for those resources already apply, which is where this kind of +// scoping decision belongs for a many-operations-per-route endpoint. + // AssertServerScopeMapComplete refuses to boot when a route touching server // data is missing from serverScopedRoutes. func AssertServerScopeMapComplete(routes []string) error { diff --git a/server/internal/mcp/transport.go b/server/internal/mcp/transport.go new file mode 100644 index 0000000..b9671e7 --- /dev/null +++ b/server/internal/mcp/transport.go @@ -0,0 +1,102 @@ +package mcp + +import ( + "context" + "errors" + "net/http" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth" + "github.com/gin-gonic/gin" + sdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// callerFromContext builds the acting credential from the session the auth +// middleware already resolved. The mcp package reads no cookie and no header of +// its own: identity is settled before a request reaches here. +func callerFromContext(c *gin.Context) Caller { + return Caller{ + InstanceID: auth.InstanceID(c), + Scopes: auth.Scopes(c), + TokenScope: auth.ServerScope(c), + TokenName: auth.TokenName(c), + } +} + +// Handler serves the MCP endpoint. It is stateless: no session resumption, each +// request self-contained, which is what lets it sit behind ordinary request +// middleware with no special casing. +func Handler() gin.HandlerFunc { + return func(c *gin.Context) { + caller := callerFromContext(c) + + // A cookie session is not an agent. MCP is a credential-shaped surface + // and browsing to it in a logged-in tab must not act as one. + if !auth.IsToken(c) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "the mcp endpoint requires an API token", + }) + return + } + + srv := sdk.NewServer(&sdk.Implementation{ + Name: "vantage", + Version: buildVersion, + }, nil) + + for _, tool := range All().Visible(caller) { + registerSDKTool(srv, tool, caller) + } + + sdk.NewStreamableHTTPHandler(func(*http.Request) *sdk.Server { + return srv + }, &sdk.StreamableHTTPOptions{Stateless: true}).ServeHTTP(c.Writer, c.Request) + } +} + +// registerSDKTool adapts one registered Tool onto the SDK, wrapping it in the +// gate check and the audit write. The gate is re-checked here rather than +// trusted from Visible, because listing and calling are separate requests and a +// token's scopes are re-read on each. +func registerSDKTool(srv *sdk.Server, tool Tool, caller Caller) { + sdk.AddTool(srv, &sdk.Tool{ + Name: tool.Name, + Description: tool.Description, + }, func(ctx context.Context, req *sdk.CallToolRequest, args map[string]any) (*sdk.CallToolResult, any, error) { + if ok, gate := Allowed(tool, caller); !ok { + LogDenied(caller, tool.Name, gate) + return nil, nil, toolError(gate, tool) + } + + out, err := tool.Handler(ctx, caller, args) + if err != nil { + return nil, nil, err + } + LogCall(caller, tool, args, 0) + return nil, out, nil + }) +} + +// toolError explains a refusal in words the model can act on. A transport-level +// failure would be invisible to it; a tool error is something it can read and +// relay to its user. +func toolError(gate string, tool Tool) error { + switch gate { + case GateMCPScope: + if tool.Write { + return errors.New("this token does not hold mcp:write, so it cannot use tools that change anything") + } + return errors.New("this token does not hold mcp:read") + case GateResourceScope: + return errors.New("this token does not hold " + tool.Scope) + default: + return errors.New("refused") + } +} + +// buildVersion is stamped so a user with several instances connected can tell +// them apart in a client. Wire it to whatever the server already uses for its +// version string. +var buildVersion = "dev" + +// SetVersion is called once at boot from main. +func SetVersion(v string) { buildVersion = v }