searchsocket
v1.0.0
Published
Semantic site search and MCP retrieval for SvelteKit static sites
Maintainers
Readme
SearchSocket
Semantic site search and MCP retrieval for SvelteKit content projects. Index your site, search it from the browser or AI tools, and scroll users to the exact content they're looking for.
Requirements: Node.js >= 22.12 | Backend: Upstash Vector | License: MIT
How it works
SvelteKit Pages → Extractor (Cheerio + Turndown) → Chunker → Upstash Vector
↓
Search UI ← SvelteKit API Hook ← Search Engine + Ranking
↓
MCP Endpoint → Claude Code / Claude DesktopSearchSocket extracts content from your SvelteKit site, converts it to markdown, splits it into chunks, and stores them in Upstash Vector. At runtime, the SvelteKit hook serves both a search API for your frontend and an MCP endpoint for AI tools.
Features
- Semantic + keyword search — an Upstash Vector hybrid index runs the dense and sparse queries and fuses their rankings with DBSF. There is no separate reranking model and no query-enrichment step
- Page-first search — pages are ranked first, then the best-matching sections within the top pages are attached as sub-results
- Scroll-to-text — auto-scroll to the matching section when a user clicks a search result, with CSS Highlight API and Text Fragment support
- SvelteKit integration — server hook for the search API, Vite plugin for build-triggered indexing
- Svelte 5 components — reactive
createSearchstore and<SearchSocket>metadata component (Svelte 5.20 or newer) - MCP server — three tools for Claude Code, Claude Desktop, and other MCP clients (stdio + HTTP)
- llms.txt generation — auto-generate LLM-friendly site indexes during indexing
- Four source modes — index from static output, build manifest, a running server, or raw markdown files
- CLI — init, index, search, dev, status, doctor, clean, prune, test, mcp, add
Install
pnpm add searchsocketA regular dependency is what the quickstart below needs: step 4 wires searchsocketHandle() into src/hooks.server.ts, which runs in your deployed server, not at build time.
The dev-dependency install is the narrower case — you only index at build time and never mount the hook (no search API, no MCP endpoint, e.g. a static site whose frontend queries a search API you host elsewhere):
pnpm add -D searchsocketPrereleases publish to the next dist-tag; stable releases to latest:
npm install searchsocket@nextQuickstart
1. Initialize
pnpm searchsocket initCreates searchsocket.config.ts, the .searchsocket/ state directory, wires up your SvelteKit hooks and Vite config, and generates .mcp.json for Claude Code.
2. Configure
Minimal config (searchsocket.config.ts):
export default {
project: {
id: "my-site", // namespaces your records in the vector index
baseUrl: "https://example.com"
},
source: {
mode: "static-output" // or "build" | "crawl" | "content-files"
}
};Defaults handle everything else, and SearchSocket reads UPSTASH_VECTOR_REST_URL and UPSTASH_VECTOR_REST_TOKEN from your environment automatically.
Set project.id and source.mode explicitly rather than leaving them out. Both are inferred when absent — project.id from package.json's name (falling back to the directory name), source.mode from whether the static output directory exists on disk. That inference works on your machine and in CI, where the checkout is right there. A deployed server bundle has neither the checkout nor package.json at the path the loader expects, so searchsocketHandle() would resolve a different project id than the one you indexed under, or fail outright on source-mode detection. Explicit values are what make the runtime hook work off a checkout.
3. Set environment variables
# .env
UPSTASH_VECTOR_REST_URL=https://...
UPSTASH_VECTOR_REST_TOKEN=...Create an Upstash Vector index with the bge-large-en-v1.5 embedding model (1024 dimensions). Copy the REST URL and token.
4. Add the SvelteKit hook
The init command does this for you, but if you need to do it manually:
// src/hooks.server.ts
import { searchsocketHandle } from "searchsocket/sveltekit";
export const handle = searchsocketHandle();This exposes POST /api/search, GET /api/search and GET /api/search/health, plus page retrieval routes.
It also mounts the MCP route at /api/mcp when mcp.enable is on (it defaults to NODE_ENV !== "production"), but that route fails closed: until you set mcp.handle.apiKey/mcp.handle.apiKeyEnv, or opt into mcp.handle.access: "public", every MCP call answers 503. (Only POST carries a call; GET and DELETE are 405 whatever the key says.) See MCP Server for the configuration.
The served API path comes from api.path (default /api/search). You can override it per-hook with searchsocketHandle({ path: "/search-api" }) — if you do, set api.path in your config to the same value, because indexing-time output such as llms.txt reads the config, not the hook option.
searchsocket/sveltekit is server-only. It pulls in the indexing pipeline, the MCP
SDK, and Node builtins (fs/promises, child_process, crypto, net, zlib), so
importing it from browser code fails the Vite browser build on createRequire. The
browser-safe entry points are searchsocket/client (the search client) and
searchsocket/scroll (searchsocketScrollToText); neither imports a Node builtin.
If your app already exports a handle, compose the two with sequence() and keep
your handle first:
// src/hooks.server.ts
import { sequence } from "@sveltejs/kit/hooks";
import { searchsocketHandle } from "searchsocket/sveltekit";
export const handle = sequence(yourExistingHandle, searchsocketHandle());Order matters. searchsocketHandle() answers its own endpoints without calling
resolve(), so anything sequenced after it never sees those requests. With your
handle outer, every SearchSocket route passes through your auth, logging and header
middleware first — which also means a handle that short-circuits a request blocks
the search API, the MCP endpoint, CORS preflight, GET /api/search/health and
llms.txt along with it. Exempt those paths in your own handle if that is not what
you want.
searchsocket init writes this composition for a fresh install. On an existing
install it does not: if hooks.server.ts already mentions searchsocketHandle
it reports "already present" and changes nothing, so an install that has the two
handles in the wrong order has to be reordered by hand.
If you run into SSR bundling issues, mark SearchSocket as external in your Vite config:
// vite.config.ts
export default defineConfig({
plugins: [sveltekit()],
ssr: {
external: ["searchsocket", "searchsocket/sveltekit", "searchsocket/client", "searchsocket/scroll"]
}
});5. Add search to your frontend
Copy the search dialog template into your project:
pnpm searchsocket add search-dialog
pnpm searchsocket add search-triggeradd copies one component per invocation, so the trigger button used below needs its
own command. This copies a styled Svelte 5 command palette (the templates use $props.id(), so they need Svelte 5.20 or newer) into src/lib/components/search/, alongside its stylesheet and helpers. It needs no CSS framework — Tailwind optional — and follows your app's light/dark theme by default. Import it in your layout and add the scroll-to-text handler:
<!-- src/routes/+layout.svelte -->
<script>
import { afterNavigate } from "$app/navigation";
import { searchsocketScrollToText } from "searchsocket/scroll";
import SearchDialog from "$lib/components/search/SearchDialog.svelte";
import SearchTrigger from "$lib/components/search/SearchTrigger.svelte";
let searchOpen = $state(false);
afterNavigate(searchsocketScrollToText);
</script>
<SearchTrigger bind:open={searchOpen} />
<SearchDialog bind:open={searchOpen} />
<slot />Brand it by overriding a few semantic CSS variables — no need to touch the component:
<SearchDialog
bind:open={searchOpen}
label="Search documentation"
pathPrefix="/docs"
topK={10}
style="--ss-search-accent: #0f766e; --ss-search-radius: 20px"
/>See docs/search-ui.md for the full token list, prop tables, and theme modes.
Users can now press Cmd+K to search. See Building a Search UI for scoped search, custom styling, and more patterns.
6. Deploy
SearchSocket is designed to index automatically on deploy. The init command already added the Vite plugin to your config. Set these environment variables on your hosting platform (Vercel, Cloudflare, etc.):
| Variable | Value |
|----------|-------|
| UPSTASH_VECTOR_REST_URL | Your Upstash Vector REST URL |
| UPSTASH_VECTOR_REST_TOKEN | Your Upstash Vector REST token |
| SEARCHSOCKET_AUTO_INDEX | 1 |
Every deploy will build your site, index the content, and serve the search API — fully automated.
For local testing, you can also build and index manually:
pnpm build
pnpm searchsocket index
pnpm searchsocket search "getting started" # confirm the index answersWith that index in place, pnpm dev and Cmd+K give you a working search.
7. Connect Claude Code (optional)
searchsocket init already wrote a .mcp.json that runs the index locally over stdio,
so Claude Code works without deploying anything. To connect to your deployed site
instead, the route has to be enabled in production and given a key (or opened to
anonymous callers deliberately); see MCP Server for both setups.
Querying the API directly
The search API is also available via HTTP and CLI:
# cURL
curl -X POST http://localhost:5173/api/search \
-H "content-type: application/json" \
-d '{"q":"getting started","topK":5,"groupBy":"page"}'
# CLI
pnpm searchsocket search "getting started" --top-k 5Response format
With groupBy: "page" (the default):
{
"q": "getting started",
"scope": "main",
"results": [
{
"url": "/docs/intro",
"title": "Getting Started",
"sectionTitle": "Installation",
"snippet": "Install SearchSocket with pnpm add searchsocket...",
"score": 0.89,
"chunks": [
{
"sectionTitle": "Installation",
"snippet": "Install SearchSocket with pnpm add searchsocket...",
"headingPath": ["Getting Started", "Installation"],
"score": 0.89
},
{
"sectionTitle": "Configuration",
"snippet": "Create searchsocket.config.ts with your API key...",
"headingPath": ["Getting Started", "Configuration"],
"score": 0.74
}
]
}
],
"meta": {
"timingsMs": { "search": 128, "total": 135 }
}
}Both meta.timingsMs.search and meta.timingsMs.total are required. They belong to the response envelope, which the browser client rejects outright when it is malformed (bad individual rows are dropped instead), so a custom endpoint or a test fixture that omits search surfaces as Invalid search response.
The chunks array contains matching sections within each page. Use groupBy: "chunk" for flat per-chunk results without page aggregation.
HTTP API reference
GET <api.path> takes the query as parameters; POST <api.path> takes the same fields as a JSON object body, plus filters (structured metadata, which has no GET spelling).
| Parameter | Type | Notes |
|---|---|---|
| q | string | Required. Empty or missing is a 400 |
| topK | integer | 1–100 |
| maxSubResults | integer | 1–20 |
| groupBy | "page" | "chunk" | Default "page" |
| pathPrefix | string | Scope results to a path |
| scope | string | Index scope |
| tags | string (GET) / string[] (POST) | The only parameter that may repeat on GET; a JSON array in a POST body |
curl "http://localhost:5173/api/search?q=getting+started&topK=5&tags=guide&tags=api"Rules the endpoint enforces:
topKandmaxSubResultsmust be whole integers on GET.10abc,1.5,1e3,0x10, zero and negatives are all 400 — no prefix-parsing.- Repeating a singleton parameter (
q,topK,scope,pathPrefix,groupBy,maxSubResults) is a 400, identical values included. The same rule applies to?scope=on the page-retrieval route. debugandrankingOverridesare refused with 403 on GET and POST alike, for being present at all — the value is irrelevant. They stay on the engine's own API, which the CLI and the local playground call directly.GET <api.path>/healthreturns 200 only when the engine reports healthy, 503 when it does not. The body keeps its existing shape —{ "ok": true }, or{ "ok": false, "details": "..." }— so only the status changed.- Rate-limited responses are 429 and carry
Retry-After, exposed to browser JavaScript viaAccess-Control-Expose-Headerswhen CORS is on. Error responses carrycache-control: no-store. - A record whose score cannot be read as a number is dropped from the results rather than serialized. The browser client would drop such a row anyway; removing it on the server keeps every consumer of the API, not only the bundled client, from seeing it.
Source Modes
SearchSocket supports four ways to load your site content for indexing.
static-output (default)
Reads prerendered HTML files from SvelteKit's build output directory.
export default {
source: {
mode: "static-output",
staticOutputDir: "build" // default
}
};Best for fully prerendered sites. Run vite build first, then searchsocket index.
build
Discovers routes from SvelteKit's build manifest and renders via an ephemeral vite preview server. No manual route lists needed.
export default {
source: {
mode: "build",
build: {
exclude: ["/api/*", "/admin/*"],
paramValues: {
"/blog/[slug]": ["hello-world", "getting-started"],
"/docs/[category]/[page]": ["guides/quickstart", "api/search"]
},
discover: true, // crawl internal links to find more pages
seedUrls: ["/"],
maxPages: 200,
maxDepth: 5
}
}
};Best for CI/CD pipelines: vite build && searchsocket index with zero route configuration.
crawl
Fetches pages from a running HTTP server.
export default {
source: {
mode: "crawl",
crawl: {
baseUrl: "http://localhost:4173",
routes: ["/", "/docs", "/blog"],
sitemapUrl: "https://example.com/sitemap.xml"
}
}
};content-files
Reads markdown and Svelte source files directly, without building or serving.
export default {
source: {
mode: "content-files",
contentFiles: {
globs: ["src/routes/**/*.md", "content/**/*.md"],
baseDir: "."
}
}
};Raw .svelte files are read as source, never rendered, so exclusion signals are read
statically. A <SearchSocket noindex />, an ignore attribute, or weight={0} with a
literal value is honoured. When a recognised protective signal is present but
not statically decidable — the value is an expression, the element sits inside an
{#if}, or the props are spread over an element the scanner can tie to a protective
attribute — the page is withheld and a warning is logged. Reading an undecidable
protective signal as "false" would publish a page someone asked to hold back, so the
strict direction is deliberate.
Known gaps, all pinned by tests: content inside {@html} and {@render} is not
extracted; signals in a +layout.svelte do not propagate to the pages beneath it; a
conditional or expression-valued data-search-ignore drops that subtree without
withholding the page; and a spread that hides a protective attribute the scanner cannot
see in the source text is not detected. Use the static-output or build source mode
when a page's exclusion depends on anything computed at runtime.
Client Library
createSearchClient(options?)
Lightweight browser-side search client.
import { createSearchClient } from "searchsocket/client";
const client = createSearchClient({
endpoint: "/api/search", // default
fetchImpl: fetch // override for SSR or testing
});
const { results } = await client.search({
q: "deployment guide",
topK: 8,
groupBy: "page",
pathPrefix: "/docs",
tags: ["guide"],
filters: { version: 2 },
maxSubResults: 3
});The client validates the response before returning it. A broken envelope — no q or scope, a results value that is not an array, or a meta.timingsMs missing search or total — is rejected with Invalid search response. Individual rows are handled more narrowly: a malformed result (a non-finite score, say) or one whose URL is not safe to navigate to is dropped, as is a malformed chunk within a result, and the rest of the response is returned. createSearch in searchsocket/svelte applies the same rules. To validate a response you fetched yourself, searchsocket/client exports parseSearchResponse (the cleaned response, or null for a broken envelope) and isSearchResponse, a strict guard that fails if any row would be dropped. The client also strips debug and rankingOverrides before sending, so type-correct code that names them does not come back as a 403.
buildResultUrl(result)
Builds a URL from a search result that includes scroll-to-text metadata:
_sskquery parameter — section title for SvelteKit client-side navigation_ssktquery parameter — text target snippet for precise scroll#:~:text=— Text Fragment for native browser scroll on full page loads
import { buildResultUrl } from "searchsocket/client";
const href = buildResultUrl(result);
// "/docs/getting-started?_ssk=Installation&_sskt=Install+with+pnpm#:~:text=Install%20with%20pnpm"isSafeResultUrl(value)
Whether a result URL is safe to render as an href or hand to a router. javascript:, data:, URLs carrying control characters, and anything the URL parser cannot resolve are refused; ordinary relative, protocol-relative and http(s) URLs pass. Exported from both searchsocket/client and searchsocket/svelte.
buildResultUrl() returns "" for an unsafe result, and the shipped SearchResults template renders such a row as a non-navigable span rather than a link. Treat an empty string as "do not link this".
Svelte 5 Integration
createSearch(options?)
A reactive search store built on Svelte 5 runes with debouncing and LRU caching.
<script>
import { createSearch } from "searchsocket/svelte";
import { buildResultUrl } from "searchsocket/client";
const search = createSearch({
endpoint: "/api/search",
debounce: 250, // ms (default)
cache: true, // LRU result caching (default)
cacheSize: 50, // max cached queries (default)
topK: 10,
groupBy: "page",
pathPrefix: "/docs" // scope search to a section
});
</script>
<input bind:value={search.query} placeholder="Search docs..." />
{#if search.loading}
<p>Searching...</p>
{/if}
{#if search.error}
<p class="error">{search.error.message}</p>
{/if}
{#each search.results as result}
<a href={buildResultUrl(result)}>
<strong>{result.title}</strong>
{#if result.sectionTitle}
<span>— {result.sectionTitle}</span>
{/if}
</a>
<p>{result.snippet}</p>
{/each}Call search.destroy() when the search is no longer needed. This is not
automatic: createSearch() uses $effect.root, which returns a teardown
function rather than registering one with the surrounding component. In a
component, call it from onDestroy:
<script>
import { onDestroy } from "svelte";
import { createSearch } from "searchsocket/svelte";
const search = createSearch();
onDestroy(search.destroy);
</script><SearchSocket> component
Declarative meta tag component for controlling per-page search behavior:
<script>
import { SearchSocket } from "searchsocket/svelte";
</script>
<!-- Boost this page's search ranking -->
<SearchSocket weight={1.2} />
<!-- Exclude from search -->
<SearchSocket noindex />
<!-- Add filterable tags -->
<SearchSocket tags={["guide", "advanced"]} />
<!-- Add structured metadata (filterable via search API) -->
<SearchSocket meta={{ version: 2, category: "api" }} />The component renders <meta> tags in <svelte:head> that SearchSocket reads during indexing.
Template components
Copy ready-made search UI components into your project:
pnpm searchsocket add search-dialog # Cmd+K command palette
pnpm searchsocket add search-input # inline field with a dropdown
pnpm searchsocket add search-results # standalone result list
pnpm searchsocket add search-trigger # the button that opens the dialogEach command writes a self-contained kit to src/lib/components/search/ (configurable via --dir): the component, a shared SearchResultRow.svelte, pure helpers in search-ui.ts, and search-theme.css. You own the Svelte 5 source — edit it freely, it is never updated under you. It is not standalone, though: the shared helpers import types from searchsocket, and the interactive components import createSearch from searchsocket/svelte and createSearchClient/buildResultUrl from searchsocket/client. The package stays a dependency; what you own is the markup and the styling.
The default is styled with plain CSS and semantic --ss-search-* variables, so it works with or without Tailwind and looks finished without edits. Existing files are never overwritten without --overwrite, so adding a second component keeps any changes you made to the shared files.
Scroll-to-Text Navigation
When a user clicks a search result, SearchSocket scrolls them to the matching section on the destination page.
Setup
Add the scroll handler to your root layout:
<!-- src/routes/+layout.svelte -->
<script>
import { afterNavigate } from '$app/navigation';
import { searchsocketScrollToText } from 'searchsocket/scroll';
afterNavigate(searchsocketScrollToText);
</script>How it works
buildResultUrl()encodes the section title and text snippet into the URL- On SvelteKit client-side navigation, the
afterNavigatehook reads_ssk/_ssktparams - A TreeWalker-based text mapper finds the exact position in the DOM
- The page scrolls smoothly to the match
- The matching text is highlighted using the CSS Custom Highlight API (with a DOM fallback for older browsers)
- On full page loads, browsers that support Text Fragments (
#:~:text=) handle scrolling natively
The highlight fades after 2 seconds. Customize with CSS:
::highlight(ssk-search-match) {
background-color: rgba(250, 204, 21, 0.4);
}Search & Ranking
Page-first search
SearchSocket searches page-first: one query ranks page summaries, then the best-matching sections within the top pages are retrieved and attached as sub-results. This keeps results coherent at page level while still pointing at the exact section that matched.
Section lookups are bounded — only the top pages are expanded, and those
requests run through a concurrency limit — so a large topK cannot fan out into
one backend request per result.
Page weights
A page's weight multiplies its final score. It comes from the page itself —
<meta name="searchsocket-weight" content="1.5"> or searchsocket.weight in
frontmatter — falling back to a ranking.pageWeights pattern. A weight of 0
from either source excludes the page entirely, so an operator can suppress
pages regardless of what their markup asks for.
Ranking configuration
export default {
ranking: {
enableIncomingLinkBoost: true, // boost pages with more internal links pointing to them
enableDepthBoost: true, // boost shallower pages (/ > /docs > /docs/api)
enableFreshnessBoost: false, // boost recently published content
enableAnchorTextBoost: false, // boost pages whose link text matches the query
pageWeights: { // per-URL score multipliers. Patterns, not raw
// prefixes: "/docs" is an exact match, "/docs/*"
// is one level down, "/docs/**" is the subtree
"/": 0.95,
"/docs": 1.15,
"/download": 1.05
},
minScoreRatio: 0.70, // drop results scoring below 70% of the top result
scoreGapThreshold: 0.4, // page mode only: cut the list at the first
// adjacent pair whose score drops 40% or more
weights: {
incomingLinks: 0.05,
depth: 0.03,
titleMatch: 0.15,
freshness: 0.1,
anchorText: 0.10
}
}
};Use gentle pageWeights values (0.9–1.2) since they compound with other boosts.
Build-Triggered Indexing
The recommended workflow is to index automatically on every deploy. Add the Vite plugin to your config:
// vite.config.ts
import { sveltekit } from "@sveltejs/kit/vite";
import { searchsocketVitePlugin } from "searchsocket/sveltekit";
export default {
plugins: [
sveltekit(),
searchsocketVitePlugin({
changedOnly: true, // incremental indexing (default)
verbose: true
})
]
};By default the plugin skips indexing with a warning when no Upstash credentials are
configured, so a contributor without secrets can still run a build. On a deploy build
that is usually the wrong trade — the site ships with a stale index and nothing fails.
Pass allowUnconfigured: false to turn the missing backend into a build failure. It only
bites when auto-indexing actually runs — the SSR build pass, with enabled: true or
SEARCHSOCKET_AUTO_INDEX set — so a deploy build usually wants both:
searchsocketVitePlugin({
enabled: true, // or set SEARCHSOCKET_AUTO_INDEX=1 in the deploy env
allowUnconfigured: false // fail the build instead of skipping indexing
})Vercel / Cloudflare / Netlify
Set these environment variables in your hosting platform:
| Variable | Value |
|----------|-------|
| UPSTASH_VECTOR_REST_URL | Your Upstash Vector REST URL |
| UPSTASH_VECTOR_REST_TOKEN | Your Upstash Vector REST token |
| SEARCHSOCKET_AUTO_INDEX | 1 |
Every deploy will build your site, index the content into Upstash, and serve the search API and MCP endpoint — fully automated.
Environment variable control
# Enable indexing on build
SEARCHSOCKET_AUTO_INDEX=1 pnpm build
# Disable temporarily
SEARCHSOCKET_DISABLE_AUTO_INDEX=1 pnpm build
# Force full rebuild (ignore incremental cache)
SEARCHSOCKET_FORCE_REINDEX=1 pnpm buildMaking Images Searchable
SearchSocket converts images to text during extraction using this priority chain:
data-search-descriptionon the<img>— your explicit descriptiondata-search-descriptionon the parent<figure>alttext +<figcaption>combinedalttext alone (filters generic words like "image", "icon")<figcaption>alone- Removed — images with no useful text are dropped
<img
src="/screenshots/settings.png"
alt="Settings page"
data-search-description="The settings page showing API key configuration, theme selection, and notification preferences"
/>Works with SvelteKit's enhanced:img:
<enhanced:img
src="./screenshots/dashboard.png"
alt="Dashboard"
data-search-description="Main dashboard showing active projects and indexing status"
/>MCP Server
SearchSocket includes an MCP server that gives Claude Code, Claude Desktop, and other MCP clients direct access to your site's search index. The MCP endpoint is built into searchsocketHandle() — once your site is deployed, any MCP client can connect to it over HTTP.
Available tools
| Tool | Description |
|------|-------------|
| search | Semantic search with filtering and section sub-results |
| get_page | Retrieve a page's indexed markdown and frontmatter |
| get_related_pages | Find related pages by links, semantics, and structure |
Connecting to your deployed site
The recommended setup is to connect Claude Code to your deployed site's MCP endpoint. This way the index stays up to date automatically as you deploy, and there's no local process to manage.
The route is off by default in production and, once on, fails closed. Its tools
return repository paths, a page's indexed markdown, and any scope the caller names, so
every MCP call answers 503 until you configure a key or opt into anonymous access.
mcp.enable defaults to NODE_ENV !== "production", so a deployed site must set it
explicitly.
Private (API key). Your own agents get full results; anyone without the key gets a
401. Enable the route in searchsocket.config.ts:
import type { SearchSocketConfig } from "searchsocket";
export default {
project: { id: "my-site", baseUrl: "https://your-site.com" },
source: { mode: "build" },
mcp: {
enable: true, // required: the default is off in production
handle: {
access: "private", // default
// Read from the environment rather than committing the key.
apiKeyEnv: "SEARCHSOCKET_MCP_API_KEY"
}
}
} satisfies SearchSocketConfig;Pass the whole config to the hook, so it is bundled with the server (serverless hosts
cannot read searchsocket.config.ts from disk at runtime):
// src/hooks.server.ts
import { searchsocketHandle } from "searchsocket/sveltekit";
import config from "../searchsocket.config";
export const handle = searchsocketHandle({ rawConfig: config });Set SEARCHSOCKET_MCP_API_KEY in the deployment environment (for example
openssl rand -hex 32), then send the same key from .mcp.json:
{
"mcpServers": {
"searchsocket": {
"type": "http",
"url": "https://your-site.com/api/mcp",
"headers": {
"Authorization": "Bearer ${SEARCHSOCKET_MCP_API_KEY}"
}
}
}
}The ${SEARCHSOCKET_MCP_API_KEY} syntax references an environment variable so you don't hardcode secrets in .mcp.json.
Anonymous public. For public documentation, set access: "public" instead:
mcp: {
enable: true,
handle: {
access: "public",
// Optional: a caller presenting this key still gets full results.
apiKeyEnv: "SEARCHSOCKET_MCP_API_KEY"
}
}The hook is unchanged, and .mcp.json needs no headers:
{
"mcpServers": {
"searchsocket": {
"type": "http",
"url": "https://your-site.com/api/mcp"
}
}
}Anonymous callers get the same redacted result set the browser API returns —
routeFile, chunkText and breakdown stripped, any scope argument ignored. A
wrong key is still a 401. This is a deliberate opt-in, not a fallback. The full
walkthrough is in docs/mcp-claude-code.md.
Auto-approving in Claude Code
Skip the approval prompt each time a tool is called:
{
"allowedMcpServers": [
{ "serverName": "searchsocket" }
]
}Add this to .claude/settings.json in your project.
Local development
During local development, you can point to your dev server instead:
{
"mcpServers": {
"searchsocket": {
"type": "http",
"url": "http://localhost:5173/api/mcp"
}
}
}Claude Desktop
Add to ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"searchsocket": {
"command": "npx",
"args": ["searchsocket", "mcp"],
"cwd": "/path/to/your/project"
}
}
}Standalone HTTP server
Run the MCP server as a standalone process (outside SvelteKit):
pnpm searchsocket mcp --transport http --port 3338llms.txt Generation
Generate llms.txt files during indexing — a standardized way to make your site content available to LLMs.
export default {
project: {
baseUrl: "https://example.com"
},
llmsTxt: {
enable: true,
title: "My Project",
description: "Documentation for My Project",
outputPath: "static/llms.txt", // default
generateFull: true, // also generate llms-full.txt
serveMarkdownVariants: false // serve /page.md variants via the hook
}
};Both files open with a short Retrieval API section, so an agent that finds the file
can query the index instead of crawling the page list. It names the search endpoint
(always — the route needs no credential), and the MCP endpoint only when that endpoint
would actually answer: MCP enabled, and either mcp.handle.access: "public" or an API
key that resolves at index time.
The advertised URLs come from api.path and mcp.handle.path in your config, read at
index time. If you override the served path with searchsocketHandle({ path }), set
api.path to the same value or the file will advertise a URL your site does not serve.
After indexing, llms.txt (page index with links) and llms-full.txt (full content) are written to your static directory. searchsocketHandle() serves the file at llmsTxt.outputPath directly; llms-full.txt has no handler of its own and is served as a static asset, so it has to be in the deployed output. If your adapter copies static/ before indexing runs, generate these ahead of the adapter step or serve them from your own route.
These are published artifacts with no second copy to recover from, so they are only replaced by a run that held the complete page list. A truncated crawl, a failed extraction, or a refused deletion plan leaves the existing files alone and logs the reason. A chunk limit does not freeze them — it truncates no pages.
There is one sharp edge worth knowing about. If the index holds custom records and a
run is not passed customRecords, those pages are absent from the run's page list even
though they are still in the index, so publishing would silently drop them from the
public list. The exports are therefore left as they are, and the warning says exactly
that: pass customRecords to refresh them. Passing customRecords: [] explicitly
asserts there are none.
CLI Commands
searchsocket init
Initialize config and state directory. Creates searchsocket.config.ts, .searchsocket/, .mcp.json, and wires up your hooks and Vite config.
pnpm searchsocket init
pnpm searchsocket init --non-interactivesearchsocket index
Index content into Upstash Vector.
pnpm searchsocket index # incremental (default: --changed-only)
pnpm searchsocket index --force # full re-index
pnpm searchsocket index --source build # override source mode
pnpm searchsocket index --scope staging # override scope
pnpm searchsocket index --dry-run # preview without writing
pnpm searchsocket index --max-pages 10 # limit for testing (no deletions when it truncates)
pnpm searchsocket index --verbose # detailed output
pnpm searchsocket index --json # machine-readable outputDeletion safety
An indexing run removes stale records only when it observed the complete source
of truth. A run truncated by --max-pages/--max-chunks, one that failed to
fetch or extract a page, or one whose source unexpectedly returned nothing is
reported as deletionEligible: false and deletes nothing — the stale records
are left in place rather than risking the loss of a valid index.
Two further guards need an explicit opt-in:
# The source legitimately produced zero pages and you want the index emptied
pnpm searchsocket index --allow-empty
# The run would remove more than indexing.maxDeletionRatio (default 50%)
pnpm searchsocket index --accept-large-deletionWhen the ratio guard refuses a run, the run writes nothing — not the deletions
and not the additions either. Withholding only the deletions used to leave
the replacement records written, which enlarged the inventory the ratio is measured
against; an identical rerun then saw the same stale records as a smaller fraction and
deleted them without --accept-large-deletion ever being supplied. Refusing the whole
mutation is what keeps a refused plan refused however often you repeat it, and leaves
the index exactly as it was last accepted. The empty-source guard is narrower: it stops
deletions but still writes the run's additions, because an empty site source is a source
problem rather than a plan whose arithmetic a retry could game.
An unreadable robots.txt blocks index writes, in crawl mode with
respectRobotsTxt on — the only mode that fetches it over the network. If the fetch
times out, returns a server error, or exceeds the 512 KiB size cap, the run cannot tell
which pages the site means to exclude, so it writes nothing rather than risk publishing
a page that was meant to stay out. A 404 or 410 is not unreadable: that is how a site
says "no rules here", and indexing proceeds. (static-output and build read
robots.txt from the build directory and treat an unreadable file as absent;
content-files does not consult it at all.)
Rule matching follows RFC 9309 for the parts that matter in practice: * wildcards, the
$ end-anchor, group merging across consecutive User-agent lines, and
longest-rule-wins specificity with Allow beating an equally long Disallow. It is not
a certified-complete implementation — percent-encoded paths are compared as written
rather than canonicalised, so Disallow: /admin does not also block /%61dmin.
Chunk and page metadata is budgeted in UTF-8 bytes against Upstash's 48 KB per-record limit, measured on the exact JSON that will be sent. The one trimmable field (the record's text) is truncated to fit, with a warning naming the record. If the required fields alone still do not fit, the run fails with the page URL and the name and size of the largest offending field, rather than letting the backend reject one item in the middle of a half-written batch.
searchsocket index exits 5 when no vector backend is configured. Pass
--allow-unconfigured to skip indexing without failing (the Vite plugin already
behaves this way).
searchsocket search
CLI search for testing. The query is a positional argument; --q <query> is also
accepted.
pnpm searchsocket search "getting started" --top-k 5
pnpm searchsocket search "api" --path-prefix /docssearchsocket dev
Watch for file changes and auto-reindex, with optional playground UI.
pnpm searchsocket dev # watch + playground at :3337
pnpm searchsocket dev --mcp # also start MCP HTTP server
pnpm searchsocket dev --no-playground # watch onlysearchsocket status
Show indexing status and backend health.
pnpm searchsocket statussearchsocket doctor
Validate config, env vars, provider connectivity, and local write access — it writes a probe file into the state directory. It does not test write permission against the remote index.
pnpm searchsocket doctorsearchsocket test
Run search quality assertions against the live index.
pnpm searchsocket test # uses searchsocket.test.json
pnpm searchsocket test --file custom-tests.json # custom test fileTest file format:
[
{
"query": "installation guide",
"expect": {
"topResult": "/docs/getting-started",
"inTop5": ["/docs/getting-started", "/docs/quickstart"]
}
}
]Reports pass/fail per assertion and Mean Reciprocal Rank (MRR) across all queries.
searchsocket clean
Delete local state and optionally remote indexes.
With --remote, the command is plan-only until --apply — it deletes nothing
at all, local state included, and prints what it would do. --apply performs both
the remote deletion and the local one; --keep-local suppresses the local deletion
on an applied run. Without --remote, clean removes the local state directory
immediately.
pnpm searchsocket clean # local state only
pnpm searchsocket clean --remote --scope staging # show the plan
pnpm searchsocket clean --remote --scope staging --apply # delete that scope
pnpm searchsocket clean --keep-local --remote --scope staging --applyDropping every scope in the project needs an explicit confirmation token:
pnpm searchsocket clean --remote --all-scopes --apply --confirm-project my-site--scope applies to the remote deletion only. Without --remote the command
just removes the local state directory.
searchsocket prune
List and delete stale scopes. Compares against remote git branches to find orphaned scopes.
pnpm searchsocket prune # dry-run (default)
pnpm searchsocket prune --apply # delete orphaned scopes
pnpm searchsocket prune --older-than 30d --apply # orphaned AND inactive 30d
pnpm searchsocket prune --older-than 30d --match any --apply # orphaned OR inactive
pnpm searchsocket prune --protect staging,demo --apply # never touch thesePrune fails closed. It refuses to run when the remote branch list cannot be
trusted — a shallow clone, a repository with no remotes, or an empty
--scopes-file — because every scope would otherwise look orphaned. In CI,
check out with full history (actions/checkout with fetch-depth: 0) or pass
--scopes-file. Scopes with no recorded index timestamp are skipped by
--older-than rather than assumed old, and the current scope plus main are
always protected.
searchsocket mcp
Run the MCP server standalone.
pnpm searchsocket mcp # transport from mcp.transport (default stdio)
pnpm searchsocket mcp --transport http --port 4000 # HTTP on a specific port
pnpm searchsocket mcp --access public --api-key SECRET # public with auth--transport, --port and --path override mcp.transport, mcp.http.port and
mcp.http.path only when passed; otherwise the config (or its defaults: stdio, 3338,
/mcp) applies. The same holds for dev --mcp-port and --mcp-path. --access and
--api-key are merged into the config before it is validated, so
mcp.access: "public" with the key supplied only by --api-key is accepted.
searchsocket add
Copy Svelte 5 search UI template components into your project. They require Svelte 5.20 or newer.
pnpm searchsocket add search-dialog
pnpm searchsocket add search-input
pnpm searchsocket add search-results
pnpm searchsocket add search-trigger
pnpm searchsocket add search-dialog --dir src/lib/components/ui # custom dir
pnpm searchsocket add search-dialog --overwrite # replace existing filesExit codes
Every command exits with one of these codes. They are stable: scripts and CI may branch on them.
| Code | Meaning | Examples |
| --- | --- | --- |
| 0 | Success | |
| 1 | Operational failure | a failed doctor check, an unexpected error during a run |
| 2 | Invalid usage | unknown command or option, bad flag value (including --top-k above 100), missing or invalid searchsocket.config.ts, unreadable searchsocket test file or a query in it the engine refuses |
| 3 | Quality gate failed | searchsocket test assertions did not pass, with every query retrieved |
| 4 | Destructive operation refused | clean, prune or migrate cleanup-legacy declined to delete without the required confirmation |
| 5 | Search backend unavailable or unconfigured | index, search or test with no vector backend configured; status reporting the backend unhealthy; status, prune, search or test failing against Upstash Vector (including rate limiting) |
Real-World Example
Here's how Canopy integrates SearchSocket into a production SvelteKit site.
Configuration
// searchsocket.config.ts
export default {
project: {
id: "canopy-website",
baseUrl: "https://canopy.dev"
},
source: {
mode: "build"
},
extract: {
dropSelectors: [".nav-blur", ".mobile-overlay", ".docs-sidebar"]
},
ranking: {
minScoreRatio: 0.70,
pageWeights: {
"/": 0.95,
"/download": 1.05,
"/docs/**": 1.05
},
},
mcp: {
// Off in production unless enabled; private with a key from the environment.
enable: true,
handle: { apiKeyEnv: "SEARCHSOCKET_MCP_API_KEY" }
}
};Server hook
// src/hooks.server.ts
import { searchsocketHandle } from "searchsocket/sveltekit";
import { env } from "$env/dynamic/private";
import config from "../searchsocket.config";
// Start from the full config so the hook serves exactly what was indexed, and
// only add what has to come from the runtime environment.
export const handle = searchsocketHandle({
rawConfig: {
...config,
upstash: {
url: env.UPSTASH_VECTOR_REST_URL,
token: env.UPSTASH_VECTOR_REST_TOKEN
}
}
});Search modal with scoped search
<!-- SearchModal.svelte -->
<script>
import { createSearchClient, buildResultUrl } from "searchsocket/client";
let { open = $bindable(false), pathPrefix = "", placeholder = "Search..." } = $props();
const client = createSearchClient();
let query = $state("");
let results = $state([]);
async function doSearch() {
if (!query.trim()) { results = []; return; }
const res = await client.search({
q: query,
topK: 8,
groupBy: "page",
pathPrefix: pathPrefix || undefined
});
results = res.results;
}
</script>
{#if open}
<dialog open>
<input bind:value={query} oninput={doSearch} {placeholder} />
{#each results as result}
<a href={buildResultUrl(result)} onclick={() => open = false}>
<strong>{result.title}</strong>
{#if result.sectionTitle}<span>— {result.sectionTitle}</span>{/if}
<p>{result.snippet}</p>
</a>
{/each}
</dialog>
{/if}Scroll-to-text in layout
<!-- src/routes/+layout.svelte -->
<script>
import { afterNavigate } from "$app/navigation";
import { searchsocketScrollToText } from "searchsocket/scroll";
afterNavigate(searchsocketScrollToText);
</script>Deploy and index
Indexing runs automatically on every Vercel deploy. Set these env vars in the Vercel dashboard:
UPSTASH_VECTOR_REST_URLUPSTASH_VECTOR_REST_TOKENSEARCHSOCKET_AUTO_INDEX=1
The Vite plugin handles the rest. Alternatively, use a postbuild script:
{
"scripts": {
"build": "vite build",
"postbuild": "searchsocket index"
}
}Connect Claude Code to the deployed site
With SEARCHSOCKET_MCP_API_KEY set in Vercel and exported in the shell that starts
Claude Code:
{
"mcpServers": {
"searchsocket": {
"type": "http",
"url": "https://canopy.dev/api/mcp",
"headers": {
"Authorization": "Bearer ${SEARCHSOCKET_MCP_API_KEY}"
}
}
}
}A site serving public documentation can use mcp.handle.access: "public" instead and
drop the header; see MCP Server.
Now Claude Code can search the live docs, retrieve page content, and find source files — all backed by the production index that stays current with every deploy.
Excluding pages from search
<!-- src/routes/blog/+page.svelte (archive page) -->
<svelte:head>
<meta name="searchsocket-weight" content="0" />
</svelte:head>Or with the component:
<script>
import { SearchSocket } from "searchsocket/svelte";
</script>
<SearchSocket weight={0} />Vite SSR config
// vite.config.ts
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [sveltekit()],
ssr: {
external: ["searchsocket", "searchsocket/sveltekit", "searchsocket/client", "searchsocket/scroll"]
}
});Environment Variables
Required
| Variable | Description |
|----------|-------------|
| UPSTASH_VECTOR_REST_URL | Upstash Vector REST API endpoint |
| UPSTASH_VECTOR_REST_TOKEN | Upstash Vector REST API token |
Optional
| Variable | Description |
|----------|-------------|
| SEARCHSOCKET_SCOPE | Override scope (when scope.mode: "env") |
| SEARCHSOCKET_AUTO_INDEX | Enable build-triggered indexing (1, true, or yes) |
| SEARCHSOCKET_DISABLE_AUTO_INDEX | Disable build-triggered indexing |
| SEARCHSOCKET_FORCE_REINDEX | Force full re-index in CI/CD |
The CLI automatically loads .env from the working directory on startup.
Configuration Reference
See docs/config.md for the full configuration reference. The config is
validated strictly: an unknown or misspelled key, a removed option, or a URL pattern
outside the supported syntax fails with CONFIG_INVALID
and names the offending key. Here's the full example:
export default {
project: {
id: "my-site",
baseUrl: "https://example.com"
},
scope: {
mode: "git", // "fixed" | "git" | "env"
fixed: "main",
sanitize: true
},
exclude: ["/admin/*", "/api/*"],
respectRobotsTxt: true,
source: {
mode: "build",
staticOutputDir: "build",
build: {
exclude: ["/api/*"],
paramValues: {
"/blog/[slug]": ["hello-world", "getting-started"]
},
discover: true,
maxPages: 200
}
},
extract: {
mainSelector: "main",
dropTags: ["header", "nav", "footer", "aside"],
dropSelectors: [".sidebar", ".toc"],
ignoreAttr: "data-search-ignore",
noindexAttr: "data-search-noindex",
imageDescAttr: "data-search-description"
},
chunking: {
maxChars: 1500,
overlapChars: 200,
minChars: 250,
prependTitle: true,
pageSummaryChunk: true
},
upstash: {
urlEnv: "UPSTASH_VECTOR_REST_URL",
tokenEnv: "UPSTASH_VECTOR_REST_TOKEN"
},
ranking: {
enableIncomingLinkBoost: true,
enableDepthBoost: true,
pageWeights: { "/docs": 1.15 },
minScoreRatio: 0.70,
},
api: {
path: "/api/search",
cors: { allowOrigins: ["https://example.com"] }
},
mcp: {
enable: true,
handle: { path: "/api/mcp" }
},
llmsTxt: {
enable: true,
title: "My Project",
description: "Documentation for My Project"
},
state: {
dir: ".searchsocket"
}
};CI/CD
See docs/ci.md for ready-to-use GitHub Actions workflows covering:
- Main branch indexing on push
- PR dry-run validation
- Preview branch scope isolation
- Scheduled scope pruning
- Vercel build-triggered indexing
Further Reading
- Building a Search UI — Cmd+K modals, scoped search, styling, and API reference
- Tuning Search Relevance — visual playground, ranking parameters, and search quality testing
- Configuration Reference — all config options, indexing hooks, and custom records
- CI/CD Workflows — GitHub Actions and Vercel integration
- MCP over HTTP Guide — detailed HTTP MCP setup for Claude Code
- Troubleshooting — common issues, diagnostics, and FAQ
Contributing
This repo follows Git Flow:
| Branch | Purpose |
| --- | --- |
| main | Production. Only ever receives merges from release/* and hotfix/*, and carries the v* release tags. |
| develop | Integration branch and the default PR target. |
| feature/* | Branched from develop, merged back into develop. |
| release/* | Branched from develop, merged into both main and develop. |
| hotfix/* | Branched from main, merged into both main and develop. |
# start a feature
git switch develop && git pull
git switch -c feature/my-thing
# open the PR against develop
gh pr create --base developLocal development:
pnpm install
pnpm run typecheck # tsc --noEmit
pnpm run test # vitest run
pnpm run build # tsup → dist/CI runs typecheck, build, the test suite, and the packed-tarball check on Node 22 and 24 for every push to
main/develop/release/*/hotfix/* and every PR into main or develop.
pnpm run test:quality runs Mean Reciprocal Rank assertions against a live index. It needs
Upstash credentials and is not part of CI — run it locally when changing src/search/ranking.ts.
Releases are cut from a release/* branch: bump the version, merge to main, then push the
v* tag. The publish workflow builds, tests, and publishes to
NPM via Trusted Publishing (OIDC).
License
MIT
