Guide · Search Integration
MCP Tools for Apache Solr — soft commits, cursor pagination, schema API
Three Solr behaviours catch MCP server authors off-guard: there is no official Solr JavaScript/TypeScript client — most Node.js MCP servers use raw HTTP calls or the community solr-client package, which means you are responsible for retry logic, URL construction, and response parsing that a managed client would provide; Solr distinguishes between soft commits (documents become visible in search but are not written to disk) and hard commits (durably written) — an MCP tool that indexes with softCommit=true and then crashes before Solr auto-commits will lose those documents on recovery; and offset-based pagination (start + rows) becomes increasingly slow and inconsistent as offsets grow — Solr's cursor-based pagination using cursorMark=* and sorting by a unique field is the correct approach for MCP tools that iterate through large result sets, but it requires a sort clause that includes the id field as a tiebreaker.
TL;DR
Use commit=true for durability, softCommit=true for near-real-time visibility with auto-commit configured in solrconfig.xml. Use cursor pagination (cursorMark=* → nextCursorMark) for large result exports. Add schema fields via the Schema API (POST /solr/{core}/schema) rather than editing schema.xml. Health probe: GET /solr/{core}/admin/ping.
HTTP client setup — no official JS client
Solr does not publish an official JavaScript client. The options for MCP server authors are: raw fetch/axios calls to the Solr REST API, the community solr-client package (maintained but not officially supported), or the solr-node package. For production MCP servers, raw fetch with a thin wrapper class gives the most control and avoids unvetted dependencies.
class SolrClient {
private readonly baseUrl: string;
private readonly core: string;
private readonly auth?: { username: string; password: string };
constructor(options: {
host?: string;
port?: number;
core: string;
username?: string;
password?: string;
protocol?: 'http' | 'https';
}) {
const { host = 'localhost', port = 8983, core, username, password, protocol = 'http' } = options;
this.baseUrl = \`\${protocol}://\${host}:\${port}/solr\`;
this.core = core;
if (username && password) this.auth = { username, password };
}
private authHeader(): Record<string, string> {
if (!this.auth) return {};
const encoded = Buffer.from(\`\${this.auth.username}:\${this.auth.password}\`).toString('base64');
return { Authorization: \`Basic \${encoded}\` };
}
async query(params: Record<string, string | number | string[]>): Promise<any> {
const searchParams = new URLSearchParams();
searchParams.set('wt', 'json'); // Always request JSON format
for (const [key, value] of Object.entries(params)) {
if (Array.isArray(value)) {
value.forEach(v => searchParams.append(key, String(v)));
} else {
searchParams.set(key, String(value));
}
}
const url = \`\${this.baseUrl}/\${this.core}/select?\${searchParams}\`;
const response = await fetch(url, {
headers: { ...this.authHeader() },
signal: AbortSignal.timeout(15_000), // 15-second timeout per query
});
if (!response.ok) {
const text = await response.text();
throw new Error(\`Solr query failed: HTTP \${response.status} — \${text.slice(0, 300)}\`);
}
return response.json();
}
async update(documents: object[], params: { commit?: boolean; softCommit?: boolean } = {}): Promise<any> {
const queryParams = new URLSearchParams({ wt: 'json' });
if (params.commit) queryParams.set('commit', 'true');
if (params.softCommit) queryParams.set('softCommit', 'true');
const url = \`\${this.baseUrl}/\${this.core}/update?\${queryParams}\`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.authHeader(),
},
body: JSON.stringify(documents),
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
const text = await response.text();
throw new Error(\`Solr update failed: HTTP \${response.status} — \${text.slice(0, 300)}\`);
}
return response.json();
}
}
const solr = new SolrClient({
host: process.env.SOLR_HOST ?? 'localhost',
port: Number(process.env.SOLR_PORT ?? 8983),
core: process.env.SOLR_CORE ?? 'products',
username: process.env.SOLR_USERNAME,
password: process.env.SOLR_PASSWORD,
protocol: (process.env.SOLR_PROTOCOL ?? 'http') as 'http' | 'https',
});
Soft commits vs hard commits — visibility vs durability in tool handlers
Solr has two commit types. A soft commit (softCommit=true) makes newly indexed documents visible to search queries without flushing to disk — it is fast (milliseconds) but the documents exist only in memory. A hard commit (commit=true) writes the index segment to disk and makes documents durable — it is slower (tens to hundreds of milliseconds depending on index size) but survives process restarts.
Most production configurations combine both: index documents with softCommit=true for near-real-time visibility, and let Solr perform automatic hard commits in the background (configured in solrconfig.xml via autoCommit with maxTime or maxDocs). MCP tools that index documents should understand which commit strategy the cluster uses.
// MCP tool: index with soft commit (near-real-time, not durable)
server.tool('index_product_fast', async ({ product }) => {
// softCommit=true — document visible to search within ~1s
// but NOT durable — a Solr crash before autoCommit runs loses this document
const result = await solr.update(
[{
id: product.id, // Required: unique key field
title: product.title,
description: product.description,
category: product.category,
price: { set: product.price }, // 'set' for atomic partial update
brand: product.brand,
inStock: product.inventory > 0,
lastIndexed: new Date().toISOString(),
}],
{ softCommit: true }
);
return { indexed: true, status: result.responseHeader.status };
});
// MCP tool: index with hard commit (durable, slightly slower)
server.tool('index_product_durable', async ({ product }) => {
// commit=true — document durable AND visible; safe to return "confirmed"
const result = await solr.update(
[{
id: product.id,
title: product.title,
description: product.description,
category: product.category,
price: product.price,
brand: product.brand,
inStock: product.inventory > 0,
}],
{ commit: true }
);
return { indexed: true, durable: true, status: result.responseHeader.status };
});
// MCP tool: atomic partial update — modify specific fields without re-indexing
server.tool('update_product_stock', async ({ productId, inStock, quantity }) => {
// Solr atomic updates — only modify specified fields
const result = await solr.update(
[{
id: productId,
inStock: { set: inStock },
quantity: { set: quantity },
lastUpdated: { set: new Date().toISOString() },
}],
{ softCommit: true }
);
return { updated: true, productId };
});
If your Solr instance is configured with autoCommit maxTime=30000 in solrconfig.xml, hard commits run automatically every 30 seconds. Documents indexed with soft commit become durable within that window without your MCP tool needing to explicitly issue a hard commit. Verify the autoCommit configuration with your Solr admin before choosing a commit strategy.
Search tool patterns — query parameters and result shaping
Solr's primary search handler is /select and accepts parameters in a URL query string or JSON body. The default query parser (defType=lucene) uses Lucene syntax; the Extended DisMax parser (defType=edismax) is recommended for user-facing search because it handles phrase queries, field boosts, and fuzzy matching more gracefully.
// MCP tool: search products using Extended DisMax
server.tool('search_products', async ({
query,
category,
brand,
minPrice,
maxPrice,
inStock,
start = 0,
rows = 20,
}) => {
// Build filter queries — Solr caches fq results separately from the main query
const fqParts: string[] = [];
if (category) fqParts.push(\`category:"\${category}"\`);
if (brand) fqParts.push(\`brand:"\${brand}"\`);
if (inStock != null) fqParts.push(\`inStock:\${inStock}\`);
if (minPrice != null || maxPrice != null) {
const lo = minPrice ?? '*';
const hi = maxPrice ?? '*';
fqParts.push(\`price:[\${lo} TO \${hi}]\`);
}
const params: Record<string, string | number | string[]> = {
q: query ? query : '*:*',
defType: 'edismax',
qf: 'title^4 description^1 brand^2 tags^1', // Query fields with boosts
pf: 'title^8', // Phrase boost — whole phrase match ranks higher
mm: '75%', // Minimum match: 75% of terms must be present
start,
rows: Math.min(rows, 100),
fl: 'id,title,brand,category,price,inStock,score', // Fields returned
hl: 'on',
'hl.fl': 'title,description',
'hl.snippets': '1',
'hl.fragsize': '150',
facet: 'on',
'facet.field': ['category', 'brand', 'inStock'],
'facet.mincount': '1',
'facet.limit': '20',
};
// Append filter queries — multiple fq params are AND'd together
if (fqParts.length) params.fq = fqParts;
const response = await solr.query(params);
return {
hits: response.response.docs,
highlighting: response.highlighting,
facets: response.facet_counts?.facet_fields,
totalHits: response.response.numFound,
start: response.response.start,
queryTime: response.responseHeader.QTime,
};
});
Solr filter queries (fq) are cached independently in Solr's filter cache. Always push exact-match conditions into fq parameters rather than the main q — this improves cache hit rates across tool calls that share the same filter conditions.
Cursor pagination — consistent deep reads for large result sets
Solr's offset pagination (start + rows) re-executes the full query and skips results, which is expensive for large offsets and produces inconsistent results when documents are added or deleted between pages. Cursor-based pagination is deterministic and efficient — each response returns a nextCursorMark token that you pass as cursorMark in the next request. It requires a sort clause that ends with a unique field (always add id asc as the final sort criterion).
// MCP tool: export all products matching a filter using cursor pagination
server.tool('export_products_cursor', async ({ category }) => {
const allDocs: any[] = [];
let cursorMark = '*'; // Initial cursor — always starts at '*'
const fq = category ? [\`category:"\${category}"\`] : [];
while (true) {
const response = await solr.query({
q: '*:*',
fq,
// sort MUST end with 'id asc' (or any unique field) for cursor to work
sort: 'price asc, id asc',
rows: 500,
cursorMark,
fl: 'id,title,brand,category,price,inStock',
});
const docs = response.response.docs;
allDocs.push(...docs);
const nextCursorMark = response.nextCursorMark;
// Stop when nextCursorMark is the same as the current one — end of results
if (nextCursorMark === cursorMark || docs.length === 0) break;
cursorMark = nextCursorMark;
}
return {
total: allDocs.length,
documents: allDocs,
};
});
Cursor marks are opaque tokens — do not try to parse or modify them. They encode the sort values of the last document returned. If you need to resume a cursor-based export across multiple MCP tool calls, you can store the nextCursorMark value externally and pass it as the cursorMark on the next invocation.
Health probe — Solr core health from an MCP server
Solr's /admin/ping endpoint checks that the core is loaded, the searcher is open, and a trivial query completes successfully. It returns HTTP 200 with "status":"OK" when healthy, and a 5xx error or non-OK body when the core is degraded.
async function checkSolrHealth(): Promise<{
healthy: boolean;
status?: string;
latencyMs?: number;
error?: string;
}> {
const start = Date.now();
try {
const url = \`http://\${process.env.SOLR_HOST ?? 'localhost'}:\${process.env.SOLR_PORT ?? 8983}/solr/\${process.env.SOLR_CORE ?? 'products'}/admin/ping?wt=json\`;
const authHeader = process.env.SOLR_USERNAME
? {
Authorization: \`Basic \${Buffer.from(
\`\${process.env.SOLR_USERNAME}:\${process.env.SOLR_PASSWORD}\`
).toString('base64')}\`,
}
: {};
const response = await fetch(url, {
headers: authHeader,
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) {
return { healthy: false, error: \`HTTP \${response.status}\` };
}
const body = await response.json();
return {
healthy: body.status === 'OK',
status: body.status,
latencyMs: Date.now() - start,
};
} catch (err: any) {
return {
healthy: false,
error: err?.message ?? String(err),
};
}
}
async function startMcpServer() {
const health = await checkSolrHealth();
if (!health.healthy) {
console.error('Solr core unhealthy at startup:', health.status, health.error);
process.exit(1);
}
console.log(\`Solr ready (\${health.status} / \${health.latencyMs}ms). Registering MCP tools.\`);
}
server.tool('search_health', async () => {
return checkSolrHealth();
});
AliveMCP monitors your MCP server's Solr-backed search tools by probing the admin ping endpoint every 60 seconds — catching Solr core load failures, out-of-memory conditions from large merge operations, and network partitions between the MCP server process and the Solr cluster before they cause agent tool failures.
Related guides
- MCP Tools for OpenSearch — bulk indexing, scroll API, AWS SigV4 auth
- MCP Tools for Elasticsearch — mapping, bool queries, index lifecycle
- MCP Tools for Algolia — API key scoping, async indexing, facet filtering
- MCP Tools for Typesense — collection schema, import batching, multi-search
- MCP Tools for Meilisearch — task lifecycle, filterable attributes, tenant tokens