Back to home
MCP server + loopback HTTP — 4 operations, BYOK

An MCP server for your SEO pipeline

research, draft, gate, and publish as four callable operations — over MCP (JSON-RPC 2.0 on stdio) or a loopback-only HTTP surface. Bring your own model and keys; no accounts, no hosted API.

Quick Start

Step 1Try it with zero keys

Every operation accepts dryRun: true — it runs the whole chain against a canned mock LLM so you can see the shape of a packet, draft, and gate verdict with no account and no API key.

node scripts/generate-post.mjs "how to do keyword research" --dry-run
Step 2Bring your own model

Set LLM_PROVIDER / LLM_BASE_URL / LLM_MODEL / LLM_API_KEY for any OpenAI-compatible endpoint, or pass a model object per call. TAVILY_API_KEY and SERP_API_KEY are optional and unlock citations and competitor data.

LLM_API_KEY=sk-... node scripts/generate-post.mjs "how to do keyword research"
Step 3Call it from your own agent

Speak MCP over stdio (tools/list, tools/call) or POST JSON to the loopback-only HTTP surface. Both expose the same four operations against the same JSON Schemas.

npm run mcp        # node scripts/mcp-server.mjs, stdio JSON-RPC 2.0
      npm run serve       # node scripts/serve.mjs, 127.0.0.1:8787

Transports

MCP server

Newline-delimited JSON-RPC 2.0 on stdin/stdout, implemented directly — no SDK dependency. Handles initialize, tools/list, tools/call, and shutdown. You spawn the process, so there is no auth layer to configure.

node scripts/mcp-server.mjs

Loopback HTTP

Plain node:http, JSON in and out. It binds to 127.0.0.1 only — any non-loopback --host is refused before the socket even opens, because there is no authentication here and /publish writes files. Put it behind something real before exposing it.

node scripts/serve.mjs --port 0
HTTP routes
GET/health{ status, service, operations, dryRunAvailable, llmConfigured }
GET/schemathe same tool schemas the MCP server advertises
POST/researchthe research packet
POST/draft{ markdown, meta }
POST/gate{ ok, errors, warnings, wordCount, slug, canonicalUrl }
POST/publish{ path, slug, wordCount, route, warnings, logs }

Examples

Real request/response shapes — trimmed for display, not hypothetical.

tools/callresearch (dry run, no key)

Request (stdin, one line)

{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"research",
         "arguments":{"keyword":"how to do keyword research","dryRun":true}}}

Response (stdout, trimmed)

{"jsonrpc":"2.0","id":1,"result":{
        "structuredContent":{
          "packetVersion":1,
          "topic":{"primaryKeyword":"how to do keyword research"},
          "serp":{"available":false},
          "research":{"available":false},
          "constraints":{"wordCount":{"min":1600,"max":2200}}
        },
        "isError":false}}
POST/gate — a rejection is a 200, not an error

Request

curl -s http://127.0.0.1:8787/gate \
        -H "Content-Type: application/json" \
        -d '{"draft":"---\ntitle: ...\n---\n\n[link](https://example.com)"}'

Response — HTTP 200

{
        "ok": false,
        "errors": [
          "Placeholder link must be removed: https://example.com"
        ],
        "warnings": []
      }

All Operations

Four operations, dispatched identically from the MCP tool name or the HTTP route. scripts/lib/pipelineOps.mjs is the only implementation; neither transport carries logic of its own.

Input{ draft, packet?, checkLinks?, linkCheckOptions? }
Output{ ok, errors, warnings, wordCount, slug, canonicalUrl }
Typed errors
InputErrorinvalid_input400missing/malformed argument, unknown cluster, unparseable draft
ProfileInputErrorinvalid_profile400the site profile cannot be loaded or fails validation
LlmConfigErrorllm_not_configured400a real (non-dry-run) call with no usable key/base URL/model
ContractErrorcontract_violation422the format contract rejected the draft; nothing is written
ConflictErrorpost_exists409the post directory exists and force was not set
StageErrorstage_failed502an upstream stage (LLM endpoint, SERP, parser) failed

publish() re-runs the identical validation gate() runs. There is one validateFormatContract() call site, so a client that skips gate entirely and calls publish directly still gets a contract_violation and no file. A model that can choose to skip validation eventually will — so the structure doesn't give it the option.

Want to see it end to end?

Every article on this blog was produced by this pipeline — research, draft, gate, publish.

See how it's used