Add specstream MCP server (main.go) and fix .gitignore
This commit is contained in:
parent
ecf100b917
commit
2d7a770ff9
2 changed files with 221 additions and 1 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
||||||
specstream
|
# The compiled binary
|
||||||
|
/specstream
|
||||||
/tmp/
|
/tmp/
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
|
|
|
||||||
219
cmd/specstream/main.go
Normal file
219
cmd/specstream/main.go
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
// specstream-mcp — MCP server that exposes SpecStream extraction as Hermes tools.
|
||||||
|
//
|
||||||
|
// Port: 3991 (alongside forgejo=3988, ob1=3987, pool-manager=3990)
|
||||||
|
//
|
||||||
|
// Hermes integration:
|
||||||
|
// mcp_servers:
|
||||||
|
// specstream:
|
||||||
|
// url: http://127.0.0.1:3991
|
||||||
|
// enabled: true
|
||||||
|
//
|
||||||
|
// This provides a direct replacement for Hermes' web_extract auxiliary.
|
||||||
|
// Instead of passing raw web content into the agent's context, the agent
|
||||||
|
// calls specstream_extract and receives typed, structured data.
|
||||||
|
// No raw text from untrusted sources ever enters the agent.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/sscoble/specstream/pkg/pipeline"
|
||||||
|
"github.com/sscoble/specstream/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
var extractPipeline = pipeline.New()
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
port := os.Getenv("SPECSTREAM_PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "3991"
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/", jsonRPCHandler)
|
||||||
|
|
||||||
|
addr := "127.0.0.1:" + port
|
||||||
|
log.Printf("[specstream] listening on %s", addr)
|
||||||
|
log.Printf("[specstream] MCP tools: specstream_extract, specstream_info")
|
||||||
|
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||||
|
log.Fatalf("[specstream] %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON-RPC Handler ───────────────────────
|
||||||
|
|
||||||
|
type jsonRPCRequest struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params json.RawMessage `json:"params"`
|
||||||
|
ID interface{} `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonRPCResponse struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
Result interface{} `json:"result,omitempty"`
|
||||||
|
Error *rpcError `json:"error,omitempty"`
|
||||||
|
ID interface{} `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonRPCHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
writeError(w, nil, -32700, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req jsonRPCRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, nil, -32700, "parse error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.JSONRPC != "2.0" {
|
||||||
|
writeError(w, req.ID, -32600, "invalid JSON-RPC version")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var result interface{}
|
||||||
|
var rpcErr *rpcError
|
||||||
|
|
||||||
|
switch req.Method {
|
||||||
|
case "specstream_extract":
|
||||||
|
result, rpcErr = handleExtract(req.Params)
|
||||||
|
case "specstream_info":
|
||||||
|
result, rpcErr = handleInfo(req.Params)
|
||||||
|
default:
|
||||||
|
rpcErr = &rpcError{Code: -32601, Message: fmt.Sprintf("method not found: %s", req.Method)}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rpcErr != nil {
|
||||||
|
writeError(w, req.ID, rpcErr.Code, rpcErr.Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeResult(w, req.ID, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tool Handlers ──────────────────────────
|
||||||
|
|
||||||
|
type extractParams struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
MaxSize int `json:"max_size"`
|
||||||
|
ExtractSections bool `json:"extract_sections"`
|
||||||
|
ExtractCode bool `json:"extract_code"`
|
||||||
|
ExtractLinks bool `json:"extract_links"`
|
||||||
|
ExtractImages bool `json:"extract_images"`
|
||||||
|
ExtractTables bool `json:"extract_tables"`
|
||||||
|
ExtractLists bool `json:"extract_lists"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleExtract(raw json.RawMessage) (interface{}, *rpcError) {
|
||||||
|
var params extractParams
|
||||||
|
if len(raw) > 0 {
|
||||||
|
if err := json.Unmarshal(raw, ¶ms); err != nil {
|
||||||
|
return nil, &rpcError{Code: -32602, Message: "invalid params: " + err.Error()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.URL == "" && params.Content == "" {
|
||||||
|
return nil, &rpcError{Code: -32602, Message: "either 'url' or 'content' is required"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.MaxSize <= 0 || params.MaxSize > 1<<20 {
|
||||||
|
params.MaxSize = 1 << 20
|
||||||
|
}
|
||||||
|
|
||||||
|
req := types.ExtractRequest{
|
||||||
|
URL: params.URL,
|
||||||
|
Content: params.Content,
|
||||||
|
Filename: params.Filename,
|
||||||
|
MaxSize: params.MaxSize,
|
||||||
|
// Default to extracting everything unless explicitly restricted
|
||||||
|
ExtractSections: !params.ExtractSections || params.ExtractSections,
|
||||||
|
ExtractCode: !params.ExtractCode || params.ExtractCode,
|
||||||
|
ExtractLinks: !params.ExtractLinks || params.ExtractLinks,
|
||||||
|
ExtractImages: !params.ExtractImages || params.ExtractImages,
|
||||||
|
ExtractTables: !params.ExtractTables || params.ExtractTables,
|
||||||
|
ExtractLists: !params.ExtractLists || params.ExtractLists,
|
||||||
|
}
|
||||||
|
|
||||||
|
var result *types.ExtractedContent
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if params.URL != "" {
|
||||||
|
result, err = extractPipeline.FetchAndExtract(params.URL, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &rpcError{Code: -32000, Message: "extraction failed: " + err.Error()}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result, err = extractPipeline.Extract([]byte(params.Content), req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &rpcError{Code: -32000, Message: "extraction failed: " + err.Error()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type infoResult struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Extractors []string `json:"extractors"`
|
||||||
|
Filters []string `json:"filters"`
|
||||||
|
MaxSize int64 `json:"max_size_bytes"`
|
||||||
|
Schema string `json:"schema"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleInfo(_ json.RawMessage) (interface{}, *rpcError) {
|
||||||
|
// Get filter names
|
||||||
|
filterNames := []string{
|
||||||
|
"strip_null_bytes",
|
||||||
|
"strip_control_chars",
|
||||||
|
"truncate_paragraphs_5000",
|
||||||
|
"strip_injection_patterns",
|
||||||
|
}
|
||||||
|
|
||||||
|
return infoResult{
|
||||||
|
Version: "specstream/0.1.0",
|
||||||
|
Extractors: []string{
|
||||||
|
"html (goquery, sections+links+images+code+tables+lists)",
|
||||||
|
"markdown (goldmark, sections+code+lists+tables)",
|
||||||
|
"text (plain text, sections by line breaks)",
|
||||||
|
},
|
||||||
|
Filters: filterNames,
|
||||||
|
MaxSize: 1 << 20,
|
||||||
|
Schema: "ExtractedContent with typed Sections, CodeBlocks, Links, Images, Lists, Tables — no raw text fields",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Response Helpers ───────────────────────
|
||||||
|
|
||||||
|
func writeResult(w http.ResponseWriter, id interface{}, result interface{}) {
|
||||||
|
resp := jsonRPCResponse{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
Result: result,
|
||||||
|
ID: id,
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, id interface{}, code int, message string) {
|
||||||
|
resp := jsonRPCResponse{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
Error: &rpcError{Code: code, Message: message},
|
||||||
|
ID: id,
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(200) // JSON-RPC always returns 200
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue