278 lines
9.4 KiB
Go
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, ¶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},
|
|
})
|
|
} |