278 lines
9.4 KiB
Go

package main
import (
"encoding/json"
"net/http"
)
const mcpProtocolVersion = "2025-03-26"
type mcpRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type mcpResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Result any `json:"result,omitempty"`
Error *mcpError `json:"error,omitempty"`
}
type mcpError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type mcpTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]any `json:"inputSchema"`
}
func (a *app) mcpProtectedResourceMetadata(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"resource": a.cfg.MCPResourceURL,
"authorization_servers": []string{a.cfg.KeycloakIssuer},
"scopes_supported": []string{
"cms.authoring.read",
"cms.authoring.propose",
"cms.authoring.preview",
"scorm.authoring.read",
"scorm.authoring.propose",
"scorm.authoring.preview",
},
"bearer_methods_supported": []string{"header"},
})
}
func (a *app) authoringRoleMiddleware(next http.Handler) http.Handler {
if a.cfg.AuthDisabled {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
if claims == nil {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
if !hasAnyRole(claims, "editor", "instructor", "admin", "platform_owner", "platform_admin") {
writeError(w, http.StatusForbidden, "authoring role required")
return
}
next.ServeHTTP(w, r)
})
}
func (a *app) handleMCP(w http.ResponseWriter, r *http.Request) {
var request mcpRequest
if err := decodeJSON(r, &request); err != nil {
writeMCPError(w, nil, -32700, "invalid JSON-RPC request")
return
}
if request.JSONRPC != "2.0" || request.Method == "" {
writeMCPError(w, request.ID, -32600, "invalid JSON-RPC request")
return
}
switch request.Method {
case "initialize":
writeMCPResult(w, request.ID, map[string]any{
"protocolVersion": mcpProtocolVersion,
"capabilities": map[string]any{
"tools": map[string]any{"listChanged": false},
"resources": map[string]any{"subscribe": false, "listChanged": false},
},
"serverInfo": map[string]any{
"name": "cms-authoring",
"version": "0.1.0",
},
})
case "ping":
writeMCPResult(w, request.ID, map[string]any{})
case "tools/list":
writeMCPResult(w, request.ID, map[string]any{"tools": externalAuthoringTools()})
case "resources/list":
writeMCPResult(w, request.ID, map[string]any{"resources": []map[string]any{
{
"uri": "cms://capabilities",
"name": "CMS authoring capabilities",
"description": "Supported proposal-only site, theme, and SCORM authoring operations.",
"mimeType": "application/json",
},
{
"uri": "cms://themes/schema",
"name": "CMS theme schema",
"description": "Allowed semantic theme tokens and validation constraints.",
"mimeType": "application/json",
},
{
"uri": "cms://sites/schema",
"name": "CMS site proposal schema",
"description": "Allowed site structure and page proposal fields.",
"mimeType": "application/json",
},
}})
case "resources/read":
if !a.mcpScopeAllowed(r, "cms.authoring.read") {
writeMCPError(w, request.ID, -32001, "insufficient OAuth scope")
return
}
a.handleMCPResourceRead(w, request)
case "tools/call":
a.handleMCPToolCall(w, r, request)
default:
writeMCPError(w, request.ID, -32601, "method not found")
}
}
func (a *app) handleMCPResourceRead(w http.ResponseWriter, request mcpRequest) {
var params struct {
URI string `json:"uri"`
}
if err := json.Unmarshal(request.Params, &params); err != nil || params.URI == "" {
writeMCPError(w, request.ID, -32602, "uri is required")
return
}
var resource any
switch params.URI {
case "cms://capabilities":
resource = map[string]any{
"modes": []string{"theme", "site", "scorm", "site_and_course"},
"proposalOnly": true,
"requiresCMSApproval": true,
"themeTools": []string{"theme_propose", "theme_validate"},
"siteTools": []string{"site_propose_structure", "site_upsert_page_proposal", "site_validate"},
}
case "cms://themes/schema":
resource = themeSchemaResource()
case "cms://sites/schema":
resource = siteSchemaResource()
default:
writeMCPError(w, request.ID, -32002, "resource not found")
return
}
encoded, err := json.Marshal(resource)
if err != nil {
writeMCPError(w, request.ID, -32603, "failed to encode resource")
return
}
writeMCPResult(w, request.ID, map[string]any{"contents": []map[string]any{
{"uri": params.URI, "mimeType": "application/json", "text": string(encoded)},
}})
}
func (a *app) handleMCPToolCall(w http.ResponseWriter, r *http.Request, request mcpRequest) {
var params struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
if err := json.Unmarshal(request.Params, &params); err != nil || params.Name == "" {
writeMCPError(w, request.ID, -32602, "tool name is required")
return
}
requiredScope := "cms.authoring.propose"
if params.Name == "authoring_get_status" || params.Name == "authoring_get_review_link" {
requiredScope = "cms.authoring.read"
}
if !a.mcpScopeAllowed(r, requiredScope) {
writeMCPError(w, request.ID, -32001, "insufficient OAuth scope")
return
}
result, err := a.callExternalAuthoringTool(r, params.Name, params.Arguments)
if err != nil {
writeMCPToolResult(w, request.ID, map[string]any{"error": err.Error()}, true)
return
}
writeMCPToolResult(w, request.ID, result, false)
}
func (a *app) mcpScopeAllowed(r *http.Request, required string) bool {
if a.cfg.AuthDisabled {
return true
}
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
return hasOAuthScope(claims, required)
}
func writeMCPToolResult(w http.ResponseWriter, id json.RawMessage, result any, isError bool) {
encoded, err := json.Marshal(result)
if err != nil {
writeMCPError(w, id, -32603, "failed to encode tool result")
return
}
writeMCPResult(w, id, map[string]any{
"content": []map[string]any{{"type": "text", "text": string(encoded)}},
"structuredContent": result,
"isError": isError,
})
}
func externalAuthoringTools() []mcpTool {
return []mcpTool{
newMCPTool("authoring_start", "Create a proposal-only authoring run. This tool never changes or publishes production content.", map[string]any{
"brief": map[string]any{"type": "object", "description": "Structured site, theme, or SCORM authoring brief."},
}, []string{"brief"}),
newMCPTool("authoring_get_status", "Get the status, assumptions, validation summary, and review state for an authoring run.", map[string]any{
"runId": map[string]any{"type": "string"},
}, []string{"runId"}),
newMCPTool("theme_propose", "Store a complete semantic theme proposal in an authoring run without applying it.", map[string]any{
"runId": map[string]any{"type": "string"},
"idempotencyKey": map[string]any{"type": "string"},
"theme": map[string]any{"type": "object"},
}, []string{"runId", "idempotencyKey", "theme"}),
newMCPTool("theme_validate", "Validate the proposed theme tokens and color contrast.", map[string]any{
"runId": map[string]any{"type": "string"},
}, []string{"runId"}),
newMCPTool("site_propose_structure", "Store a proposal-only site title and navigation structure without creating CMS pages.", map[string]any{
"runId": map[string]any{"type": "string"},
"idempotencyKey": map[string]any{"type": "string"},
"site": map[string]any{"type": "object"},
}, []string{"runId", "idempotencyKey", "site"}),
newMCPTool("site_upsert_page_proposal", "Store one complete normalized page tree in a site proposal without changing CMS pages.", map[string]any{
"runId": map[string]any{"type": "string"},
"idempotencyKey": map[string]any{"type": "string"},
"page": map[string]any{"type": "object"},
}, []string{"runId", "idempotencyKey", "page"}),
newMCPTool("site_validate", "Validate proposed page IDs, slugs, SEO, block trees, and navigation targets.", map[string]any{
"runId": map[string]any{"type": "string"},
}, []string{"runId"}),
newMCPTool("authoring_get_review_link", "Return an authenticated CMS review URL for a validated proposal.", map[string]any{
"runId": map[string]any{"type": "string"},
}, []string{"runId"}),
newMCPTool("authoring_discard", "Discard an unapplied proposal owned by the current user.", map[string]any{
"runId": map[string]any{"type": "string"},
}, []string{"runId"}),
}
}
func newMCPTool(name, description string, properties map[string]any, required []string) mcpTool {
return mcpTool{
Name: name,
Description: description,
InputSchema: map[string]any{
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": false,
},
}
}
func writeMCPResult(w http.ResponseWriter, id json.RawMessage, result any) {
writeJSON(w, http.StatusOK, mcpResponse{JSONRPC: "2.0", ID: id, Result: result})
}
func writeMCPError(w http.ResponseWriter, id json.RawMessage, code int, message string) {
writeJSON(w, http.StatusOK, mcpResponse{
JSONRPC: "2.0",
ID: id,
Error: &mcpError{Code: code, Message: message},
})
}