mcp connection
This commit is contained in:
195
go-api/internal/httpserver/mcp.go
Normal file
195
go-api/internal/httpserver/mcp.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/auth"
|
||||
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||
"github.com/krow/krow-backend/go-api/internal/mcpserver"
|
||||
"github.com/krow/krow-backend/go-api/internal/oauth"
|
||||
"github.com/krow/krow-backend/go-api/internal/ratelimit"
|
||||
"github.com/krow/krow-backend/go-api/internal/runtime"
|
||||
)
|
||||
|
||||
// Mounting the MCP surface and the OAuth authorization server behind it.
|
||||
//
|
||||
// This file is the seam between the existing HTTP server and two packages that
|
||||
// know nothing about it. It is deliberately thin: no validation, no policy and
|
||||
// no business logic live here, because every one of those already lives in the
|
||||
// package being mounted. What this file decides is only WHERE things are served
|
||||
// and WHAT AUTHENTICATES them, and those two decisions are the ones that have
|
||||
// to be right.
|
||||
//
|
||||
// OFF UNLESS CONFIGURED. Without OAUTH_ISSUER and MCP_RESOURCE, none of these
|
||||
// routes are registered at all. That follows routeRuns' precedent exactly: a
|
||||
// deployment that does not serve agents answers 404 rather than registering
|
||||
// routes that fail, and the same is true of one that does not serve MCP. An
|
||||
// existing deployment that upgrades to this build gains nothing it did not ask
|
||||
// for.
|
||||
|
||||
// routeOAuth registers the authorization server and its discovery documents.
|
||||
//
|
||||
// WHICH OF THESE ARE PUBLIC, AND WHY — this is the part worth reading twice.
|
||||
// Four paths bypass the cookie middleware, and each has a specific reason:
|
||||
//
|
||||
// /.well-known/oauth-protected-resource RFC 9728. A client that has no
|
||||
// /.well-known/oauth-authorization-server RFC 8414. token cannot read a
|
||||
// document that requires one, and
|
||||
// these are how it learns where to
|
||||
// get a token. They contain only
|
||||
// public endpoint URLs.
|
||||
//
|
||||
// /oauth/register RFC 7591. A client that has never registered has no
|
||||
// credential to present — that is the entire point of
|
||||
// dynamic registration.
|
||||
//
|
||||
// /oauth/token The client authenticates with an authorization code or a
|
||||
// refresh token IN THE BODY. A cookie would be meaningless:
|
||||
// this is a back-channel call from Claude's servers, where
|
||||
// no browser and no cookie exist.
|
||||
//
|
||||
// /oauth/authorize is deliberately NOT public. It runs in a browser, as a
|
||||
// person, and it requires the existing KROW session — that is how the consent
|
||||
// screen knows whose organisation is being granted. An unauthenticated visitor
|
||||
// is redirected to the existing login and comes back.
|
||||
//
|
||||
// /mcp is deliberately NOT public either, and also does not use the cookie. See
|
||||
// routeMCP.
|
||||
func (s *Server) routeOAuth(mux *http.ServeMux) int {
|
||||
if !s.cfg.OAuth.Enabled() {
|
||||
return 0
|
||||
}
|
||||
|
||||
cfg := oauth.Config{
|
||||
Issuer: s.cfg.OAuth.Issuer,
|
||||
Resource: s.cfg.OAuth.Resource,
|
||||
}
|
||||
store := oauth.NewStore(s.db.Pool)
|
||||
as := oauth.NewServer(cfg, store, sessionResolver{s}, s.cfg.OAuth.LoginPath, s.log)
|
||||
|
||||
mux.Handle("GET /.well-known/oauth-protected-resource", cfg.ProtectedResourceHandler())
|
||||
mux.Handle("GET /.well-known/oauth-authorization-server", cfg.AuthorizationServerHandler())
|
||||
// Registration is the only endpoint that writes for a caller with no
|
||||
// credential at all, so it carries the tightest limit on the surface.
|
||||
mux.Handle("POST /oauth/register",
|
||||
s.limited(ratelimit.OAuthRegister, s.byClientAddr, as.RegisterHandler()))
|
||||
// GET renders consent; POST carries the decision. One handler, because the
|
||||
// POST re-validates every parameter the GET validated rather than trusting
|
||||
// the form it rendered.
|
||||
mux.Handle("GET /oauth/authorize",
|
||||
s.limited(ratelimit.OAuthAuthorize, s.byAddrAndUser, as.AuthorizeHandler()))
|
||||
mux.Handle("POST /oauth/authorize",
|
||||
s.limited(ratelimit.OAuthAuthorize, s.byAddrAndUser, as.AuthorizeHandler()))
|
||||
|
||||
// The token endpoint carries two limits on two different subjects, because
|
||||
// its two grant types are abused differently: a code exchange is bounded
|
||||
// per client, and a refresh is bounded per token so a loop on one
|
||||
// connection cannot spend another's budget. Which applies is decided per
|
||||
// request by the grant_type, inside tokenLimited.
|
||||
mux.Handle("POST /oauth/token", s.tokenLimited(as.TokenHandler()))
|
||||
|
||||
// Revocation is deliberately unlimited — see ratelimit/rules.go. It is the
|
||||
// emergency brake, and an attacker gains nothing by pulling it.
|
||||
mux.Handle("POST /oauth/revoke", as.RevokeHandler())
|
||||
|
||||
return 7
|
||||
}
|
||||
|
||||
// routeMCP registers the MCP endpoint.
|
||||
//
|
||||
// AUTHENTICATION HERE IS THE BEARER PATH AND ONLY THE BEARER PATH.
|
||||
//
|
||||
// The handler authenticates its own callers from the Authorization header and
|
||||
// ignores whatever the cookie middleware put in the context. That is a property
|
||||
// of mcpserver, not of this file — see its auth.go.
|
||||
//
|
||||
// /mcp IS on the publicPaths allowlist, and that is deliberate rather than an
|
||||
// oversight. The cookie middleware has to step aside here: an MCP client
|
||||
// discovers how to authenticate by calling this endpoint without a token and
|
||||
// reading the WWW-Authenticate header of the 401, and the middleware's own 401
|
||||
// carries no such header. Guarding the path here would refuse the client with
|
||||
// nowhere to go, and the connection could never be made at all.
|
||||
//
|
||||
// The credential requirement is not weakened by that, because it was never
|
||||
// this middleware enforcing it: mcpserver refuses every method but the
|
||||
// handshake without a bearer token, and it takes its identity as a parameter
|
||||
// rather than from the request context, so a cookie cannot supply one.
|
||||
//
|
||||
// There is no second authorization layer. A tool call goes straight into the
|
||||
// registry the agent runtime already uses, under the policy table it already
|
||||
// consults.
|
||||
func (s *Server) routeMCP(mux *http.ServeMux) int {
|
||||
if !s.cfg.OAuth.Enabled() {
|
||||
return 0
|
||||
}
|
||||
|
||||
// The SAME registry the runtime builds. Not a copy, not a second
|
||||
// construction: a tool added once is available to Owliver and to MCP
|
||||
// together, and neither can drift from the other.
|
||||
registry := runtime.DefaultTools(
|
||||
s.db.Pool,
|
||||
nil, // knowledge_search is not exposed over MCP — see mcpserver/tools.go
|
||||
)
|
||||
|
||||
authenticator := oauth.NewAuthenticator(
|
||||
oauth.NewStore(s.db.Pool),
|
||||
s.users,
|
||||
// The audience an access token must carry. From configuration, never
|
||||
// from a request: a resource value supplied by a caller would let the
|
||||
// caller choose their own audience.
|
||||
s.cfg.OAuth.Resource,
|
||||
s.log,
|
||||
)
|
||||
|
||||
server := mcpserver.New(registry, authenticator, s.log).
|
||||
WithResourceMetadataURL(s.cfg.OAuth.Issuer + "/.well-known/oauth-protected-resource").
|
||||
// The per-organisation ceiling is installed INSIDE the MCP server
|
||||
// rather than as middleware, because the organisation is only known
|
||||
// after the token has been resolved. See orgLimiter in mcplimit.go.
|
||||
WithOrgLimiter(orgLimiter{s})
|
||||
|
||||
mux.Handle("POST /mcp", s.mcpLimited(server.Handler()))
|
||||
// GET is what the Streamable HTTP binding uses for a server-initiated
|
||||
// stream, which this server does not open. Registered so the answer is 405
|
||||
// with an Allow header rather than a 404 that suggests the endpoint is
|
||||
// absent.
|
||||
mux.Handle("GET /mcp", server.Handler())
|
||||
|
||||
return 2
|
||||
}
|
||||
|
||||
// sessionResolver adapts the existing cookie session to oauth.SessionResolver.
|
||||
//
|
||||
// This is the ONLY place the OAuth package learns who is signed in, and it does
|
||||
// so through the existing session manager — the same lookup every other
|
||||
// authenticated route performs. No second password store, no second session
|
||||
// table, no second notion of identity.
|
||||
type sessionResolver struct{ s *Server }
|
||||
|
||||
// CurrentUser resolves the session cookie into an identity.
|
||||
//
|
||||
// Re-reads the user row rather than trusting the session's own copy, exactly as
|
||||
// authenticate() does, so a suspended account cannot approve an authorization
|
||||
// in the window before its session lapses.
|
||||
func (r sessionResolver) CurrentUser(req *http.Request) (authctx.Identity, bool) {
|
||||
token := sessionToken(req)
|
||||
if token == "" {
|
||||
return authctx.Identity{}, false
|
||||
}
|
||||
sess, err := r.s.sessions.Authenticate(req.Context(), token)
|
||||
if err != nil {
|
||||
return authctx.Identity{}, false
|
||||
}
|
||||
user, err := r.s.users.FindByID(req.Context(), sess.UserID)
|
||||
if err != nil || !user.IsActive() {
|
||||
return authctx.Identity{}, false
|
||||
}
|
||||
return authctx.Identity{
|
||||
UserID: user.ID, OrgID: user.OrgID, Email: user.Email,
|
||||
FullName: user.FullName, Role: user.Role, AccountType: user.AccountType,
|
||||
Status: user.Status, SessionID: sess.ID, ExpiresAt: sess.ExpiresAt,
|
||||
}, true
|
||||
}
|
||||
|
||||
// compile-time proof that the existing user store satisfies what OAuth needs.
|
||||
var _ oauth.UserLookup = (auth.UserStore)(nil)
|
||||
Reference in New Issue
Block a user