diff --git a/ingestion/internal/extract/docmark.go b/ingestion/internal/extract/docmark.go index 4281742..b3190ac 100644 --- a/ingestion/internal/extract/docmark.go +++ b/ingestion/internal/extract/docmark.go @@ -97,8 +97,13 @@ func extractViaDocmark(path string) (string, error) { return "", fmt.Errorf("docmark: HTTP %d: %s", resp.StatusCode, string(body)) } + jsonBody := body + if data := sseDataPayload(body); data != nil { + jsonBody = data + } + var out docmarkResponse - if err := json.Unmarshal(body, &out); err != nil { + if err := json.Unmarshal(jsonBody, &out); err != nil { return "", fmt.Errorf("decode docmark response: %w", err) } if out.Error != nil { @@ -114,6 +119,23 @@ func extractViaDocmark(path string) (string, error) { return text, nil } +// sseDataPayload extracts the JSON payload from an SSE-framed response body +// ("event: message\r\ndata: {...}\r\n\r\n"). docmark's Streamable-HTTP +// transport frames every response this way (Content-Type: text/event-stream) +// regardless of stateless_http — that flag removes the session/initialize +// requirement, not the SSE wire framing. Returns nil if body isn't SSE-framed +// (e.g. a plain-JSON response, kept as a fallback for forward-compatibility). +func sseDataPayload(body []byte) []byte { + const prefix = "data: " + for _, line := range bytes.Split(body, []byte("\n")) { + line = bytes.TrimRight(line, "\r") + if bytes.HasPrefix(line, []byte(prefix)) { + return bytes.TrimPrefix(line, []byte(prefix)) + } + } + return nil +} + // fileBase returns the final path segment (like filepath.Base, kept local to // avoid importing path/filepath just for this one call). func fileBase(path string) string { diff --git a/ingestion/internal/extract/docmark_test.go b/ingestion/internal/extract/docmark_test.go index 7ae6ae5..06da518 100644 --- a/ingestion/internal/extract/docmark_test.go +++ b/ingestion/internal/extract/docmark_test.go @@ -30,9 +30,17 @@ type mcpToolResult struct { } `json:"error,omitempty"` } +// writeMCPResponse mirrors docmark's REAL response framing (empirically +// confirmed against the live server): Content-Type: text/event-stream, +// body is SSE-framed ("event: message\r\ndata: {...}\r\n\r\n"), not bare +// JSON -- inherent to MCP Streamable-HTTP, independent of stateless_http. func writeMCPResponse(w http.ResponseWriter, body mcpToolResult) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(body) + payload, _ := json.Marshal(body) + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("event: message\r\ndata: ")) + _, _ = w.Write(payload) + _, _ = w.Write([]byte("\r\n\r\n")) } func TestExtractViaDocmark_Success(t *testing.T) {