feat: serve the mcp endpoint behind the licence feature

This commit is contained in:
2026-09-08 13:57:21 +00:00
parent 674236bb76
commit 0166b17299
7 changed files with 157 additions and 0 deletions
+102
View File
@@ -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 }