134 lines
4.8 KiB
Go
134 lines
4.8 KiB
Go
package ghmcp
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"github.com/github/github-mcp-server/internal/oauth"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
// sessionPrompter adapts an MCP server session to oauth.Prompter, presenting
|
|
// authorization prompts to the user via elicitation. Keeping the prompt on the
|
|
// MCP control channel (rather than a tool result) keeps the authorization URL
|
|
// and any session-bound state out of the model's context.
|
|
type sessionPrompter struct {
|
|
session *mcp.ServerSession
|
|
}
|
|
|
|
// elicitationCaps returns the client's declared elicitation capabilities, or nil
|
|
// if the client did not advertise any.
|
|
func (p *sessionPrompter) elicitationCaps() *mcp.ElicitationCapabilities {
|
|
params := p.session.InitializeParams()
|
|
if params == nil || params.Capabilities == nil {
|
|
return nil
|
|
}
|
|
return params.Capabilities.Elicitation
|
|
}
|
|
|
|
// CanPromptURL reports whether the client supports URL-mode elicitation.
|
|
func (p *sessionPrompter) CanPromptURL() bool {
|
|
caps := p.elicitationCaps()
|
|
return caps != nil && caps.URL != nil
|
|
}
|
|
|
|
// PromptURL presents the authorization URL via URL-mode elicitation and blocks
|
|
// until the user acknowledges, declines, or ctx is done.
|
|
func (p *sessionPrompter) PromptURL(ctx context.Context, prompt oauth.Prompt) error {
|
|
res, err := p.session.Elicit(ctx, &mcp.ElicitParams{
|
|
Mode: "url",
|
|
Message: prompt.Message,
|
|
URL: prompt.URL,
|
|
ElicitationID: rand.Text(),
|
|
})
|
|
if err != nil {
|
|
// The client advertised URL elicitation but the request itself failed:
|
|
// classify it as undeliverable (not a user decision) so the flow can fall
|
|
// back to a channel that needs no client capability.
|
|
return fmt.Errorf("%w: %w", oauth.ErrPromptUnavailable, err)
|
|
}
|
|
if res.Action != "accept" {
|
|
return oauth.ErrPromptDeclined
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CanPromptForm reports whether the client supports form-mode elicitation. The
|
|
// SDK treats a client that advertises neither form nor URL capabilities as
|
|
// supporting forms, for backward compatibility, so we mirror that here.
|
|
func (p *sessionPrompter) CanPromptForm() bool {
|
|
caps := p.elicitationCaps()
|
|
if caps == nil {
|
|
return false
|
|
}
|
|
return caps.Form != nil || caps.URL == nil
|
|
}
|
|
|
|
// PromptForm presents a textual acknowledgement (used to display a device code
|
|
// when URL elicitation is unavailable) and blocks until the user responds.
|
|
func (p *sessionPrompter) PromptForm(ctx context.Context, prompt oauth.Prompt) error {
|
|
res, err := p.session.Elicit(ctx, &mcp.ElicitParams{
|
|
Mode: "form",
|
|
Message: prompt.Message,
|
|
})
|
|
if err != nil {
|
|
// As with PromptURL, a delivery failure is undeliverable rather than a
|
|
// decline, so the flow can fall back instead of aborting.
|
|
return fmt.Errorf("%w: %w", oauth.ErrPromptUnavailable, err)
|
|
}
|
|
if res.Action != "accept" {
|
|
return oauth.ErrPromptDeclined
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// oauthAuthenticator is the subset of *oauth.Manager that the middleware needs.
|
|
// Depending on the interface (rather than the concrete manager) lets the
|
|
// middleware be exercised with a deterministic fake, since driving the real
|
|
// manager to its branches would require standing up live GitHub flows.
|
|
type oauthAuthenticator interface {
|
|
HasToken() bool
|
|
Authenticate(ctx context.Context, prompter oauth.Prompter) (*oauth.Outcome, error)
|
|
}
|
|
|
|
// createOAuthMiddleware returns receiving middleware that authorizes the session
|
|
// lazily, on the first tool call. Authorization is deferred until here (rather
|
|
// than at startup) because the prompts depend on an initialized session whose
|
|
// elicitation capabilities are known.
|
|
//
|
|
// When a token is already available the call proceeds untouched. Otherwise the
|
|
// flow runs: secure channels (browser, URL elicitation) block until the token
|
|
// arrives and then the call proceeds; the last-resort channel returns the
|
|
// instruction to the user as a tool result and asks them to retry.
|
|
func createOAuthMiddleware(mgr oauthAuthenticator, logger *slog.Logger) func(next mcp.MethodHandler) mcp.MethodHandler {
|
|
return func(next mcp.MethodHandler) mcp.MethodHandler {
|
|
return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) {
|
|
if method != "tools/call" || mgr.HasToken() {
|
|
return next(ctx, method, request)
|
|
}
|
|
|
|
callReq, ok := request.(*mcp.CallToolRequest)
|
|
if !ok {
|
|
return next(ctx, method, request)
|
|
}
|
|
|
|
outcome, err := mgr.Authenticate(ctx, &sessionPrompter{session: callReq.Session})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("github authorization failed: %w", err)
|
|
}
|
|
if outcome != nil && outcome.UserAction != nil {
|
|
logger.Info("surfacing github authorization instructions to user")
|
|
return &mcp.CallToolResult{
|
|
Content: []mcp.Content{&mcp.TextContent{Text: outcome.UserAction.Message}},
|
|
}, nil
|
|
}
|
|
return next(ctx, method, request)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ensure sessionPrompter satisfies the Prompter contract.
|
|
var _ oauth.Prompter = (*sessionPrompter)(nil)
|