@vncsleal/quillby
v0.4.2
Published
MCP server for multi-client content operations: brand voice memory, daily research briefs, and drafts. Agent-first, no server-side LLM.
Maintainers
Readme
Quillby MCP
MCP server for multi-client content operations: brand voice memory, daily research briefs, and social media drafts.
Spec: MCP 2026-07-28 (stateless)
SDK: @modelcontextprotocol/server v2
Architecture: agent-first
The server is the agent's durable memory. It stores per-client knowledge (voice, examples, lessons, sources), daily brief records (harvested items + ranked ideas), and drafts with lifecycle status. It does not generate anything — the host agent (opencode, Claude, etc.) reads the state, thinks in conversation, and writes decisions back. No server-side LLM, no LLM API keys (Sampling was deprecated in the spec, SEP-2577): relevance ranking is deterministic and corpus-relative (inverse document frequency over each run's own harvest, blended with the client's persisted, time-decayed harvest history — a word that shows up in half the news wire earns near-zero credit, a rare term earns full credit; no hardcoded keyword lists anywhere), so the server runs keyless from first boot.
The tools are deliberately few and order-agnostic: no call depends on a prior call, every write is an upsert, and reads default to the selected client. Onboarding can happen in any order — drafts before voice, sources after, corrections anytime.
Protocol-native human decisions (2026-07-28 MRTR + elicitation). The gates that
encode human decisions — draft approve / draft reject, brief save, workspace
delete — demand in-band consent when the client declares the elicitation
capability: the tool returns an input_required result carrying a form-mode
elicitation, the client renders it as a native UI, and only an explicit accept
completes the call; decline/cancel store nothing. An agent can no longer record a
decision the human never made. Clients without the capability keep the agent-mediated
flow (confirm flags, conversation-asked reasons) unchanged.
Durable async execution (io.modelcontextprotocol/tasks). When the client declares
the io.modelcontextprotocol/tasks extension, the long-running tools — brief run,
web fetch, web ingest — run detached and their response carries a durable task
handle (taskId, status, ttl, pollInterval) instead of blocking. The client
polls tasks/get (resumable across disconnects — handles live in SQLite), cancels
cooperatively via tasks/cancel, pages tasks/list, and re-issues the original call
via tasks/getPayload. Everyone else keeps the current blocking behavior. The tasks
RPCs ride the 2025-era method registry (where today's hosts still live); the
2026-07-28 era's closed method registry has no tasks entries in the current SDK yet,
so modern-era clients get the handle but poll through their own extension plumbing.
Quick Start
npm install -g @vncsleal/quillby
quillby-mcp{ "mcpServers": { "quillby": { "command": "quillby-mcp" } } }Tools (7)
workspace — the client envelope
| Action | What it does |
|---|---|
| list | all clients |
| get | voice, examples, lessons, sources, last brief date (default action) |
| create | name (required), optional segment + initial voice/radar; auto-selects and reports selected: true plus a note naming it the active client — subsequent calls without an id target it |
| update | patch name/segment/voice; reason records a lesson |
| voice_lint | deterministic check of a draft against the client's voice — banned terms (Unicode-aware boundaries; inflections like unleashing catch banned unleash), preferred terminology, passive voice, sentence lengths, structure (no LLM). Returns structured score/passed/stats/issues plus a human-readable report string; vacuous: true when no voice rules are configured, so a green report is never mistaken for an actual pass |
| delete | needs confirm: true (or in-band consent when the client declares the elicitation capability) |
| select | set the active client (all other calls default to it) |
| sources_add | urls + type (rss) — http(s) format-validated only; feed reachability is checked at brief run time and reported per source; duplicates come back as {url, reason} in skippedUrls |
| sources_remove / sources_list | by id or url / list |
Voice fields: pillars, banned (words), vocabulary {preferred, banned},
bannedConstructions, aesthetic, styleRules, preferredTerms {term → preferred},
lint {maxSentenceWords, minSentenceWords, maxSentenceChars}, examples
(approved content refs), lessons (auto-appended when updating with a reason).
Radar profile fields (what the brief run's intelligence layer scores against):
coreOffer, goals, topics, avoid, location, directUrls, freshnessDays (default 120), locale (en | pt — localizes Google News queries/params, categories, formats, angles, and generated prompts), audience (consumer | business | mixed, default mixed — a deterministic flavor detector penalizes B2B-flavored content for consumer clients and vice-versa; mixed never penalizes).
brief — the daily record
One record per date: radar-ranked items + curated ideas.
| Action | What it does |
|---|---|
| run | the content radar: collects from the client's sources + Google News (keyless) + key-gated providers (Exa/Tavily/Firecrawl), scores every item against the radar profile with corpus-relative IDF weighting (each matched term credited by its inverse document frequency over the run's own harvest blended with the client's persisted, time-decayed harvest history — generic wire words earn near-zero credit, rare terms full credit, and a small harvest still gets corpus-relative weights from the client's history; multi-word profile entries like "fresh flowers" match as contiguous phrases; no hardcoded keyword lists) plus location matches, freshness, authority, avoid penalties and audience-flavor penalties — with a human-readable why, clusters related items, categorizes (Seasonal / Customer Education / Local Opportunity / Operations / Trend), and returns a ranked digest plus prompt-ready ideas (headline, hook, brief with guardrails, ready-to-paste prompt for the operating LLM). Press releases, product listings, and PT-BR job/class announcements are filtered out and counted in filtered. Location-only matches (no domain hits, no qualifying semantic overlap) are capped at 3, sort after domain-relevant items, and score-penalized — the location lives in its own match channel and never inflates semantic similarity, so a story that merely mentions the city can't outrank a genuine domain match. refresh: true bypasses the ~4h fetch cache for a fresh harvest. The cache is per provider: fresh, error-free rows are served as-is while stale or previously-failed providers are re-fetched, so a transient failure heals on the next run instead of poisoning the cache; the whole cache is dropped automatically when the radar profile (topics/avoid/location/…) or the source list changes, so an edit is never silently served stale items. Items already seen in the last seenWithinDays (default 7) are flagged seen; newItems counts the digest's unseen links and surfaced its already-seen ones. The voice guide is emitted once at the top level (voice) instead of inside every idea. Each idea's angle and headline are specialized on its own evidence (its rarest matched domain term, else its location) so same-category items read as distinct takes, not copies of one category template. Each provider contributes at most 10 digest items, so Google News can't crowd out niche RSS feeds; Local Opportunity categorization requires real location evidence. A run with sources but no topics/coreOffer returns a warning instead of scoring generic noise, a digest that is entirely location-only despite domain terms in the profile returns a warning pointing at the profile, and an all-seen rerun reports lastBriefDate so "when did anything fresh last appear?" is answered in the same call. Backdated runs (date before today) describe that date, not now: items published after the run date are excluded (and items with no provable publish date), age/freshness/decay are scored as of the run date, and the exclusion count lands in the note — harvested keeps the raw count. Location evidence is always surfaced with its original accents and case ("São Paulo", never "sao paulo") in why lines, idea headlines, angles, and prompts. Generated ideas are suggestions — the run persists them on the record as suggestions (view with get; a saved marker flags one the human later curated via save); persist curated ones with save. Every completed run stores its record and says so machine-readably (recordStored: true, stored = items persisted); a no-op run on an unconfigured client stores nothing (recordStored: false). The response defaults to a token-light compact digest (title/url/source/score/category/seen per item, headline/format/angle/reason/source per idea); pass verbose: true for the full shape (snippets, why, prompt-ready briefs — compact: true forces compact even alongside verbose). While a fresh harvest runs, per-provider notifications/progress keep the agent informed, and freshly-fetched providers report their elapsedMs in sources (cache-served rows skip it). Every response carries the workspaceId it operated on; all scores are rounded to 2 decimals |
| save | record ranked ideas for a date — ideas is required (pass [] to clear); replaces that date's ideas; stored ideas carry stable idea:<n> ids echoed back in the response and on get, so an agent can reference a curated idea; only after the user confirms the curation (clients with the elicitation capability confirm in-band via input_required) |
| get | full record for a date (default today) — radar-ranked items + curated ideas + the run's suggestions (each marked saved when a curated idea references its source link), or a range with from/to (from required with to, from ≤ to); range dedupes items by link keeping the newest occurrence (its date and seen flag); range reads default to the compact shape — pass verbose: true for full items; flags expired ideas; compact: true forces the title/url digest on single-date reads; returns totalItems + truncated when the item cap (30 single / 100 ranged, limit raises it) is hit. Dates are validated as real calendar dates (e.g. 2026-02-30 is rejected) |
| list | recent dates with counts — item/idea/suggestion per day — {workspaceId, total, days, truncated} (default 20 per page, max 500, offset pages past the cap) |
| delete | remove a date's record — errors when no record exists for the date; echoes the removed date as deletedDate. The seen-memory survives: previously surfaced links are never re-flagged as new |
Idea fields: thesis, angle, platform, source, link, expiresAt (null =
evergreen). The weekly view is brief get {from: monday, to: today}.
draft — the post decision log
Nothing is stored as uncommitted scratch. Content enters the record only as a human decision — the agent writes in conversation; the user decides; only then does it hit the disk.
| Action | What it does |
|---|---|
| approve | content + format (format is required — a decision without one fails at the schema layer; format is trimmed, capped at 40 chars — instagram, linkedin, twitter/x, blog, email, newsletter, tiktok, carousel…) + optional reason, briefDate/ideaIndex provenance — creates an approved record (clients with the elicitation capability confirm the decision in-band first) |
| reject | same — creates a rejected record with reason (the anti-example that powers learning); the reason is required, and with elicitation it comes from the user's in-band decision |
| list | filters: status, format, from/to date range (from ≤ to), q — free-text search over content and reject reason (the anti-examples that power learning; LIKE wildcards are escaped, so literal %/_ match literally); compact: true omits content — returns {total, items, truncated} (default 50 per page, max 500, offset pages past the cap) |
| get / update / delete | by id / content correction or status flip (a flip to rejected needs a fresh reason; a flip to approved clears the stale one) / remove |
note — the editorial memory
Structured notes (research | idea | feedback | decision) with tags and
free-text search. The reusable material that briefings fold in.
| Action | What it does |
|---|---|
| save | title, body, kind, tags, optional source URL (titles are trimmed; whitespace-only titles are rejected) |
| update / get / delete | by id |
| list | filter by kind, tag, or free-text q — returns {total, items, truncated} (default 50 per page, max 500, offset pages past the cap); pass includeBody: true to include full note bodies (omitted by default) |
briefing — the editorial brief
A structured control sheet: kind-specific template sections (tech-spec,
article, update, social-post) each with per-section writing guidance,
plus a Research Material section folded from notes. The writing agent
completes it in conversation — nothing is generated server-side.
| Action | What it does |
|---|---|
| create | title (trimmed, non-empty, unique per client), kind, optional noteIds + notesText + sources; nonexistent or foreign noteIds are dropped and reported as skippedNoteIds with per-id reasons in skippedReasons (not found vs belongs to another client) |
| get | full control-sheet markdown with the client's voice |
| list | optional kind filter — returns {total, items, truncated} (default 50 per page, max 500, offset pages past the cap) |
| status | id + status draft|ready |
| delete | by id — also removes the briefing's rendered artifacts; the count returns as deletedArtifacts |
artifact — the scaffold store
Deterministic structural scaffolds rendered from briefings (headings +
guidance + voice requirements). Only the skeleton is stored — finished
content enters the record via draft approve/reject.
| Action | What it does |
|---|---|
| render | briefingId → stored skeleton (works from any briefing status); marks the briefing ready |
| get / list / delete | by id / per briefing ({total, items, truncated}, default 50 per page, max 500, offset pages past the cap) / remove |
web — live web access
Grounds the pipeline in live data without leaving the assistant. No API keys.
| Action | What it does |
|---|---|
| search | keyless engine chain — DuckDuckGo → Bing → Mojeek (query, maxResults); results are not stored. A bot-block (DuckDuckGo's HTTP 202 anomaly page) or engine failure moves the chain on instead of returning a silent empty list; the response reports which engine served (engine: ddg|bing|mojeek), and engine: none marks a genuine dry query. Multi-token queries keep only results sharing two query tokens (a lone "paulo" overlap must not float an Apostle-Paul biography for a São Paulo query); single-token queries trust the engine |
| fetch | fetch a URL, strip chrome, convert the main content to markdown (not stored) — Mozilla Readability (the Firefox Reader Mode algorithm) extracts the article, falling back to the article/main/body heuristics for non-article pages; RSS/Atom feeds parse to a markdown headline survey; PDFs and other non-HTML content return an actionable hint instead of a dead-end error. With the tasks extension declared, runs detached and returns a task handle |
| ingest | fetch + persist as a research note (tags, source = URL) that briefings fold in; with the tasks extension declared, runs detached and returns a task handle |
Tasks RPC surface (io.modelcontextprotocol/tasks)
Durable handles for long-running tool calls, served on the 2025-era method registry (where today's hosts live):
| Method | What it does |
|---|---|
| tasks/get | current task state — {taskId, status, ttl, createdAt, lastUpdatedAt, pollInterval, statusMessage, result?, error?}; terminal tasks carry the digest/note result or the failure |
| tasks/cancel | cooperative cancel — the runner observes it at its next phase boundary; the record is not stored after a cancel |
| tasks/list | page of tasks (cursor, newest first) |
| tasks/getPayload | the original tool call (tool, workspaceId, args) so a client can re-issue it |
Prompts & resources
- Prompts:
draft-from-brief(briefing + notes + voice requirements embedded for the operating LLM),voice-review(editorial critique against the voice),editorial-workflow(end-to-end research → brief → artifact → lint → decide) - Resources:
quillby://voice/{workspaceId},quillby://notes/{workspaceId}/{noteId},quillby://briefings/{workspaceId}/{briefingId},quillby://artifacts/{workspaceId}/{artifactId}(enumerable viaresources/list)
The daily loop (agent-side)
workspace get → voice, examples, lessons, sources
brief run → today's items (auto-stored; seen flags show what's new)
(agent ranks ideas in conversation — nothing stored)
brief save → today's ideas, only after the user confirms
(agent drafts in chat when the user asks — nothing stored)
draft approve/reject → the user's decision, with reason
workspace update → deliberate voice learning, with reason (after repeated feedback themes)Continuity costs nothing: workspace get returns lessons and last brief date; brief
get with a range is the weekly view; expired ideas are flagged on read; brief run
flags items you've already seen. Feedback only becomes voice knowledge deliberately —
after repeated themes, the agent proposes a rule and the user confirms it via
workspace update.
The editorial loop (for specs, articles, and long-form)
note save → research/ideas from the web, conversations, anywhere
briefing create → control sheet: kind template + per-section guidance + folded notes
artifact render → deterministic scaffold (headings + guidance + voice requirements)
draft-from-brief → prompt embeds briefing + notes + voice for the operating LLM
workspace voice_lint → deterministic check of the draft against the voice (no LLM)
draft approve/reject → the human decision records the finished contentPersistence = decision. The only bytes that reach the disk: harvested facts
(brief run), curation the user confirmed (brief save), scaffolds the agent
rendered (artifact render), and posts the user decided on (draft approve /
draft reject). The agent's scratch — every draft it writes — lives in the
conversation and nowhere else.
Config
~/.quillby/config.json:
{ "activeWorkspace": null, "dbPath": "~/.quillby/quillby.db" }activeWorkspace is a process-global, last-write-wins selection shared by
every MCP client on the same machine/home. Two MCP servers running
concurrently (e.g. separate agent configs pointed at the same QUILLBY_HOME)
each mutate the same file, so one client's select can silently change what
the other one resolves to. Ids are stable per workspace, so the robust
pattern under concurrency is to pass workspaceId (or the object's id)
explicitly per call instead of relying on the process-level selection — every
tool already accepts both.
Storage: SQLite via better-sqlite3 (the industry-standard driver — WAL
mode, foreign keys, a 5s busy_timeout so concurrent access waits instead
of failing, synchronous API, no experimental-warning noise; a native module
with prebuilt binaries for macOS/Linux/Windows, falling back to a source
compile when none match). Requires Node ≥ 24. The DB migrates in place from
earlier versions (including databases written by the previous node:sqlite
driver — same file format). Set QUILLBY_HOME to relocate config + DB
elsewhere (useful for testing or multiple installations).
Logging: stdout is reserved for the JSON-RPC protocol; operational logs go to
stderr. Set QUILLBY_LOG_LEVEL=debug for per-run timing and error traces
(default info, which logs startup/shutdown and unexpected errors only).
Clients may also set a per-request level via
io.modelcontextprotocol/logLevel in the request's _meta (2026-07-28
spec) — that request's handlers log at the declared level without affecting
concurrent requests.
Robustness: every non-workspace tool validates the target client exists and
returns client not found instead of leaking raw SQL errors; all business errors
return the same {error: message} envelope (schema violations surface
as tool errors from the MCP SDK); every list tool returns {total, items,
truncated} so page caps are never silent, and an offset param pages
past the cap; dates are validated as real
YYYY-MM-DD calendar dates and inverted ranges (from > to) are rejected;
limits are bounded (1..500); all titles are trimmed and capped at 200
chars; draft formats are trimmed and capped at 40 chars; curated ideas
require a non-empty thesis and an ISO expiresAt (or null — evergreen).
Calendar days follow the local timezone (a brief run after midnight
files under the new day, not UTC's). Harvested fetches are cached ~4h per
provider (one row per feed URL / provider name): fresh, error-free rows
are served as-is on rerun while stale or previously-failed providers are
re-fetched, so a transient failure heals on the next run instead of
poisoning the cache — and serving cached rows never extends their TTL. The
whole cache is dropped on radar/source edits, and stale cache rows are
pruned after 3 days on every fresh run. Key-gated providers are
each timeboxed (45–60s) so a dead provider can never hold a run open past
its budget. web fetch accepts http(s) only and surfaces the underlying
cause of network failures instead of undici's generic fetch failed.
sources_remove accepts ids or urls (urls resolve to the client's own
sources) and reports unmatched inputs (missing, unmatchedUrls) instead
of a silent {removed: 0}, and every resource timestamp reads back as ISO 8601
(including briefs). voice_lint returns structured findings
(score/passed/stats/issues) alongside its human-readable report.
Relevance scoring additionally leans on a persistent IDF corpus: each
fresh harvest folds its term/phrase document frequencies into the client's
history (30-day half-life, faded rows reaped), and every run — even a
3-item one — blends that history into its IDF weights, so genericity stays
data-driven and stable across runs instead of flipping with a single small
harvest.
web search gets the same resilience treatment as the radar: DuckDuckGo's
reverse-engineered html endpoint fingerprint-blocks bots with an HTTP 202
anomaly page that parses to zero results — the server detects the block
(202 status or anomaly-page body) and fails that engine instead of
reporting a silent empty list, then tries the next keyless engine (Bing,
then Mojeek), reporting which engine served in engine. web fetch runs
Mozilla Readability before markdown conversion, so nav/ads/sidebars stay
out of ingested research notes (the heuristics remain as fallback for
non-article pages). Cacheable results carry 2026-07-28 ttlMs/cacheScope
hints: tools/list and server/discover at server level, per-resource
TTLs at registration (30s, private). Every tool declares an
outputSchema (JSON Schema 2020-12, SEP-2106 — zod v4's native dialect)
and every success result carries structuredContent alongside the text,
so schema-aware clients validate the contract instead of guessing.
Key-gated providers (all optional — RSS, Google News and DDG search work with zero configuration):
| Var | Provider |
|---|---|
| QUILLBY_EXA_API_KEY | Exa semantic news search |
| QUILLBY_TAVILY_API_KEY | Tavily news search |
| QUILLBY_FIRECRAWL_API_KEY | Firecrawl direct-URL extraction (radar.directUrls) |
Development
npm install
npm run typecheck # typecheck
npm run build # clean + compile to dist/
npx tsx src/index.ts # dev
npm test # unit tests
npm run test:e2e # end-to-end over real stdio JSON-RPC (isolated QUILLBY_HOME)
npm run test:all # the full gate: typecheck → build → unit → e2ePublishing
prepublishOnly runs the full test gate. Verify the tarball before publishing:
npm pack --dry-run # inspect dist/, README.md, CHANGELOG.md, LICENSE
npm publish --access public