// Command ottobib-mcp exposes ISBN lookup to an agent over the Model Context Protocol. // // WHY A SEPARATE BINARY, and not an endpoint on the service. The edge config this repo pushes // says, in as many words, that ottobib is "a CLOSED path set, GET only, no endpoint taking a // body" — a deliberate posture, and the reason the edge can abort anything unrecognised // without reading it. MCP needs POST with a JSON-RPC body. Adding that to the serving path // would trade a stated security property for a distribution convenience, on the one process // that answers paying traffic. // // This speaks MCP over stdio and calls the same public HTTP API any other client would, so // the service keeps its posture and this can be rewritten without touching it. A remote HTTP // transport is a reasonable thing to want later; it is an operator decision, not a detail. // // THE STDIO CONTRACT IS THE WHOLE RISK. stdout carries newline-delimited JSON-RPC and nothing // else: one stray log line, one fmt.Println left in, and every client's parser desynchronises // on a message it cannot read. Logs go to stderr, always, and there is a test that asserts it. package main import ( "bufio" "encoding/json" "fmt" "io" "net/http" "os" "strings" "time" ) // defaultProtocol is what we claim when a client does not ask for a version. When it does ask // we ECHO its version back rather than insisting on ours: a server that answers a version // negotiation with an unrelated number is how a working client gets refused. const defaultProtocol = "2025-06-18" const defaultBase = "https://www.ottobib.com" var styles = []string{"mla", "apa", "chicago", "wikipedia", "bibtex"} type rpc struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id,omitempty"` Method string `json:"method,omitempty"` Params json.RawMessage `json:"params,omitempty"` } type rpcError struct { Code int `json:"code"` Message string `json:"message"` } type response struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id"` Result any `json:"result,omitempty"` Error *rpcError `json:"error,omitempty"` } type server struct { base string http *http.Client } func (s *server) result(id json.RawMessage, v any) *response { return &response{JSONRPC: "2.0", ID: id, Result: v} } func (s *server) fail(id json.RawMessage, code int, msg string) *response { return &response{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}} } // handle returns the response to one message, or nil when none is owed. // // nil is not an error path — it is the NOTIFICATION path. A JSON-RPC notification has no id // and must receive no reply; answering one is a protocol violation that some clients treat as // a fatal desync rather than ignoring. func (s *server) handle(m rpc) *response { if m.ID == nil { return nil } switch m.Method { case "initialize": var p struct { ProtocolVersion string `json:"protocolVersion"` } _ = json.Unmarshal(m.Params, &p) v := p.ProtocolVersion if v == "" { v = defaultProtocol } return s.result(m.ID, map[string]any{ "protocolVersion": v, "capabilities": map[string]any{"tools": map[string]any{}}, "serverInfo": map[string]any{"name": "ottobib", "version": version}, "instructions": "Look up a book by ISBN and return a formatted citation. " + "Accepts ISBN-10 or ISBN-13, with or without hyphens.", }) case "tools/list": return s.result(m.ID, map[string]any{"tools": []any{map[string]any{ "name": "cite_isbn", "description": "Look up a book by ISBN and return a formatted bibliographic " + "citation from the immutable local publication. Returns all five styles unless one is named. A 200 found:false ends the immediate lookup attempt. Temporary failures follow Retry-After.", "inputSchema": map[string]any{ "type": "object", "properties": map[string]any{ "isbn": map[string]any{ "type": "string", "description": "ISBN-10 or ISBN-13, hyphens optional.", }, "style": map[string]any{ "type": "string", "enum": styles, "description": "Citation style. Omit for all five.", }, }, "required": []string{"isbn"}, }, }}}) case "tools/call": var p struct { Name string `json:"name"` Arguments struct { ISBN string `json:"isbn"` Style string `json:"style"` } `json:"arguments"` } if err := json.Unmarshal(m.Params, &p); err != nil { return s.fail(m.ID, -32602, "invalid params: "+err.Error()) } if p.Name != "cite_isbn" { return s.fail(m.ID, -32602, "unknown tool: "+p.Name) } text, isErr := s.cite(p.Arguments.ISBN, p.Arguments.Style) return s.result(m.ID, map[string]any{ "content": []any{map[string]any{"type": "text", "text": text}}, "isError": isErr, }) case "ping": return s.result(m.ID, map[string]any{}) } return s.fail(m.ID, -32601, "method not found: "+m.Method) } // cite calls the public API. A lookup that finds nothing is NOT a transport error: the // service answers 200 with found:false on purpose, because a 404 for a valid ISBN provokes // client retries. That distinction has to survive into the tool result, or an agent will // retry a book that genuinely is not catalogued. func (s *server) cite(isbn, style string) (string, bool) { isbn = strings.TrimSpace(isbn) if isbn == "" { return "no isbn given", true } u := s.base + "/isbn/" + isbn if style != "" { u += "/" + style } req, err := http.NewRequest(http.MethodGet, u, nil) if err != nil { return "bad request: " + err.Error(), true } req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", "ottobib-mcp/"+version) res, err := s.http.Do(req) if err != nil { return "lookup failed: " + err.Error(), true } defer res.Body.Close() body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) if err != nil { return "lookup failed: " + err.Error(), true } if res.StatusCode != http.StatusOK { return fmt.Sprintf("lookup failed: HTTP %d; Retry-After: %s", res.StatusCode, res.Header.Get("Retry-After")), true } return string(body), false } // version is stamped at build time with -ldflags -X main.version=... , like every other // command here. printVersionAndExit answers before any other work begins: a deploy asking a // binary what it is must never be able to run it by accident. var version = "dev" func printVersionAndExit() { if len(os.Args) > 1 && (os.Args[1] == "-version" || os.Args[1] == "--version") { fmt.Println(version) os.Exit(0) } } func main() { printVersionAndExit() base := os.Getenv("OTTOBIB_BASE") if base == "" { base = defaultBase } s := &server{base: strings.TrimSuffix(base, "/"), http: &http.Client{Timeout: 30 * time.Second}} if err := s.serve(os.Stdin, os.Stdout); err != nil { fmt.Fprintln(os.Stderr, "ottobib-mcp:", err) os.Exit(1) } } func (s *server) serve(in io.Reader, out io.Writer) error { sc := bufio.NewScanner(in) sc.Buffer(make([]byte, 0, 64<<10), 4<<20) enc := json.NewEncoder(out) for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" { continue } var m rpc if err := json.Unmarshal([]byte(line), &m); err != nil { // A parse failure has no id to answer, so there is nobody to tell. Report it on // stderr and keep reading: exiting would take down the session over one bad line. fmt.Fprintln(os.Stderr, "ottobib-mcp: unparseable message:", err) continue } if r := s.handle(m); r != nil { if err := enc.Encode(r); err != nil { return err } } } return sc.Err() }