LinksHoard MCP Server
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@LinksHoard MCP ServerSave https://example.com/article for reading later"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Links Hoard
Local-first read-later bookmark library: save a URL, get the extracted article text, tag it, highlight passages and search everything — stored in a single SQLite file on your own computer and exposed to an assistant through MCP.
Spanish version: README.es.md.
Faustus copilot
The shared Faustus button opens its chat panel in every view. Faustus owns
conversations, drafts, memory and agent execution; Links supplies the current
native link, a selected current paragraph, selected text and form values that
have not been saved. New and missing links provide app context without an
invented object. Completing a turn refreshes lists and the reader while keeping
unsaved notes and settings. Manual tag additions and removals preserve labels
added by the agent. edit_link exposes partial title, notes and tag edits through
the normal agent API and MCP. Credentials stay in the server; configure its
Faustus endpoint with LINKS_FAUSTUS_URL or backend settings.
Related MCP server: xmcp-core
Ask a saved article
The reader's Preguntar al artículo form answers through the Hub model with
exact quotes and clickable paragraph anchors. A click highlights and focuses
the source paragraph. MCP offers the same capability through ask_link and
read_link_paragraphs. Partial context, unavailable models, invalid quotes and
changed text are reported. Quote checks verify provenance, not interpretation;
questions and answers are transient and do not edit the saved library.
See the API, MCP contract and limits.
Run
Requires Node.js 22.13 or later (it uses the built-in node:sqlite). No native modules, no Docker.
npm install
npm run build
npm start # http://127.0.0.1:5181npm run open starts the server and opens the browser on Windows. npm run dev runs the API (node --watch) and Vite together with the /api proxy configured automatically.
The server binds to 127.0.0.1 only. If port 5181 is busy it walks up to the next free port and prints the address; set PORT_STRICT=1 to fail instead.
Environment variables
Variable | Purpose |
| Preferred port (default |
| Do not fall back to another port. |
| Data folder (default |
| Extra host names accepted behind a tunnel (see below). |
| MCP bridge: base URL of the running app (default |
| MCP bridge: where to read the bearer token (default |
| Let the app fetch pages, feeds and media from addresses on this machine or your own network (a NAS, a wiki on the LAN). Off by default: only the public internet is reachable, and every redirect hop is checked. |
| Downloads folder when none is set in Ajustes (default |
| Path of each program (see Downloads); the family-wide |
| The family's folder; |
| One more folder searched for the programs, after |
|
|
| Python interpreter used for |
| Video insight: |
| Key-frame cap (default 24), largest attached file (default 500 MB), seconds before each retry (default |
| Local fallback engine: faster-whisper model (default |
| Netscape cookies file for private posts (the setting in Ajustes wins). |
| Comma-separated browsers to try for cookies, in order (default |
| Do not re-encode downloaded videos that are not H.264/AAC. |
Access from your phone (behind a tunnel)
The server binds 127.0.0.1 and only answers requests whose Host is localhost, 127.0.0.1 or [::1]. To reach it from your phone through a tunnel that fronts the app (a private mesh network, a reverse proxy), list the extra host names in LINKS_ALLOWED_HOSTS, comma-separated, exact names or *.suffix: LINKS_ALLOWED_HOSTS=my-pc.example,*.ts.net. Port and letter case are ignored, and the Origin of API calls must resolve to one of those hosts too (any scheme or port). Cross-site fetches are still refused; opening the app from another page (a link, a bookmarklet, the share sheet) is a normal navigation and works.
What it does
Save instantly. Paste a URL (or use the bookmarklet, the browser share sheet once installed as a PWA, or an assistant's
save_linktool) and it is stored right away withfetch_status: pending. A background queue (2 at a time) downloads the page, extracts the article with Readability and fills in title, byline, excerpt and full text — the row updates itself, no reload needed.Read cleanly. The reader shows the extracted text at an adjustable font size, with the original URL, site, author and reading time. Select any text to highlight it, with an optional note.
Read within your available time. Ask Faustus for a
reading_planwith a minute budget, assumed reading speed and optional tag/site filters. It combines complete unread saved texts that fit, reports estimated and unused minutes, and leaves the library and daily picks unchanged. See Reading plans.Organize. Tags, favorites, archive, read/unread. Sidebar shows tag counts; the list can filter by site too.
Search everything. Full-text search (SQLite FTS5) over title, description, extracted text, notes and tags. If the runtime's SQLite build lacks FTS5 the app falls back to a plain
LIKEsearch automatically and says so in Ajustes.Import in bulk. Netscape bookmarks HTML (what every browser exports) or a plain list of URLs, one per line. Both dedupe against what you already have.
Weekly digest.
GET /api/digest?since=(and thelink_digestMCP tool) lists what was saved since a date, grouped by site, with excerpts.Watches (Vigía). The library also brings things in: follow an RSS/Atom feed, a GitHub repository (releases, tags or commits — no API token, its public Atom feeds) or a plain page (a text diff on every change). Each is checked on its own interval (default 60 min, scheduler in-process,
LINKS_WATCHES=0to disable); the first check is a baseline, and every later new entry, release or change becomes a watch item and, withauto_save, a saved link with the watch's tags (sourcewatch), fetched like any other. A page that advertises a feed (<link rel="alternate" type="application/rss+xml">) becomes a feed watch automatically. Every new item is posted to the family bus aslinks.watch.new(and page changes aslinks.watch.changed), so a Hoard Hub rule or the assistant can react — a digest, a flashcard, a note.Download media. Tell the assistant "descárgame esto: " (or paste the link in Descargas) and the video, the audio or the photos land in a folder on your disk: YouTube, X, Instagram, TikTok, Audiomack, SoundCloud, Vimeo, Twitch, Reddit, Facebook, Bilibili and every other site yt-dlp knows. See Downloads.
Highlights to flashcards. Each highlight in the reader has Enviar a Hypatia (and the list has Enviar todos a Hypatia); the
highlights_to_cardstool does the same for one link or everything saved since a date. The card goes through the hub to Hypatia'scards_addin the deck Lecturas: front = the highlighted text, back = your note and the article title,source_ref = hoard://links/highlight/<id>. A highlight is sent once (highlights.card_sent_at; the reader shows En Hypatia ✓);resendsends it again and Hypatia does not duplicate the card. Nothing is marked as sent unless Hypatia answered, and the reply says why when the hub is down (hub_down) or Hypatia is not there (hypatia_unavailable). Creating a highlight postslinks.highlight.added {highlight_id, link_id}on the family bus.Para leer hoy. The Bandeja opens with a few links saved a while ago (3 by default, 1 to 10 in Ajustes), and
resurface {count}answers the same list. Candidates: unread for at least 2 days (older first; favourites, highlights and topics you actually read weigh more), plus links read two weeks ago that carry highlights, for a re-read; downloads, archived and unfetched links never. The same link is not offered twice within 14 days (30 for a re-read); the day's list is stored, so it does not change until tomorrow (a link you read drops out of it). Once a day, from 08:00 local, onedigest.itemevent ("Para leer hoy (3): …") goes to the hub for the family digest, only when there is something to read and only marked as sent when the hub took it.LINKS_RESURFACE=0or the Ajustes switch turns the daily line off.Install as an app.
manifest.webmanifestdeclares ashare_target, so once installed on Android you can share a page from any app straight into Links Hoard.
Downloads
The Descargas page (#/descargas) and the media_download tool turn a link into files on disk. Video and audio go through yt-dlp, photo posts and carousels that yt-dlp cannot take ("There is no video in this post") through gallery-dl. Both run as separate programs; the app does not bundle them.
Sites. YouTube, X (Twitter), Instagram, TikTok, Audiomack, SoundCloud, Vimeo, Twitch, Reddit, Facebook, Bilibili and more get a label; any other URL is still handed to yt-dlp (labelled "Otro (yt-dlp)"), which supports over a thousand sites.
Formats.
video(MP4; H.264 + AAC is preferred and a file in another codec is re-encoded with ffmpeg so it plays everywhere),audio(MP3, best quality),image(gallery-dl) orauto(video; when the post has no video, its photos).qualityfor video:best,1080,720,480. A playlist is only downloaded withplaylist: true, up tomax_items(default 50).Where files go. The folder in Ajustes → Descargas (
media.dir), elseLINKS_MEDIA_DIR, else<home>/Downloads/Links Hoard;diron a single call overrides it. Files are namedtitle [id].ext, made safe for Windows; an existing file is never overwritten, and photo posts get their own sub-folder (uploader - caption, numbered if it exists). No.info.json, thumbnails or other sidecar files are left: title, caption, uploader, date and duration are stored in the database.Queue. One download at a time, first in first out, with progress (percent, speed, ETA) kept live in memory and mirrored to the
media_downloadstable. Cancelling removes a waiting download without running it; cancelling the running one kills yt-dlp and everything it started (taskkill /T /Fon Windows). On the next start, downloads that were running become failed ("interrumpida al cerrar la app") and downloads that were still waiting are queued again.Library link. With
save_link(default on) a finished download also saves the URL as a link taggeddescarga(sourcedownload, kind video, audio or image) with the note "Descargado en "; when the page itself gives no text (Instagram, X), the caption becomes the link's text soread_linkand search find it. It posts the canonical job events on the family bus:links.job.queued,started,progress(at most every 3 seconds),done,failedandcancelled, each with{job_id, title, kind: "download", progress 0..1, gpu: false, eta_s, url, error}plussource_url,platform,format,media_kind,dir,files,bytesandlink_id. The hub already maps the oldlinks.media.*names to these, so those are no longer sent (a rule written against them keeps matching through the hub).Private content. Without a login, Instagram and X often refuse. The app first tries without cookies and, if the site asks for a login, with the cookies of your browsers in turn (Firefox, Chrome, Edge, Brave, Chromium, Vivaldi, Opera) until one works;
cookies_browserpicks one browser (ornone), and Ajustes → Descargas accepts a Netscapecookies.txtinstead. Log in to the site in that browser first. Chrome-family browsers on Windows can refuse while the browser is open: close it or use Firefox.Tools needed. yt-dlp for video and audio, gallery-dl for photos, ffmpeg to merge streams and make MP3 (video works without it, as a single file). Each is looked up in this order (the shared lookup of the family): its variable (
HOARD_YTDLP…, thenLINKS_YTDLP…),<HOARD_HOME>/bin, the sibling folder../Writers hoard desktop/resources/bin, thePATH, the usual system folders, andpython -m yt_dlp/python -m gallery_dl(ffmpeg also throughimageio-ffmpeg). Install withpython -m pip install -U yt-dlp gallery-dlandwinget install Gyan.FFmpegon Windows. The Herramientas card in Descargas (andmedia_tools) shows what was found with versions, and Actualizar runsyt-dlp -U/gallery-dl -Uorpip install -Uwhen they run as Python modules. Windows.cmd/.batshims on thePATHare ignored; use the.exeor point the variable at it.yt-dlp keeps itself current. Sites change and an old yt-dlp starts failing (a YouTube video answering HTTP 403 is the usual sign). Before a download, a yt-dlp build older than 45 days is updated; and when a download fails the way an outdated yt-dlp fails, it is updated and the download is repeated once, before any browser cookies are tried. At most one automatic update every 6 hours; the switch is in Ajustes → Descargas, which also shows the last automatic update.
Play and show. The page plays the files in place (video, audio, images) from
GET /api/media/:id/file, which supports Range requests, and opens Explorer with the file selected. In the reader, a saved video (or a link from a known site) has a Descargar button.Family service. Links Hoard owns media downloads for the whole family: the other apps ask it through the hub instead of running yt-dlp themselves.
media_downloadalso takessections([[start_s, end_s], ...], at most 10: only those parts are downloaded, one file each, named[part N]when there are several),max_duration_s(a longer video is refused before anything is downloaded),max_height(caps the resolution; the lower of it andqualitywins) anddest_dir(alias ofdir).media_infois the probe plus thumbnail, caption languages, id, extractor andis_live;media_subtitlesreturns the captions of a video as text with timing without downloading it (languages in order of preference, manual before automatic);media_audio_for_asrreturns a mono 16 kHz WAV under<downloads>/asr/for speech to text and never saves a library link.Which addresses. A media link must point at the public internet: other schemes,
localhost,*.local, IP addresses of this machine or a private network, and public-looking names that resolve to one are refused.LINKS_ALLOW_PRIVATE_URLS=1lifts the address rule for a server on your own network.Limits. The app does not decrypt DRM streams, sign in for you, or download from sites yt-dlp and gallery-dl cannot read. Deleting files from the app is permanent (Node has no recycle bin) and only happens with an explicit request.
REST routes (same local guard as the rest of /api):
Route | Use |
| List, newest first ( |
| Start |
| One download with progress, files and metadata. |
| Cancel (waiting or running) / queue the same download again. |
| Remove the record; |
| Stream a produced file (Range supported; |
| Open the file manager on the file ( |
| What a link holds (title, duration, heights, playlist or photo post) without downloading. |
| Programs found with versions / update them. |
| Downloads folder and cookies file. |
|
|
|
|
|
|
|
|
Video insight (transcript and key frames)
A saved video link (a reel, a short, a video page, or any media file the app holds) gets, in the background:
a transcript with timestamps: the family speech-to-text service (Funes, through Hoard Link) when it is running; otherwise
faster-whisperon the CPU if Python has it and its model is already cached (nothing is downloaded behind your back); otherwise the step is markedskippedand retried later (up to 3 attempts, 1 / 5 / 30 minutes);key frames: ffmpeg scene changes (
select=gt(scene,0.3)), an opening frame and timed frames in the gaps, at most 24 JPEGs (640 px wide) in<data>/video/<link id>/frames, each with its time.
Where the media comes from, in order: a file you give (path or upload), a finished download of that link (Descargas), and only on request (download: true, the "Descargar y analizar" button, or the auto_download setting) a download with the app's own yt-dlp integration. Finished downloads of videos are analysed by themselves (auto, on by default). The job is idempotent: the same file with nothing missing is not redone; only the missing part (for example the transcript) is retried; force redoes everything.
The transcript is part of the full-text search (/api/links?q= and the Bandeja box find a link by what is said in it), and GET /api/video/search?q= returns the lines with their time. In the item view, "Vídeo: transcripción y fotogramas" shows the status, the frame strip (click a frame to jump to the matching line) and the timed transcript with a filter.
Endpoint | What |
| status, frames (with |
|
|
| raw bytes of a video/audio file to attach (max 500 MB) |
| one frame (JPEG) |
| spoken-word search with times |
|
|
MCP tools: link_video (transcript + frames of a link), link_video_analyze (start / repeat, waits up to timeout_s), link_video_search.
Limits: needs ffmpeg; a video with no audio track gets no_audio; very long videos are cut at max_duration_s for scene detection; no OCR of the frames; the app never downloads a video by itself unless auto_download is on.
URL normalization (the dedupe key)
Saving is idempotent on a normalized form of the URL (the family's shared rule, normalizeUrl of hoard-commons/web.js): utm_*, fbclid, gclid, mc_* and similar tracking params are stripped, the fragment is dropped, the host is lowercased, default ports and a trailing slash are removed (the bare root has no slash), the query is sorted, and a LinkedIn job link keeps only its job id. A scheme-less example.com/page is accepted and gets https://. When an older library is opened, the saved links are re-keyed to this form once. Saving an already-saved page (even with different tracking params) returns the existing link with existing: true instead of duplicating it.
How pages and feeds are fetched
Saved pages, watches and oEmbed titles use the family's shared fetcher (hoard-commons/web.js): the address of every redirect hop is checked (loopback, private, link-local and cloud-metadata addresses are refused unless LINKS_ALLOW_PRIVATE_URLS=1), the charset comes from the header or the page, the body is capped (5 MB for pages, 3 MB for feeds) and a Cloudflare, CAPTCHA or login page is reported as a failed fetch ("blocked: …") instead of being saved as the content. A page watch compares the text with clocks, "5 minutes ago" lines and similar noise removed, never counts a blocked or too-short page (under about 40 words) as a change, and sends the stored ETag / Last-Modified back so an unchanged page answers 304. Watches read public addresses through the family hub when it is running (one polite fetcher for every app) and directly otherwise.
Connect an assistant
In Ajustes (or via faustus-plugin.json) an assistant configured for local MCP servers over stdio can connect using server/mcp.js, LINKS_URL and LINKS_TOKEN_FILE. The bridge never opens the database itself: every call is proxied over HTTP to the running app, authenticated with a random token in <data dir>/mcp-token that is created once and then kept, so a bridge that is already running keeps working after the app restarts. The app is also on the family bus (Hoard Link 0.8, server/hoard-link.js and the shared commons in server/hoard-commons/): every agent call is posted to the Hoard Hub as an agent.call event, /api/health carries the hoard_link block, and the hub's proxy (POST <hub>/api/apps/links/call) can reach these tools on behalf of any sibling app.
Tools:
Tool | Use |
| Save a URL (idempotent); waits up to 10 s for the fetch so it can report the real title/excerpt. |
| List by state (unread/read/archived/all), tag, site, since. |
| Full-text search. |
| Read the extracted text, paginated by characters; includes highlights. |
| Whole saved paragraphs, paginated by index, with versioned reader anchors. |
| Ask the Hub model about saved text; answer sentences include checked quotes and paragraph anchors. |
| Import available captions from a saved YouTube video for reading, search and quotes. Uses the same caption path as |
| Add/remove tags. |
| Edit only the supplied title, notes or tags; label deltas preserve other labels. |
| Set read/unread/archived/favorite. Repeating read status preserves the original reading date. |
| Save a highlighted quote with a note. |
| What was saved since a date, grouped by site. |
| Re-download and re-extract. |
| Permanently delete (destructive; confirm first). |
| Follow a feed, a GitHub repository or a page for new things (kind auto-detected; interval, tags, auto_save). |
| The watches with last check, last error and item count. |
| What the watches brought in (unread first; since; per watch). |
| Check one watch (or every due one) now. |
| Mark an item as seen. |
| Stop following (items and saved links stay). |
| Send highlights (of a link, or all not yet sent / since a date) to Hypatia as flashcards in the deck "Lecturas" ( |
| What to read today from the saved-but-unread ( |
| Choose complete unread texts for |
| Every tag in use, with counts. |
| Download the video, audio or photos behind a link; waits and returns the files with absolute paths and sizes ( |
| Progress and result of one download or the recent list. |
| Cancel a waiting or running download. |
| Queue a failed or cancelled download again. |
| Title, duration, available heights, playlist or photo post — no download. |
| The probe plus |
| Captions of a video as text and timed cues ( |
| The audio of a link as a mono 16 kHz WAV for speech to text ( |
| Whether yt-dlp, gallery-dl and ffmpeg were found, with versions; |
| Remove a download record; its files only with |
30 tools in total. GET /api/agent/tools always reflects the live list. Tool descriptions end with a Sinónimos: line of Spanish words, so a Spanish-speaking user's phrasing ("guarda esto", "resumen de la semana") matches the right tool.
The assistant is instructed to summarize or quote a link only from the text read_link returns, never from the title alone, and to say plainly when a fetch is still pending or failed rather than guessing.
Data and limits
data/links-hoard.db: links, highlights (and which ones went to Hypatia), watches, the download list and settings, SQLite with WAL. Downloaded files are not in the data folder: they are in the downloads folder.data/mcp-token: local credential created at startup; not published or included anywhere else.Fetch: 15 s timeout, a browser-like User-Agent header, 5 MB body cap. HTML goes through
linkedom+@mozilla/readability, with a manual fallback (title + meta description + stripped body) when Readability finds nothing usable. Before extraction, common boilerplate (infoboxes, navboxes, sidebars, reference markers, tables of contents, edit-section links,<nav>/<aside>) is stripped from the page, and the remaining HTML is converted to text block-by-block (a newline after each paragraph/heading/list item/table row, a space between table cells) so text never gets glued together across cells or blocks the way plaintextContentwould. The excerpt picks the first real paragraph (≥ 80 characters with sentence punctuation) rather than whatever text happens to come first in the markup, such as an infobox. PDFs and images are recorded with a filename-derived title (no OCR, no rendering). YouTube/Vimeo get an oEmbed title with no API key required. A link saved before this extraction logic improved can be fixed up in place with refetch_link /POST /api/links/:id/refetch(or the "Reintentar descarga" button in the reader) — it re-runs the current extraction code against the same URL and overwrites the stored title, excerpt and text.Search: FTS5 when the runtime's SQLite build supports it (verified at every startup); otherwise a
LIKEfallback across the same fields, both reported inGET /api/state.
Verification
npm test
npm run buildTests use temporary data directories and a local HTTP server for fixtures — nothing touches your real data or the network. A fake hub (tests/fake-hub.js) stands in for the family hub: it records the events and answers the proxied cards_add. Downloads are tested with small fake yt-dlp, gallery-dl and ffmpeg scripts (tests/media-fakes.js, selected with LINKS_YTDLP=node:<script>), so no real site or binary is needed. They cover URL normalization (and the one-time re-keying of an older library), the shared page fetcher (charset, PDFs and images, challenge pages, the byte cap, the address check), watches (ETag, blocked pages, the hub path, a silent baseline after an upgrade), the family media service (sections, limits, media_info, media_subtitles, media_audio_for_asr, private addresses), the real entry point (restart keeps the token, SIGTERM exits 0), Readability extraction against an HTML fixture, save idempotency, full-text search, the digest, highlights, bookmarks/URL-list import parsing, agent token auth, a full API round-trip, and for the family: highlights to cards (once each, source_ref, older Hypatia, hub down, chunks of 50), the links.highlight.added event, the "Para leer hoy" picks (ranking, cooldown, one list per day) and its daily digest, the canonical job events; and for downloads: platform detection, arguments per format and quality, progress parsing, the queue (order, cancel of a waiting and of the running download with its process tree), the gallery-dl fallback, cookies from browsers, the saved link with its note, no overwriting, file streaming with Range, the REST routes and the MCP tools.
Design and decisions: DESIGN.md.
This server cannot be deployed
Maintenance
Related MCP Connectors
Save, search, read, tag, and organize your bookmarks from AI assistants with OAuth sign-in.
Personal knowledge MCP: capture bookmarks, notes & todos by chat; archive pages; search memory.
Save and organize web finds in persistent, user-controlled collections for AI assistants.
Official Bookmax MCP server: search and save bookmarks, read notes and docs from AI assistants.
1
Related MCP Servers
- AlicenseAqualityCmaintenanceEnables AI assistants to save, search, and manage bookmarks with semantic search, automatic metadata extraction, and optional LLM-powered enrichment, all running locally.86MIT
- AlicenseNot gradedqualityCmaintenanceA generic X/bookmark intelligence MCP core for ingesting, enriching, and retrieving bookmarks with semantic search, brief generation, and academic paper scanning, exposed as MCP tools.Apache 2.0
- AlicenseAqualityAmaintenanceA local, read-only MCP server that makes bookmarks from browsers, read-later apps, and export files accessible to AI assistants, enabling search, triage, and summarization without sending data anywhere.861 npm1MIT
- AlicenseNot gradedqualityAmaintenanceTurns likes, bookmarks, and saved links into persistent project-aware memory, enabling AI agents to search, import, and process personal content through a local SQLite-backed MCP server.Apache 2.0