platform
This commit is contained in:
1 parent
e706f7d907
commit
21bcb63440
42 files changed
+5903
No files matched your search
@@ -0,0 +1,47 @@
|
||||
package mcp
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// JSON-RPC 2.0 envelope types (the wire format MCP rides on). Notifications are
|
||||
// requests with no id and must not receive a response.
|
||||
|
||||
type rpcRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// isNotification reports whether the message is a notification (no id), which
|
||||
// per JSON-RPC must not receive a response.
|
||||
func (r *rpcRequest) isNotification() bool { return len(r.ID) == 0 }
|
||||
|
||||
type rpcResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// JSON-RPC standard error codes.
|
||||
const (
|
||||
codeParseError = -32700
|
||||
codeInvalidRequest = -32600
|
||||
codeMethodNotFound = -32601
|
||||
codeInvalidParams = -32602
|
||||
codeInternalError = -32603
|
||||
)
|
||||
|
||||
func result(id json.RawMessage, v any) *rpcResponse {
|
||||
return &rpcResponse{JSONRPC: "2.0", ID: id, Result: v}
|
||||
}
|
||||
|
||||
func errorResponse(id json.RawMessage, code int, msg string) *rpcResponse {
|
||||
return &rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package mcp
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Tiny JSON-Schema builders for tool input schemas. They keep tool definitions
|
||||
// readable without a schema dependency.
|
||||
|
||||
// Props is a set of named property schemas for Object.
|
||||
type Props map[string]json.RawMessage
|
||||
|
||||
// Object builds an object JSON Schema from named property schemas and the list
|
||||
// of required keys. required may be nil.
|
||||
func Object(p Props, required []string) json.RawMessage {
|
||||
schema := map[string]any{"type": "object"}
|
||||
if len(p) > 0 {
|
||||
schema["properties"] = p
|
||||
} else {
|
||||
schema["properties"] = map[string]any{}
|
||||
}
|
||||
if len(required) > 0 {
|
||||
schema["required"] = required
|
||||
}
|
||||
b, _ := json.Marshal(schema)
|
||||
return b
|
||||
}
|
||||
|
||||
// Scalar builds a single-typed schema with a description (e.g. Scalar("integer", …)).
|
||||
func Scalar(typ, desc string) json.RawMessage {
|
||||
b, _ := json.Marshal(map[string]string{"type": typ, "description": desc})
|
||||
return b
|
||||
}
|
||||
|
||||
func String(desc string) json.RawMessage { return Scalar("string", desc) }
|
||||
func Integer(desc string) json.RawMessage { return Scalar("integer", desc) }
|
||||
func Number(desc string) json.RawMessage { return Scalar("number", desc) }
|
||||
func Bool(desc string) json.RawMessage { return Scalar("boolean", desc) }
|
||||
func ObjectValue(desc string) json.RawMessage {
|
||||
return Scalar("object", desc)
|
||||
}
|
||||
|
||||
// StringArray builds an array-of-strings schema.
|
||||
func StringArray(desc string) json.RawMessage {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"type": "array", "description": desc, "items": map[string]string{"type": "string"},
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
// ObjectArray builds an array-of-objects schema.
|
||||
func ObjectArray(desc string) json.RawMessage {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"type": "array", "description": desc, "items": map[string]string{"type": "object"},
|
||||
})
|
||||
return b
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
// Package mcp is the shared Model Context Protocol server core: JSON-RPC 2.0
|
||||
// over streamable HTTP, tool registration, and the tools/list + tools/call
|
||||
// dispatch. It is transport/protocol plumbing only — the tools themselves
|
||||
// (their domain logic) stay in each module. A module builds its []Tool and calls
|
||||
// NewServer; nothing here knows about carts, content, or orders.
|
||||
//
|
||||
// See docs/platform-uniforming-plan.md §3b and platform-event-messaging-sketch.md.
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// defaultProtocolVersion is advertised when Info.ProtocolVersion is empty.
|
||||
const defaultProtocolVersion = "2025-06-18"
|
||||
|
||||
const maxBodyBytes = 4 << 20 // 4 MiB request cap, matching the prior per-module servers.
|
||||
|
||||
// Info identifies the server in the MCP initialize handshake.
|
||||
type Info struct {
|
||||
Name string
|
||||
Version string
|
||||
ProtocolVersion string // optional; defaults to defaultProtocolVersion
|
||||
}
|
||||
|
||||
// Server is the MCP edge over a set of tools. Build it with NewServer and mount
|
||||
// Handler() (e.g. at /mcp). The tool slice is read on every request, so AddTool
|
||||
// is safe after Handler() is in use.
|
||||
type Server struct {
|
||||
info Info
|
||||
tools []Tool
|
||||
}
|
||||
|
||||
// NewServer builds an MCP server advertising the given tools.
|
||||
func NewServer(info Info, tools ...Tool) *Server {
|
||||
if info.ProtocolVersion == "" {
|
||||
info.ProtocolVersion = defaultProtocolVersion
|
||||
}
|
||||
return &Server{info: info, tools: tools}
|
||||
}
|
||||
|
||||
// AddTool appends a tool so it is advertised via tools/list and dispatched via
|
||||
// tools/call alongside the rest.
|
||||
func (s *Server) AddTool(t Tool) { s.tools = append(s.tools, t) }
|
||||
|
||||
// Handler returns the streamable-HTTP handler to mount (e.g. at /mcp).
|
||||
func (s *Server) Handler() http.Handler { return http.HandlerFunc(s.serveHTTP) }
|
||||
|
||||
func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes))
|
||||
if err != nil {
|
||||
writeRPC(w, errorResponse(nil, codeParseError, "read error"))
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
if isBatch(body) {
|
||||
var reqs []rpcRequest
|
||||
if err := json.Unmarshal(body, &reqs); err != nil {
|
||||
writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON"))
|
||||
return
|
||||
}
|
||||
var out []*rpcResponse
|
||||
for i := range reqs {
|
||||
if resp := s.dispatch(ctx, &reqs[i]); resp != nil {
|
||||
out = append(out, resp)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
writeRPC(w, out)
|
||||
return
|
||||
}
|
||||
var req rpcRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON"))
|
||||
return
|
||||
}
|
||||
resp := s.dispatch(ctx, &req)
|
||||
if resp == nil {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
writeRPC(w, resp)
|
||||
}
|
||||
|
||||
func (s *Server) dispatch(ctx context.Context, req *rpcRequest) *rpcResponse {
|
||||
if req.JSONRPC != "2.0" {
|
||||
if req.isNotification() {
|
||||
return nil
|
||||
}
|
||||
return errorResponse(req.ID, codeInvalidRequest, "jsonrpc must be \"2.0\"")
|
||||
}
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
return result(req.ID, s.initialize(req.Params))
|
||||
case "ping":
|
||||
return result(req.ID, struct{}{})
|
||||
case "tools/list":
|
||||
return result(req.ID, map[string]any{"tools": s.tools})
|
||||
case "tools/call":
|
||||
return s.callTool(ctx, req)
|
||||
case "notifications/initialized", "notifications/cancelled":
|
||||
return nil // notifications: acknowledged by the transport, no reply
|
||||
default:
|
||||
if req.isNotification() {
|
||||
return nil
|
||||
}
|
||||
return errorResponse(req.ID, codeMethodNotFound, "unknown method: "+req.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) initialize(params json.RawMessage) map[string]any {
|
||||
pv := s.info.ProtocolVersion
|
||||
if len(params) > 0 {
|
||||
var p struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
}
|
||||
if json.Unmarshal(params, &p) == nil && p.ProtocolVersion != "" {
|
||||
pv = p.ProtocolVersion
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"protocolVersion": pv,
|
||||
"capabilities": map[string]any{"tools": map[string]any{}},
|
||||
"serverInfo": map[string]any{"name": s.info.Name, "version": s.info.Version},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) callTool(ctx context.Context, req *rpcRequest) *rpcResponse {
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
}
|
||||
if err := json.Unmarshal(req.Params, &p); err != nil {
|
||||
return errorResponse(req.ID, codeInvalidParams, "invalid params")
|
||||
}
|
||||
// Strip (and log) the UCP-Agent meta sibling before handing args to the tool,
|
||||
// so it never leaks into the tool's argument namespace. No-op when absent.
|
||||
args := extractUCPAgentMeta(p.Arguments)
|
||||
|
||||
for i := range s.tools {
|
||||
if s.tools[i].Name == p.Name {
|
||||
out, err := s.tools[i].Invoke(ctx, args)
|
||||
if err != nil {
|
||||
return result(req.ID, ErrorResult(err))
|
||||
}
|
||||
return result(req.ID, Text(out))
|
||||
}
|
||||
}
|
||||
return errorResponse(req.ID, codeInvalidParams, "unknown tool: "+p.Name)
|
||||
}
|
||||
|
||||
func isBatch(body []byte) bool {
|
||||
for _, b := range body {
|
||||
switch b {
|
||||
case ' ', '\t', '\r', '\n':
|
||||
continue
|
||||
case '[':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeRPC(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Tool is one MCP tool: its advertised schema plus the Invoke func that parses
|
||||
// arguments, does the work, and returns a JSON-encodable payload. Invoke's error
|
||||
// is surfaced to the client as a tool error (isError: true), not a protocol
|
||||
// error. The context carries request scope (deadline, cancellation, trace span);
|
||||
// it flows from the HTTP request through to Invoke.
|
||||
type Tool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema json.RawMessage `json:"inputSchema"`
|
||||
Invoke func(ctx context.Context, args json.RawMessage) (any, error) `json:"-"`
|
||||
}
|
||||
|
||||
// Text wraps a payload as a successful tools/call result: one JSON text block.
|
||||
func Text(v any) any {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return ErrorResult(err)
|
||||
}
|
||||
return map[string]any{
|
||||
"content": []map[string]any{{"type": "text", "text": string(b)}},
|
||||
}
|
||||
}
|
||||
|
||||
// ErrorResult wraps an operational error as an isError tools/call result (a tool
|
||||
// failure the model can read, not a JSON-RPC protocol error).
|
||||
func ErrorResult(err error) any {
|
||||
return map[string]any{
|
||||
"isError": true,
|
||||
"content": []map[string]any{{"type": "text", "text": err.Error()}},
|
||||
}
|
||||
}
|
||||
|
||||
// Decode unmarshals tool arguments into v, treating empty/null args as a no-op
|
||||
// (zero value). Returns a wrapped error suitable for returning from Invoke.
|
||||
func Decode(args json.RawMessage, v any) error {
|
||||
if len(args) == 0 || string(args) == "null" {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(args, v); err != nil {
|
||||
return fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
)
|
||||
|
||||
// extractUCPAgentMeta checks whether the JSON arguments carry a "meta" key with
|
||||
// a nested "ucp-agent.profile" (the UCP-Agent profile advertisement, a sibling
|
||||
// of the real arguments per the UCP spec). If present it logs the profile for
|
||||
// observability and strips "meta" so it does not leak into the tool's argument
|
||||
// namespace. When absent it returns the arguments unchanged — a no-op for tools
|
||||
// that don't speak UCP.
|
||||
func extractUCPAgentMeta(arguments json.RawMessage) json.RawMessage {
|
||||
if len(arguments) == 0 {
|
||||
return arguments
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(arguments, &obj); err != nil {
|
||||
return arguments
|
||||
}
|
||||
metaRaw, hasMeta := obj["meta"]
|
||||
if !hasMeta {
|
||||
return arguments
|
||||
}
|
||||
var meta struct {
|
||||
UCPAgent *struct {
|
||||
Profile string `json:"profile"`
|
||||
} `json:"ucp-agent"`
|
||||
}
|
||||
if err := json.Unmarshal(metaRaw, &meta); err == nil && meta.UCPAgent != nil && meta.UCPAgent.Profile != "" {
|
||||
log.Printf("ucp-agent: profile=%s", meta.UCPAgent.Profile)
|
||||
}
|
||||
delete(obj, "meta")
|
||||
cleaned, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return arguments
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
Reference in new issue
Block a user