# 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

```bash
npm install -g @vncsleal/quillby
quillby-mcp
```

```json
{ "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 via `resources/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 content
```

**Persistence = 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`:

```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

```bash
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 → e2e
```

## Publishing

`prepublishOnly` runs the full test gate. Verify the tarball before publishing:

```bash
npm pack --dry-run      # inspect dist/, README.md, CHANGELOG.md, LICENSE
npm publish --access public
```
