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, ¶ms); 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, ¶ms); 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}, }) }