返回 DeepSeek-Reasonix
transport_http.go
根目录 / internal / plugin / transport_http.go
1 package plugin
2
3 import (
4 "bufio"
5 "bytes"
6 "context"
7 "encoding/json"
8 "fmt"
9 "io"
10 "net/http"
11 "net/url"
12 "strings"
13 "sync"
14
15 "reasonix/internal/tool"
16 )
17
18 // maxHTTPBody caps how much of a JSON / SSE response body we read, so a
19 // misbehaving server can't make us buffer without bound.
20 const maxHTTPBody = 16 << 20 // 16 MiB
21
22 // httpTransport speaks MCP's Streamable HTTP transport: every JSON-RPC message
23 // is an HTTP POST to the server URL. The server replies with either
24 // application/json (one response) or text/event-stream (an SSE stream carrying
25 // the response plus any server notifications). The Mcp-Session-Id header, once
26 // the server assigns one, is echoed on every subsequent request.
27 //
28 // The mutex serialises a request and its response. That means concurrent tool
29 // calls to the *same* server run one at a time; calls to different servers use
30 // different transports and stay concurrent. Correctness over latency for P1 —
31 // it also keeps nextID and the session id race-free.
32 type httpTransport struct {
33 name string
34 url string
35 headers map[string]string
36 client *http.Client
37 roots []mcpRoot
38 progress progressRouter
39
40 mu sync.Mutex
41 nextID int
42 session string // Mcp-Session-Id, captured from responses
43 }
44
45 func newHTTPTransport(s Spec) (*httpTransport, error) {
46 if s.URL == "" {
47 return nil, fmt.Errorf("http plugin %q: url is required", s.Name)
48 }
49 headers := make(map[string]string, len(s.Headers))
50 for key, value := range s.Headers {
51 headers[key] = value
52 }
53 return &httpTransport{
54 name: s.Name,
55 url: s.URL,
56 headers: headers,
57 roots: mcpRoots(s.WorkspaceRoot),
58 client: &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
59 if len(via) == 0 || sameHTTPOrigin(via[0].URL, req.URL) {
60 return nil
61 }
62 // Do not send configured credentials to another origin. Returning
63 // ErrUseLastResponse exposes the 3xx to the normal status handling
64 // without issuing the redirected request.
65 return http.ErrUseLastResponse
66 }},
67 }, nil
68 }
69
70 func sameHTTPOrigin(a, b *url.URL) bool {
71 if a == nil || b == nil || !strings.EqualFold(a.Scheme, b.Scheme) || !strings.EqualFold(a.Hostname(), b.Hostname()) {
72 return false
73 }
74 effectivePort := func(u *url.URL) string {
75 if port := u.Port(); port != "" {
76 return port
77 }
78 switch strings.ToLower(u.Scheme) {
79 case "http":
80 return "80"
81 case "https":
82 return "443"
83 default:
84 return ""
85 }
86 }
87 return effectivePort(a) == effectivePort(b)
88 }
89
90 func (t *httpTransport) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
91 t.mu.Lock()
92 defer t.mu.Unlock()
93
94 t.nextID++
95 id := t.nextID
96 body, err := json.Marshal(rpcRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params})
97 if err != nil {
98 return nil, err
99 }
100
101 resp, err := t.do(ctx, body)
102 if err != nil {
103 return nil, fmt.Errorf("plugin %q: %s: %w", t.name, method, err)
104 }
105 defer resp.Body.Close()
106 t.captureSession(resp)
107
108 if resp.StatusCode/100 != 2 {
109 b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
110 msg := strings.TrimSpace(string(b))
111 if isHTTPSessionExpiredResponse(resp.StatusCode, b) {
112 t.session = ""
113 return nil, fmt.Errorf("plugin %q: %s: %w", t.name, method, &httpSessionExpiredError{
114 status: resp.StatusCode,
115 body: msg,
116 })
117 }
118 return nil, fmt.Errorf("plugin %q: %s: http %d: %s", t.name, method, resp.StatusCode, msg)
119 }
120
121 if strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream") {
122 return t.readSSEResponse(ctx, resp.Body, id)
123 }
124 return decodeRPCResult(resp.Body, t.name)
125 }
126
127 func (t *httpTransport) notify(ctx context.Context, method string, params any) error {
128 t.mu.Lock()
129 defer t.mu.Unlock()
130
131 body, err := json.Marshal(rpcRequest{JSONRPC: "2.0", Method: method, Params: params})
132 if err != nil {
133 return err
134 }
135 resp, err := t.do(ctx, body)
136 if err != nil {
137 return fmt.Errorf("plugin %q: %s: %w", t.name, method, err)
138 }
139 defer resp.Body.Close()
140 t.captureSession(resp)
141 _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxHTTPBody))
142 if resp.StatusCode/100 != 2 {
143 return fmt.Errorf("plugin %q: %s: http %d", t.name, method, resp.StatusCode)
144 }
145 return nil
146 }
147
148 func (t *httpTransport) close() {
149 t.client.CloseIdleConnections()
150 }
151
152 func (t *httpTransport) registerProgress(token string, sink tool.ProgressFunc) func() {
153 return t.progress.registerProgress(token, sink)
154 }
155
156 // do POSTs one JSON-RPC body with the standard MCP headers, the configured
157 // static headers, and the session id (once known). Caller holds t.mu.
158 func (t *httpTransport) do(ctx context.Context, body []byte) (*http.Response, error) {
159 req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.url, bytes.NewReader(body))
160 if err != nil {
161 return nil, err
162 }
163 req.Header.Set("Content-Type", "application/json")
164 req.Header.Set("Accept", "application/json, text/event-stream")
165 for k, v := range t.headers {
166 req.Header.Set(k, v)
167 }
168 if t.session != "" {
169 req.Header.Set("Mcp-Session-Id", t.session)
170 }
171 return t.client.Do(req)
172 }
173
174 func (t *httpTransport) captureSession(resp *http.Response) {
175 if sid := resp.Header.Get("Mcp-Session-Id"); sid != "" {
176 t.session = sid
177 }
178 }
179
180 type httpSessionExpiredError struct {
181 status int
182 body string
183 }
184
185 func (e *httpSessionExpiredError) Error() string {
186 if e.body == "" {
187 return fmt.Sprintf("http %d: MCP session expired", e.status)
188 }
189 return fmt.Sprintf("http %d: %s", e.status, e.body)
190 }
191
192 func isHTTPSessionExpiredResponse(status int, body []byte) bool {
193 if status != http.StatusNotFound {
194 return false
195 }
196 var resp rpcResponse
197 if err := json.Unmarshal(bytes.TrimSpace(body), &resp); err != nil || resp.Error == nil {
198 return false
199 }
200 return resp.Error.Code == -32001 && strings.Contains(strings.ToLower(resp.Error.Message), "session not found")
201 }
202
203 // readSSEResponse scans an SSE stream for the JSON-RPC response matching id,
204 // skipping server notifications and any other-id messages. Per the SSE spec,
205 // consecutive data: lines within one event are joined with "\n" and an event is
206 // dispatched on the blank line that terminates it.
207 func (t *httpTransport) readSSEResponse(ctx context.Context, body io.Reader, id int) (json.RawMessage, error) {
208 sc := bufio.NewScanner(io.LimitReader(body, maxHTTPBody))
209 sc.Buffer(make([]byte, 0, 64*1024), maxHTTPBody)
210
211 var data strings.Builder
212 // match reports whether the accumulated event data is our response; it
213 // returns (result, matched, error).
214 match := func() (json.RawMessage, bool, error) {
215 if data.Len() == 0 {
216 return nil, false, nil
217 }
218 payload := data.String()
219 data.Reset()
220 message, ok := decodeInboundMessage([]byte(payload))
221 if !ok {
222 return nil, false, nil // not a JSON-RPC message we care about
223 }
224 if message.Method != "" {
225 if isNotificationID(message.ID) {
226 if message.Method == "notifications/progress" {
227 t.progress.dispatchProgress(message.Params)
228 }
229 return nil, false, nil
230 }
231 if err := t.replyServerRequest(ctx, message); err != nil {
232 return nil, false, err
233 }
234 return nil, false, nil
235 }
236 var resp rpcResponse
237 if err := json.Unmarshal([]byte(payload), &resp); err != nil {
238 return nil, false, nil
239 }
240 if resp.ID != id {
241 return nil, false, nil // a notification or another call's response
242 }
243 if resp.Error != nil {
244 return nil, false, fmt.Errorf("plugin %q: %w", t.name, resp.Error)
245 }
246 return resp.Result, true, nil
247 }
248
249 for sc.Scan() {
250 line := sc.Text()
251 if line == "" { // event boundary
252 if res, ok, err := match(); err != nil || ok {
253 return res, err
254 }
255 continue
256 }
257 if v, found := strings.CutPrefix(line, "data:"); found {
258 if data.Len() > 0 {
259 data.WriteByte('\n')
260 }
261 data.WriteString(strings.TrimPrefix(v, " "))
262 }
263 // event:, id:, retry: and comments (":") are ignored
264 }
265 if err := sc.Err(); err != nil {
266 return nil, fmt.Errorf("plugin %q: read SSE: %w", t.name, err)
267 }
268 if res, ok, err := match(); err != nil || ok { // stream ended on a final unterminated event
269 return res, err
270 }
271 return nil, fmt.Errorf("plugin %q: SSE stream ended without a response to id %d", t.name, id)
272 }
273
274 // replyServerRequest sends a JSON-RPC response on a separate Streamable HTTP
275 // POST while the original response stream remains open. call holds t.mu here,
276 // so do can safely read the current session id without taking another lock.
277 func (t *httpTransport) replyServerRequest(ctx context.Context, message inboundMessage) error {
278 body, err := json.Marshal(serverRequestReply(message.ID, message.Method, t.roots))
279 if err != nil {
280 return err
281 }
282 resp, err := t.do(ctx, body)
283 if err != nil {
284 return fmt.Errorf("plugin %q: reply to %s: %w", t.name, message.Method, err)
285 }
286 defer resp.Body.Close()
287 t.captureSession(resp)
288 _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxHTTPBody))
289 if resp.StatusCode/100 != 2 {
290 return fmt.Errorf("plugin %q: reply to %s: http %d", t.name, message.Method, resp.StatusCode)
291 }
292 return nil
293 }
294
295 // decodeRPCResult parses a single application/json JSON-RPC response body.
296 func decodeRPCResult(body io.Reader, name string) (json.RawMessage, error) {
297 b, err := io.ReadAll(io.LimitReader(body, maxHTTPBody))
298 if err != nil {
299 return nil, fmt.Errorf("plugin %q: read response: %w", name, err)
300 }
301 var resp rpcResponse
302 if err := json.Unmarshal(bytes.TrimSpace(b), &resp); err != nil {
303 return nil, fmt.Errorf("plugin %q: decode response: %w", name, err)
304 }
305 if resp.Error != nil {
306 return nil, fmt.Errorf("plugin %q: %w", name, resp.Error)
307 }
308 return resp.Result, nil
309 }
310
310 lines GO