tinyrelayfile
tinyrelay/internal/mcp/server.go/

internal/mcp/server.go raw
1package mcp
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "io"
9 "mime"
10 "net/http"
11 "strings"
12)
13
14// Server serves the MCP endpoint. Every request is authenticated, validated
15// against its mirrored headers and routed independently; no state survives
16// between requests.
17type Server struct {
18 // Tools is the tool table served by tools/list and tools/call.
19 Tools *Registry
20 // Info identifies this server in every result's _meta.
21 Info Implementation
22 // Instructions is optional guidance returned by server/discover.
23 Instructions string
24 // Authenticate resolves the caller from the request and its body. An
25 // error answers 401 with Challenge in WWW-Authenticate.
26 Authenticate func(r *http.Request, body []byte) (string, error)
27 // Challenge is the WWW-Authenticate value sent with 401.
28 Challenge string
29 // Origin returns the origin this endpoint is served from. A request whose
30 // Origin header names another origin is refused with 403.
31 Origin func(r *http.Request) string
32 // MaxBodyBytes bounds the request body. Zero means one megabyte.
33 MaxBodyBytes int64
34}
35
36// Observation summarizes one handled request for telemetry. It never carries
37// the caller's identity.
38type Observation struct {
39 Method string
40 Tool string
41 Outcome string
42 Status int
43}
44
45const (
46 OutcomeOK = "ok"
47 OutcomeInvalid = "invalid"
48 OutcomeUnauthorized = "unauthorized"
49 OutcomeError = "error"
50)
51
52const (
53 toolListTTL = 5 * 60 * 1000
54 discoverTTL = 60 * 60 * 1000
55 defaultBody = 1 << 20
56)
57
58// ServeHTTP handles one POST to the MCP endpoint.
59func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.Handle(w, r) }
60
61// Handle serves the request and reports what happened.
62func (s *Server) Handle(w http.ResponseWriter, r *http.Request) Observation {
63 w.Header().Set("Cache-Control", "no-store")
64 if r.Method != http.MethodPost {
65 w.Header().Set("Allow", "POST")
66 http.Error(w, "method not allowed: the MCP endpoint accepts POST", http.StatusMethodNotAllowed)
67 return Observation{Outcome: OutcomeInvalid, Status: http.StatusMethodNotAllowed}
68 }
69 if origin := r.Header.Get("Origin"); origin != "" && s.Origin != nil && !strings.EqualFold(origin, s.Origin(r)) {
70 return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusForbidden, "origin %s is not allowed", origin), Observation{})
71 }
72 limit := s.MaxBodyBytes
73 if limit <= 0 {
74 limit = defaultBody
75 }
76 body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit))
77 if err != nil {
78 var tooLarge *http.MaxBytesError
79 if errors.As(err, &tooLarge) {
80 return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusRequestEntityTooLarge, "request body exceeds %d bytes", limit), Observation{})
81 }
82 return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusBadRequest, "request body could not be read"), Observation{})
83 }
84 actor := ""
85 if s.Authenticate != nil {
86 actor, err = s.Authenticate(r, body)
87 if err != nil {
88 if s.Challenge != "" {
89 w.Header().Set("WWW-Authenticate", s.Challenge)
90 }
91 return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusUnauthorized, "%s", err.Error()), Observation{})
92 }
93 }
94 if accept := r.Header.Get("Accept"); accept != "" && !accepts(accept, "application/json") {
95 return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusNotAcceptable, "Accept must include application/json"), Observation{})
96 }
97 if media, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); media != "application/json" {
98 return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusUnsupportedMediaType, "Content-Type must be application/json"), Observation{})
99 }
100 var request Request
101 if err := json.Unmarshal(body, &request); err != nil || !bytes.HasPrefix(bytes.TrimSpace(body), []byte("{")) {
102 return s.fail(w, nil, errorf(CodeParse, http.StatusBadRequest, "body must be a single JSON-RPC request or notification"), Observation{})
103 }
104 obs := Observation{Method: request.Method}
105 if request.JSONRPC != "2.0" || request.Method == "" {
106 return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusBadRequest, "jsonrpc must be \"2.0\" and method is required"), obs)
107 }
108 if !request.Notification() && !validID(request.ID) {
109 return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusBadRequest, "id must be a string or a number"), obs)
110 }
111 if request.Notification() {
112 w.WriteHeader(http.StatusAccepted)
113 obs.Outcome, obs.Status = OutcomeOK, http.StatusAccepted
114 return obs
115 }
116 var p params
117 if len(request.Params) > 0 {
118 if err := json.Unmarshal(request.Params, &p); err != nil {
119 return s.fail(w, request.ID, errorf(CodeInvalidParams, http.StatusBadRequest, "params must be an object"), obs)
120 }
121 }
122 if err := validateHeaders(r, request, p); err != nil {
123 return s.fail(w, request.ID, err, obs)
124 }
125 var result any
126 var rpcErr *Error
127 switch request.Method {
128 case "server/discover":
129 result = s.discover()
130 case "tools/list":
131 result = s.list()
132 case "tools/call":
133 obs.Tool = p.Name
134 var tool Result
135 tool, rpcErr = s.call(r.Context(), r, actor, p)
136 if rpcErr == nil {
137 result = s.result(map[string]any{"content": tool.Content, "structuredContent": tool.StructuredContent, "isError": tool.IsError})
138 if tool.IsError {
139 obs.Outcome = OutcomeInvalid
140 }
141 }
142 default:
143 rpcErr = errorf(CodeMethodNotFound, http.StatusNotFound, "method %s is not supported", request.Method)
144 }
145 if rpcErr != nil {
146 return s.fail(w, request.ID, rpcErr, obs)
147 }
148 if obs.Outcome == "" {
149 obs.Outcome = OutcomeOK
150 }
151 obs.Status = http.StatusOK
152 write(w, http.StatusOK, Response{JSONRPC: "2.0", ID: request.ID, Result: result})
153 return obs
154}
155
156func (s *Server) fail(w http.ResponseWriter, id json.RawMessage, err *Error, obs Observation) Observation {
157 status := err.Status
158 if status == 0 {
159 status = http.StatusBadRequest
160 }
161 obs.Status = status
162 switch {
163 case status == http.StatusUnauthorized:
164 obs.Outcome = OutcomeUnauthorized
165 case status >= 500:
166 obs.Outcome = OutcomeError
167 default:
168 obs.Outcome = OutcomeInvalid
169 }
170 write(w, status, Response{JSONRPC: "2.0", ID: id, Error: err})
171 return obs
172}
173
174// validateHeaders enforces the request metadata rules of the Streamable HTTP
175// binding: the protocol version in the header and in _meta must agree and be
176// supported, Mcp-Method must match the method, and Mcp-Name must match the
177// tool name of a tools/call request.
178func validateHeaders(r *http.Request, request Request, p params) *Error {
179 version := r.Header.Get(HeaderProtocolVersion)
180 if version == "" {
181 return errorf(CodeHeaderMismatch, http.StatusBadRequest, "missing %s header; this server speaks %s", HeaderProtocolVersion, strings.Join(SupportedVersions, ", "))
182 }
183 if !supported(version) {
184 return &Error{Code: CodeUnsupportedProtocolVersion, Status: http.StatusBadRequest, Message: "Unsupported protocol version", Data: map[string]any{"supported": SupportedVersions, "requested": version}}
185 }
186 metaVersion, ok := metaString(p.Meta, MetaProtocolVersion)
187 if !ok {
188 return errorf(CodeInvalidParams, http.StatusBadRequest, "params._meta[%q] is required", MetaProtocolVersion)
189 }
190 if metaVersion != version {
191 return errorf(CodeHeaderMismatch, http.StatusBadRequest, "Header mismatch: %s header value '%s' does not match body value '%s'", HeaderProtocolVersion, version, metaVersion)
192 }
193 if capabilities, present := p.Meta[MetaClientCapabilities]; !present || !bytes.HasPrefix(bytes.TrimSpace(capabilities), []byte("{")) {
194 return errorf(CodeInvalidParams, http.StatusBadRequest, "params._meta[%q] is required; send {} when the client has none", MetaClientCapabilities)
195 }
196 method := r.Header.Get(HeaderMethod)
197 if method == "" {
198 return errorf(CodeHeaderMismatch, http.StatusBadRequest, "missing %s header", HeaderMethod)
199 }
200 if method != request.Method {
201 return errorf(CodeHeaderMismatch, http.StatusBadRequest, "Header mismatch: %s header value '%s' does not match body value '%s'", HeaderMethod, method, request.Method)
202 }
203 source := ""
204 switch request.Method {
205 case "tools/call", "prompts/get":
206 source = p.Name
207 case "resources/read":
208 source = p.URI
209 default:
210 return nil
211 }
212 raw := r.Header.Get(HeaderName)
213 if raw == "" {
214 return errorf(CodeHeaderMismatch, http.StatusBadRequest, "missing %s header", HeaderName)
215 }
216 name, err := DecodeHeaderValue(raw)
217 if err != nil {
218 return errorf(CodeHeaderMismatch, http.StatusBadRequest, "Header mismatch: %s %s", HeaderName, err.Error())
219 }
220 if name != source {
221 return errorf(CodeHeaderMismatch, http.StatusBadRequest, "Header mismatch: %s header value '%s' does not match body value '%s'", HeaderName, name, source)
222 }
223 return nil
224}
225
226func (s *Server) discover() map[string]any {
227 return s.result(map[string]any{
228 "supportedVersions": SupportedVersions,
229 "capabilities": map[string]any{"tools": map[string]any{}},
230 "instructions": s.Instructions,
231 "ttlMs": discoverTTL,
232 "cacheScope": "private",
233 })
234}
235
236func (s *Server) list() map[string]any {
237 tools := s.Tools.Tools()
238 if tools == nil {
239 tools = []Tool{}
240 }
241 return s.result(map[string]any{"tools": tools, "ttlMs": toolListTTL, "cacheScope": "private"})
242}
243
244func (s *Server) call(ctx context.Context, r *http.Request, actor string, p params) (Result, *Error) {
245 if p.Name == "" {
246 return Result{}, errorf(CodeInvalidParams, http.StatusBadRequest, "params.name is required")
247 }
248 tool, ok := s.Tools.Lookup(p.Name)
249 if !ok {
250 return Result{}, errorf(CodeInvalidParams, http.StatusBadRequest, "Unknown tool: %s", p.Name)
251 }
252 arguments := map[string]any{}
253 if len(p.Arguments) > 0 && string(p.Arguments) != "null" {
254 if err := json.Unmarshal(p.Arguments, &arguments); err != nil {
255 return Result{}, errorf(CodeInvalidParams, http.StatusBadRequest, "params.arguments must be an object")
256 }
257 }
258 if err := Validate(tool.InputSchema, arguments); err != nil {
259 return Failure(err.Error(), map[string]any{"inputSchema": tool.InputSchema}).filled(), nil
260 }
261 result, err := tool.Handler(ctx, Call{Actor: actor, Name: tool.Name, Arguments: arguments, Request: r})
262 if err != nil {
263 return Failure(err.Error(), nil).filled(), nil
264 }
265 return result.filled(), nil
266}
267
268func (s *Server) result(fields map[string]any) map[string]any {
269 fields["resultType"] = "complete"
270 if s.Info.Name != "" {
271 fields["_meta"] = map[string]any{MetaServerInfo: s.Info}
272 }
273 return fields
274}
275
276func metaString(meta map[string]json.RawMessage, key string) (string, bool) {
277 raw, ok := meta[key]
278 if !ok {
279 return "", false
280 }
281 var value string
282 if err := json.Unmarshal(raw, &value); err != nil {
283 return "", false
284 }
285 return value, true
286}
287
288func validID(id json.RawMessage) bool {
289 trimmed := bytes.TrimSpace(id)
290 if len(trimmed) == 0 || string(trimmed) == "null" {
291 return false
292 }
293 return trimmed[0] == '"' || trimmed[0] == '-' || (trimmed[0] >= '0' && trimmed[0] <= '9')
294}
295
296func accepts(header, media string) bool {
297 for _, part := range strings.Split(header, ",") {
298 value := strings.TrimSpace(strings.Split(part, ";")[0])
299 if value == media || value == "*/*" || value == strings.Split(media, "/")[0]+"/*" {
300 return true
301 }
302 }
303 return false
304}
305
306func write(w http.ResponseWriter, status int, response Response) {
307 raw, err := json.Marshal(response)
308 if err != nil {
309 http.Error(w, "could not encode response", http.StatusInternalServerError)
310 return
311 }
312 w.Header().Set("Content-Type", "application/json")
313 w.WriteHeader(status)
314 _, _ = w.Write(raw)
315}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs