feat: serve the mcp endpoint behind the licence feature
This commit is contained in:
@@ -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 }
|
||||
Reference in New Issue
Block a user