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.
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-runSet 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"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:8787Newline-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.mjsPlain 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/health{ status, service, operations, dryRunAvailable, llmConfigured }/schemathe same tool schemas the MCP server advertises/researchthe research packet/draft{ markdown, meta }/gate{ ok, errors, warnings, wordCount, slug, canonicalUrl }/publish{ path, slug, wordCount, route, warnings, logs }Real request/response shapes — trimmed for display, not hypothetical.
research (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}}/gate — a rejection is a 200, not an errorRequest
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": []
}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.
{ draft, packet?, checkLinks?, linkCheckOptions? }{ ok, errors, warnings, wordCount, slug, canonicalUrl }InputErrorinvalid_input400missing/malformed argument, unknown cluster, unparseable draftProfileInputErrorinvalid_profile400the site profile cannot be loaded or fails validationLlmConfigErrorllm_not_configured400a real (non-dry-run) call with no usable key/base URL/modelContractErrorcontract_violation422the format contract rejected the draft; nothing is writtenConflictErrorpost_exists409the post directory exists and force was not setStageErrorstage_failed502an upstream stage (LLM endpoint, SERP, parser) failedpublish() 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.
Every article on this blog was produced by this pipeline — research, draft, gate, publish.
See how it's used