openvibers/openvibe.search Commits

MCP: POST /mcp (Streamable HTTP, protocol 2025-06-18) with openvibe_search (the Search Fabric) and openvibe_search_providers; schemas from the contracts; CORS for browser clients; nginx location (#122)

OP OpenVibers committed 16 days ago · 1e60686b08 · 7 files, +771 −0

Changes

ddiff --git a/README.md b/README.md
iindex e3a77e9..51ec3e2 100644
---- a/README.md
++++ b/README.md
@@@ -216,6 +216,35 @@ network or mine scope when it was the only one asked. The answer never contains
Asking `"answer": true` answers `answer: null` and adds an `answer` explain row: answers are not on
yet.
++## MCP
++
++The same fabric is a [Model Context Protocol](https://modelcontextprotocol.io) server over
++Streamable HTTP at **`https://search.openvibe.network/mcp`** (JSON-RPC 2.0, protocol revision
++2025-06-18). It is stateless: one `POST`, one answer, no session id. `GET` and `DELETE` answer 405.
++
++| Tool | What it does |
++|---|---|
++| `openvibe_search` | one search over `network`, `mine`, `web`, `news`, `video`, `products`, `places` and `code`; results grouped by scope with `route.explain` and cost |
++| `openvibe_search_providers` | the web ladder's providers, with price per call and whether each is available |
++
++Add it to an MCP client:
++
++```json
++{
++ "mcpServers": {
++ "openvibe-search": {
++ "type": "http",
++ "url": "https://search.openvibe.network/mcp"
++ }
++ }
++}
++```
++
++Anonymous callers search the public network and the open web index. A Bearer token (a Network user
++token) adds the caller's own `mine` scope and lets the `web` scope reach DeepSeek; it is also the
++person the fabric's visibility rules apply to. Each `tools/call` shares the fabric's per-actor
++`search.fabric` limit (30 a minute, 600 an hour); `initialize` and `tools/list` are not limited.
++
## Indexing
Owners call with an OpenVibe.Network client-credentials token (`audience=openvibe.search`) holding
ddiff --git a/deploy/nginx/search.openvibe.network.conf b/deploy/nginx/search.openvibe.network.conf
iindex a331491..5e4409a 100644
---- a/deploy/nginx/search.openvibe.network.conf
++++ b/deploy/nginx/search.openvibe.network.conf
@@@ -14,6 +14,8 @@
## public documents only; the ov_token cookie or a Bearer adds
## the caller's ACL matches). Responses are Cache-Control: no-store.
## /api/v1/saved-searches[/...] a signed-in person's saved searches (Network user token)
++## /mcp the Search Fabric as a Model Context Protocol server over
++## Streamable HTTP (POST only, JSON-RPC 2.0, no sessions)
## /api/v1/documents (PUT/DELETE), /api/v1/owners/... owner API (service tokens): loopback only.
## Services on this host call 127.0.0.1:4710 directly.
## /internal/events OpenVibe.Events deliveries: loopback only.
@@@ -121,6 +123,19 @@ server {
proxy_cache_bypass 1;
}
++ # ── MCP over Streamable HTTP: one stateless POST /mcp, JSON-RPC 2.0, same fabric and per-actor
++ # limit as /api/v1/query. Browsers preflight it (OPTIONS), so that method is allowed through too;
++ # no session header is issued or required. Never buffered or cached: every call answers once.
++ location = /mcp {
++ limit_except POST OPTIONS { deny all; }
++ limit_req zone=ovsearch_query burst=20 nodelay;
++ client_max_body_size 64k;
++ proxy_pass http://127.0.0.1:4710;
++ proxy_buffering off;
++ proxy_no_cache 1;
++ proxy_cache_bypass 1;
++ }
++
# One document by id: GET is public (ACL-checked by Search); PUT/DELETE are owner writes,
# loopback only.
location ^~ /api/v1/documents/ {
ddiff --git a/server/api/mcp.js b/server/api/mcp.js
nnew file mode 100644
iindex 0000000..302343b
---- /dev/null
++++ b/server/api/mcp.js
@@@ -0,0 +1,394 @@
++'use strict';
++/**
++ * The Search Fabric as a Model Context Protocol server (revision 2025-06-18), one stateless
++ * Streamable HTTP endpoint:
++ *
++ * POST /mcp one JSON-RPC 2.0 request, or a batch array, over the same viewer and the same
++ * search.fabric per-actor limit as POST /api/v1/query
++ * GET /mcp 405 (Allow: POST)
++ * DELETE /mcp 405 (Allow: POST)
++ * OPTIONS /mcp the CORS preflight a browser MCP client sends
++ *
++ * The two tools are read-only: openvibe_search runs the fabric (network, mine, web, news, video,
++ * products, places, code) and openvibe_search_providers lists the web ladder's offers. Their input
++ * and output schemas come from the published contracts at boot, so the enums cannot drift from the
++ * fabric's request. There are no sessions and no Mcp-Session-Id: every call answers once as JSON.
++ * A query is never echoed into any answer, a log line or an error.
++ */
++const fs = require('fs');
++const express = require('express');
++const pkg = require('../../package.json');
++const { withViewer, QueryError } = require('./query');
++const { SEARCH_LIMITS } = require('./fabric');
++
++const PROTOCOL_VERSION = '2025-06-18';
++const SUPPORTED_VERSIONS = Object.freeze(new Set(['2025-06-18', '2025-03-26']));
++const BODY_LIMIT = '64kb';
++const CONTRACT_DIR = 'openvibe-contracts/contracts/search';
++
++/** The request/result contracts as JSON Schema objects, read from the package at startup. */
++function loadContracts() {
++ const request = JSON.parse(fs.readFileSync(require.resolve(`${CONTRACT_DIR}/fabric-request.v1.json`), 'utf8'));
++ const result = JSON.parse(fs.readFileSync(require.resolve(`${CONTRACT_DIR}/fabric-result.v1.json`), 'utf8'));
++ // MCP's outputSchema is the result object itself; $schema/$id would make it a document, not a shape.
++ delete result.$schema;
++ delete result.$id;
++ return { request, result };
++}
++
++const CONTRACTS = loadContracts();
++const REQUEST_SCHEMA = CONTRACTS.request;
++const RESULT_SCHEMA = CONTRACTS.result;
++
++const INSTRUCTIONS = 'Search OpenVibe\'s permission-aware index and the open web: network covers OpenVibe\'s own sites, mine the caller\'s own documents (needs a signed-in person), web the open index and then DeepSeek for signed-in callers and Wikipedia, and news, video, products, places and code the OpenVibe documents of that kind. Results are grouped by scope and route.explain says what each step did and what it cost; web results carry the provider that found them and the date they were read. A query is never stored with a person\'s name: a Bearer token only decides what that person may see.';
++
++/** A JSON Schema object for the MCP arguments; the enums are the fabric request contract's own. */
++function inputSchema() {
++ const properties = REQUEST_SCHEMA.properties || {};
++ const scopeEnum = properties.scopes && properties.scopes.items && properties.scopes.items.enum;
++ const freshnessEnum = properties.freshness && properties.freshness.enum;
++ const depthEnum = properties.depth && properties.depth.enum;
++ const q = properties.q || {};
++ const site = (properties.filters && properties.filters.properties && properties.filters.properties.site) || {};
++ return {
++ type: 'object',
++ required: ['q'],
++ properties: {
++ q: {
++ type: 'string',
++ minLength: Number.isInteger(q.minLength) ? q.minLength : 1,
++ maxLength: Number.isInteger(q.maxLength) ? q.maxLength : 200,
++ description: 'What to find (1-200 characters).',
++ },
++ scopes: {
++ type: 'array',
++ minItems: 1,
++ maxItems: 10,
++ uniqueItems: true,
++ items: { enum: [...(scopeEnum || [])] },
++ default: ['network'],
++ description: 'Where to look; results are grouped by scope in this order. Default: network.',
++ },
++ freshness: {
++ enum: [...(freshnessEnum || [])],
++ default: 'any',
++ description: 'How recent results must be, where the source dates them.',
++ },
++ depth: {
++ enum: [...(depthEnum || [])],
++ default: 'standard',
++ description: 'quick: indexes and caches only; standard: the ladder until a step answers; deep: every step, merged.',
++ },
++ limit: {
++ type: 'integer',
++ minimum: 1,
++ maximum: 20,
++ default: 8,
++ description: 'At most this many results per scope.',
++ },
++ site: {
++ type: 'string',
++ maxLength: Number.isInteger(site.maxLength) ? site.maxLength : 253,
++ ...(typeof site.pattern === 'string' ? { pattern: site.pattern } : {}),
++ description: 'web: results from this host only.',
++ },
++ },
++ };
++}
++
++const SEARCH_TOOL = {
++ name: 'openvibe_search',
++ title: 'Search OpenVibe and the web',
++ description: 'Search OpenVibe\'s permission-aware index and the web. Scopes: network (OpenVibe\'s sites), mine (the caller\'s own documents; needs a signed-in person), web (OpenVibe\'s open index, then DeepSeek for signed-in callers, then Wikipedia), and news, video, products, places and code (OpenVibe\'s documents of that kind). Results come grouped by scope with route.explain and cost.',
++ inputSchema: inputSchema(),
++ outputSchema: RESULT_SCHEMA,
++ annotations: { readOnlyHint: true, openWorldHint: true },
++};
++
++const PROVIDERS_TOOL = {
++ name: 'openvibe_search_providers',
++ title: 'OpenVibe web search providers',
++ description: 'The web search providers behind openvibe_search\'s web scope, with their price per call and whether they are available right now.',
++ inputSchema: { type: 'object', properties: {} },
++ annotations: { readOnlyHint: true },
++};
++
++const TOOLS = Object.freeze([SEARCH_TOOL, PROVIDERS_TOOL]);
++
++function money(usd) {
++ return `$${Number(usd || 0).toFixed(5)}`;
++}
++
++function stepText(step) {
++ const bits = [`${step.scope}:${step.step}`, step.ok ? 'ok' : 'failed'];
++ if (Number.isInteger(step.count)) bits.push(String(step.count));
++ if (step.cached) bits.push('cached');
++ if (Number.isFinite(step.cost_usd) && step.cost_usd > 0) bits.push(money(step.cost_usd));
++ if (step.skipped) bits.push(`skipped (${step.skipped})`);
++ return bits.join(' ');
++}
++
++/** The readable half of a tools/call result: a heading per scope, numbered results, then the route. */
++function searchText(answer, limit) {
++ const lines = [];
++ const results = Array.isArray(answer.results) ? answer.results : [];
++ for (const scope of Object.keys(answer.groups || {})) {
++ lines.push(`## ${scope}`);
++ const inScope = results.filter((r) => r.scope === scope).slice(0, limit);
++ if (!inScope.length) lines.push('No results.');
++ inScope.forEach((r, i) => {
++ lines.push(`${i + 1}. ${r.title} — ${r.url}`);
++ if (r.snippet) lines.push(` ${r.snippet}`);
++ });
++ }
++ const route = answer.route || {};
++ const steps = Array.isArray(route.explain) ? route.explain.map(stepText) : [];
++ lines.push(`Route: ${steps.length ? steps.join('; ') : 'no steps'}; cost ${money(route.cost_usd)}`);
++ return lines.join('\n');
++}
++
++/** One line per provider: name, scopes, price, availability and the day's usage. */
++function providersText(providers) {
++ return (providers || []).map((p) => {
++ const price = Number(p.price_usd_per_call) === 0 ? 'free' : `${money(p.price_usd_per_call)} per call`;
++ const availability = p.available ? 'available' : `unavailable${p.reason ? `: ${p.reason}` : ''}`;
++ const bits = [
++ `scopes ${Array.isArray(p.scopes) ? p.scopes.join(', ') : 'unknown'}`,
++ price,
++ availability,
++ ];
++ if (p.daily_cap != null) bits.push(`${Number(p.used_today) || 0}/${p.daily_cap} calls today`);
++ if (p.answered_rate != null) bits.push(`${Math.round(Number(p.answered_rate) * 100)}% answered`);
++ if (p.latency_ms_p50 != null) bits.push(`p50 ${p.latency_ms_p50} ms`);
++ if (p.terms_url) bits.push(p.terms_url);
++ return `${p.provider}: ${bits.join(' · ')}`;
++ }).join('\n');
++}
++
++/** The fabric request for the tool's arguments; the MCP default (8) is not the fabric default (20). */
++function fabricBody(args) {
++ // The tool's schema promises 1-20 where the fabric contract allows 1-50; refuse the gap here
++ // rather than return more results than the caller's schema said it could ask for.
++ if (args.limit !== undefined && (!Number.isInteger(args.limit) || args.limit < 1 || args.limit > 20)) {
++ throw new QueryError('search.bad_query', 'limit must be an integer between 1 and 20');
++ }
++ const body = { q: args.q, limit: args.limit === undefined ? 8 : args.limit };
++ if (args.scopes !== undefined) body.scopes = args.scopes;
++ if (args.freshness !== undefined) body.freshness = args.freshness;
++ if (args.depth !== undefined) body.depth = args.depth;
++ if (args.site !== undefined) body.filters = { site: args.site };
++ return body;
++}
++
++function result(id, value) {
++ return { jsonrpc: '2.0', id, result: value };
++}
++
++function error(id, code, message) {
++ return { jsonrpc: '2.0', id: id === undefined ? null : id, error: { code, message } };
++}
++
++function validId(id) {
++ return id === null || typeof id === 'string' || (typeof id === 'number' && Number.isFinite(id));
++}
++
++/**
++ * Run the fabric's per-actor limiter for one tools/call without letting it write to the real
++ * response: the capture is what lets a batch count every call and still answer as one JSON-RPC
++ * array. A refusal is copied to the HTTP response exactly as the fabric route sends it (429,
++ * Retry-After, problem+json).
++ */
++function takeBudget(limit, req) {
++ return new Promise((resolve, reject) => {
++ let settled = false;
++ const finish = (value) => { if (!settled) { settled = true; resolve(value); } };
++ const capture = {
++ statusCode: 200,
++ headers: {},
++ body: '',
++ setHeader(name, value) { this.headers[String(name).toLowerCase()] = value; },
++ getHeader(name) { return this.headers[String(name).toLowerCase()]; },
++ end(body) {
++ this.body = body === undefined ? '' : String(body);
++ finish({ limited: true, status: this.statusCode, headers: this.headers, body: this.body });
++ },
++ };
++ try {
++ limit(req, capture, (err) => {
++ if (err) { settled = true; reject(err); return; }
++ finish({ limited: false });
++ });
++ } catch (err) {
++ settled = true;
++ reject(err);
++ }
++ });
++}
++
++function sendLimited(res, gate) {
++ res.statusCode = gate.status || 429;
++ for (const [name, value] of Object.entries(gate.headers)) res.setHeader(name, value);
++ res.end(gate.body);
++}
++
++function sendRpc(res, status, body) {
++ return res.status(status).json(body);
++}
++
++async function callTool(id, params, req, viewer, { fabric, searchLimit }) {
++ const name = params.name;
++ if (name !== SEARCH_TOOL.name && name !== PROVIDERS_TOOL.name) {
++ return { response: error(id, -32602, 'Unknown tool') };
++ }
++
++ const gate = await takeBudget(searchLimit, req);
++ if (gate.limited) return { limited: gate };
++
++ if (name === PROVIDERS_TOOL.name) {
++ const providers = await fabric.providers();
++ return {
++ response: result(id, {
++ content: [{ type: 'text', text: providersText(providers) }],
++ structuredContent: { providers },
++ isError: false,
++ }),
++ };
++ }
++
++ const args = params.arguments && typeof params.arguments === 'object' && !Array.isArray(params.arguments)
++ ? params.arguments : {};
++ let answer;
++ try {
++ answer = await fabric.query(fabricBody(args), viewer);
++ } catch (err) {
++ // Per MCP a tool error is a result, not a JSON-RPC error: the caller can read it and retry.
++ if (err instanceof QueryError) {
++ return { response: result(id, { content: [{ type: 'text', text: err.message }], isError: true }) };
++ }
++ throw err;
++ }
++ const limit = Number.isInteger(args.limit) ? args.limit : 8;
++ return {
++ response: result(id, {
++ content: [{ type: 'text', text: searchText(answer, limit) }],
++ structuredContent: answer,
++ isError: false,
++ }),
++ };
++}
++
++/** One JSON-RPC message: a response, a notification (no response), or a rate-limit stop. */
++async function handleMessage(msg, req, viewer, deps) {
++ if (!msg || typeof msg !== 'object' || Array.isArray(msg)) {
++ return { response: error(null, -32600, 'Invalid Request') };
++ }
++ if (msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
++ return { response: error(null, -32600, 'Invalid Request') };
++ }
++ const hasId = Object.prototype.hasOwnProperty.call(msg, 'id');
++ if (hasId && !validId(msg.id)) return { response: error(null, -32600, 'Invalid Request') };
++ // MCP notifications carry no id; notifications/initialized is one even if a client labels it.
++ if (!hasId || msg.method.startsWith('notifications/')) return { notification: true };
++
++ const id = msg.id;
++ const params = msg.params && typeof msg.params === 'object' && !Array.isArray(msg.params) ? msg.params : {};
++ switch (msg.method) {
++ case 'initialize': {
++ const requested = params.protocolVersion;
++ const protocolVersion = SUPPORTED_VERSIONS.has(requested) ? requested : PROTOCOL_VERSION;
++ return {
++ response: result(id, {
++ protocolVersion,
++ capabilities: { tools: { listChanged: false } },
++ serverInfo: { name: 'openvibe-search', title: 'OpenVibe Search', version: pkg.version },
++ instructions: INSTRUCTIONS,
++ }),
++ };
++ }
++ case 'ping':
++ return { response: result(id, {}) };
++ case 'tools/list':
++ return { response: result(id, { tools: TOOLS }) };
++ case 'tools/call':
++ return await callTool(id, params, req, viewer, deps);
++ default:
++ return { response: error(id, -32601, 'Method not found') };
++ }
++}
++
++function mcpRouter({ fabric, auth, limits }) {
++ const router = express.Router();
++ const searchLimit = limits('search.fabric', SEARCH_LIMITS);
++ // strict: false parses a scalar as JSON too, so "valid JSON, not a JSON-RPC message" is -32600
++ // rather than blurring into -32700; dispatch below rejects every non-object.
++ const json = express.json({ limit: BODY_LIMIT, type: ['application/json', 'application/*+json'], strict: false });
++
++ // Browser-based MCP clients exist; the endpoint is anonymous or Bearer-token only, never cookies
++ // as a cross-site credential (the browser will not send them to `*`).
++ router.use('/mcp', (_req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); next(); });
++
++ router.options('/mcp', (_req, res) => {
++ res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
++ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Protocol-Version');
++ res.setHeader('Access-Control-Max-Age', '600');
++ res.status(204).end();
++ });
++
++ router.post('/mcp', json, withViewer(auth, async (req, res, viewer) => {
++ const version = req.get('mcp-protocol-version');
++ if (version && !SUPPORTED_VERSIONS.has(version)) {
++ const body = req.body;
++ const id = body && !Array.isArray(body) && (typeof body.id === 'string' || typeof body.id === 'number' || body.id === null)
++ ? body.id : null;
++ return sendRpc(res, 400, error(id, -32600, 'Unsupported protocol version'));
++ }
++
++ const offered = req.body;
++ if (!offered || typeof offered !== 'object') {
++ return sendRpc(res, 200, error(null, -32600, 'Invalid Request'));
++ }
++ const batch = Array.isArray(offered);
++ if (batch && offered.length === 0) {
++ return sendRpc(res, 200, error(null, -32600, 'Invalid Request'));
++ }
++ const messages = batch ? offered : [offered];
++ const responses = [];
++ for (const message of messages) {
++ const out = await handleMessage(message, req, viewer, { fabric, searchLimit });
++ if (out.limited) return sendLimited(res, out.limited);
++ if (out.response) responses.push(out.response);
++ }
++
++ // A notification (or a batch of only notifications) is accepted with no body.
++ if (!responses.length) return res.status(202).end();
++ return sendRpc(res, 200, batch ? responses : responses[0]);
++ }));
++
++ // Everything else is not part of the stateless Streamable HTTP transport this endpoint offers.
++ router.all('/mcp', (_req, res) => {
++ res.setHeader('Allow', 'POST');
++ res.status(405).json(error(null, -32600, 'Method Not Allowed'));
++ });
++
++ // JSON parsing is ours, so a malformed body is a JSON-RPC parse error rather than the API's
++ // problem+json envelope; a body over 64 KB is refused before any tool runs.
++ // eslint-disable-next-line no-unused-vars
++ router.use((err, req, res, next) => {
++ if (err && err.type === 'entity.parse.failed') return sendRpc(res, 400, error(null, -32700, 'Parse error'));
++ if (err && err.type === 'entity.too.large') return sendRpc(res, 413, error(null, -32600, 'Request body too large'));
++ return next(err);
++ });
++
++ return router;
++}
++
++module.exports = {
++ mcpRouter,
++ TOOLS,
++ INSTRUCTIONS,
++ PROTOCOL_VERSION,
++ SUPPORTED_VERSIONS,
++ searchText,
++ providersText,
++ fabricBody,
++};
ddiff --git a/server/app.js b/server/app.js
iindex 5b92cfb..176e647 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -9,6 +9,7 @@ const { createSearchReadiness, registerSearchGauges } = require('./observability
const { documentsRouter } = require('./api/documents');
const { queryRouter, createSearcher } = require('./api/query');
const { fabricRouter } = require('./api/fabric');
++const { mcpRouter } = require('./api/mcp');
const { createQueryCache } = require('./fabric/cache');
const { createWebProviders } = require('./fabric/web');
const { createFabric } = require('./fabric');
@@@ -85,6 +86,8 @@ function createApp({ config, db, store, engine, auth, outbox, purges, purger, sa
app.use(documentsRouter({ store, auth, db, outbox, purges, limits }));
app.use(queryRouter({ config, store, engine, auth, searcher, limits }));
app.use(fabricRouter({ fabric, auth, limits }));
++ // MCP over Streamable HTTP: the same fabric, viewer and search.fabric limit as the query route above.
++ app.use(mcpRouter({ fabric, auth, limits }));
app.use(savedRouter({ config, saved, searcher, auth, limits }));
// GET / (HTML search page for browsers, the text route index otherwise), /robots.txt, /llms.txt and
// /sitemap.xml (whose lastmods come from the index and the deployed release, never from the clock).
ddiff --git a/server/web/page.js b/server/web/page.js
iindex 28b3ae9..f67fa42 100644
---- a/server/web/page.js
++++ b/server/web/page.js
@@@ -133,6 +133,7 @@ function llmsTxt(origin) {
] },
{ title: 'Machine-readable', links: [
{ title: 'Search API', url: `${origin}/api/v1/search?q=`, note: 'JSON results; anonymous = public documents only' },
++ { title: 'MCP endpoint', url: `${origin}/mcp`, note: 'Streamable HTTP MCP (JSON-RPC 2.0): agents call openvibe_search and openvibe_search_providers' },
{ title: 'Suggest API', url: `${origin}/api/v1/suggest?q=`, note: 'title suggestions (JSON)' },
{ title: 'Sitemap', url: `${origin}/sitemap.xml` },
{ title: 'robots.txt', url: `${origin}/robots.txt` },
ddiff --git a/test/mcp.test.js b/test/mcp.test.js
nnew file mode 100644
iindex 0000000..47734d0
---- /dev/null
++++ b/test/mcp.test.js
@@@ -0,0 +1,318 @@
++'use strict';
++/**
++ * MCP over Streamable HTTP (server/api/mcp.js, POST /mcp): initialization and version negotiation,
++ * notifications, ping, the two tools and their contract-fed schemas, an anonymous search (DeepSeek
++ * skipped, Wikipedia answers), a signed-in search that reaches DeepSeek, tool errors as results,
++ * JSON-RPC errors, batching, 405s, CORS and the protocol-version header. The AI stub is the fabric
++ * test's, so the same ladder is exercised end to end.
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { ids } = require('openvibe-contracts');
++const { boot, request, userToken, doc, suite } = require('./helpers');
++
++const t = suite('mcp');
++const pkg = require('../package.json');
++const requestContract = require('openvibe-contracts/contracts/search/fabric-request.v1.json');
++const alice = ids.newId('user');
++const asAlice = { token: userToken({ subjectId: alice, aud: ['openvibe.search'] }) };
++const AI_ENV = { OV_AI_INTERNAL_URL: 'http://ai.test', OV_OAUTH_CLIENT_SECRET: 'test-ai-secret', SEARCH_WEB_DAILY: '500' };
++const RETRIEVED = '2026-10-28T12:00:00.000Z';
++
++let svc;
++const call = (method, p, opts = {}) => request(svc.base, method, p, opts);
++const post = (body, opts = {}) => call('POST', '/mcp', { body, ...opts });
++const rpc = (method, params, id = 1) => ({ jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) });
++
++let aiCalls = [];
++
++function response(status, json) {
++ return { ok: status >= 200 && status < 300, status, json: async () => json, text: async () => JSON.stringify(json) };
++}
++
++function runWith(sources, providers = {}, retrievedAt = RETRIEVED) {
++ return { run: { status: 'succeeded', output: { sources, providers, usage: {}, retrieved_at: retrievedAt } } };
++}
++
++function wikiSource(overrides = {}) {
++ return {
++ source_type: 'wikipedia', url: 'https://en.wikipedia.org/wiki/Test', title: 'Test — Wikipedia',
++ snippet: '<p>An <b>article</b> &amp; its summary.</p>', retrieved_at: RETRIEVED,
++ provenance: { provider: 'wikipedia' }, ...overrides,
++ };
++}
++
++function webSource(overrides = {}) {
++ return {
++ source_type: 'web', url: 'https://deep.example/quasar', title: 'Quasar result',
++ snippet: 'A web snippet', retrieved_at: RETRIEVED, published_at: '2026-10-27T00:00:00Z',
++ provenance: { provider: 'deepseek' }, ...overrides,
++ };
++}
++
++function defaultResearch(body) {
++ if (body.providers.includes('deepseek')) {
++ return runWith([webSource()], { deepseek: { ok: true, count: 1, ms: 10 } });
++ }
++ return runWith([wikiSource()], { wikipedia: { ok: true, count: 1, ms: 10 } });
++}
++
++async function fetchImpl(url, opts = {}) {
++ const u = String(url);
++ if (u.includes('/oauth/token')) return response(200, { access_token: 'test-ai-token', token_type: 'Bearer', expires_in: 300 });
++ if (u.includes('/api/v1/research')) {
++ const body = opts.body ? JSON.parse(opts.body) : {};
++ aiCalls.push(body);
++ return response(200, defaultResearch(body));
++ }
++ throw new Error(`unexpected fetch ${u}`);
++}
++
++t('boot with the web ladder configured and the fixtures indexed', async () => {
++ svc = await boot({ env: AI_ENV, fetchImpl });
++ await svc.store.apply(doc({
++ id: 'mcp-public', title: 'MCP public solstice', body: 'solstice mcp public body',
++ canonical_url: 'https://openvibe.wiki/p/mcp-public',
++ }));
++ await svc.store.apply(doc({
++ owner: 'sources', type: 'item', id: 'src_mcp', title: 'MCP source pulsar', body: 'pulsar mcp source body',
++ canonical_url: 'https://sources.example/pulsar',
++ }));
++ await svc.store.apply(doc({
++ id: 'mcp-private', visibility: 'private', acl: { subjects: [alice] },
++ title: 'MCP private solstice', body: 'solstice private body',
++ canonical_url: 'https://openvibe.wiki/p/mcp-private',
++ }));
++});
++
++t('initialize negotiates the protocol version, names the server and sets tools capabilities', async () => {
++ const old = await post(rpc('initialize', { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'test', version: '1' } }, 1));
++ assert.strictEqual(old.status, 200, old.text);
++ assert.strictEqual(old.body.jsonrpc, '2.0');
++ assert.strictEqual(old.body.id, 1);
++ assert.strictEqual(old.body.result.protocolVersion, '2025-03-26');
++ assert.deepStrictEqual(old.body.result.capabilities, { tools: { listChanged: false } });
++ assert.deepStrictEqual(old.body.result.serverInfo, { name: 'openvibe-search', title: 'OpenVibe Search', version: pkg.version });
++ assert.strictEqual(typeof old.body.result.instructions, 'string');
++ assert.match(old.body.result.instructions, /provider/i);
++ assert.match(old.body.result.instructions, /date/i);
++ assert.match(old.body.result.instructions, /never stored/i);
++
++ const latest = await post(rpc('initialize', { protocolVersion: '2025-06-18' }, 2));
++ assert.strictEqual(latest.body.result.protocolVersion, '2025-06-18');
++ const unknown = await post(rpc('initialize', { protocolVersion: '2024-11-05' }, 3));
++ assert.strictEqual(unknown.body.result.protocolVersion, '2025-06-18', 'an unknown version gets the latest supported');
++ const missing = await post(rpc('initialize', {}, 4));
++ assert.strictEqual(missing.body.result.protocolVersion, '2025-06-18');
++});
++
++t('notifications/initialized and any notification answer 202 with an empty body', async () => {
++ const one = await post({ jsonrpc: '2.0', method: 'notifications/initialized' });
++ assert.strictEqual(one.status, 202, one.text);
++ assert.strictEqual(one.text, '');
++ const other = await post({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 9 } });
++ assert.strictEqual(other.status, 202);
++ assert.strictEqual(other.text, '');
++ const batch = await post([
++ { jsonrpc: '2.0', method: 'notifications/initialized' },
++ { jsonrpc: '2.0', method: 'notifications/cancelled', params: {} },
++ ]);
++ assert.strictEqual(batch.status, 202, batch.text);
++ assert.strictEqual(batch.text, '');
++});
++
++t('ping answers {}', async () => {
++ const r = await post(rpc('ping', undefined, 'ping-1'));
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(r.body.result, {});
++ assert.strictEqual(r.body.id, 'ping-1');
++});
++
++t('tools/list has both read-only tools; input enums are the contract\'s and outputSchema is stripped', async () => {
++ const r = await post(rpc('tools/list', {}, 2));
++ assert.strictEqual(r.status, 200, r.text);
++ const tools = r.body.result.tools;
++ assert.strictEqual(tools.length, 2);
++ assert.deepStrictEqual(tools.map((x) => x.name), ['openvibe_search', 'openvibe_search_providers']);
++
++ const search = tools[0];
++ assert.strictEqual(search.title, 'Search OpenVibe and the web');
++ for (const word of ['network', 'mine', 'web', 'DeepSeek', 'Wikipedia', 'news', 'video', 'products', 'places', 'code', 'route.explain', 'cost']) {
++ assert.ok(search.description.includes(word), `description mentions ${word}`);
++ }
++ assert.deepStrictEqual(search.annotations, { readOnlyHint: true, openWorldHint: true });
++ assert.deepStrictEqual(search.inputSchema.required, ['q']);
++ assert.strictEqual(search.inputSchema.properties.q.type, 'string');
++ assert.strictEqual(search.inputSchema.properties.q.minLength, 1);
++ assert.strictEqual(search.inputSchema.properties.q.maxLength, 200);
++ assert.deepStrictEqual(search.inputSchema.properties.scopes.items.enum, requestContract.properties.scopes.items.enum, 'scopes enum equals fabric-request@1');
++ assert.deepStrictEqual(search.inputSchema.properties.freshness.enum, requestContract.properties.freshness.enum, 'freshness enum equals fabric-request@1');
++ assert.deepStrictEqual(search.inputSchema.properties.depth.enum, requestContract.properties.depth.enum, 'depth enum equals fabric-request@1');
++ assert.strictEqual(search.inputSchema.properties.limit.type, 'integer');
++ assert.deepStrictEqual([search.inputSchema.properties.limit.minimum, search.inputSchema.properties.limit.maximum, search.inputSchema.properties.limit.default], [1, 20, 8]);
++ assert.strictEqual(search.inputSchema.properties.site.type, 'string');
++ assert.ok(search.outputSchema, 'the fabric result schema is the outputSchema');
++ assert.strictEqual(search.outputSchema.$schema, undefined);
++ assert.strictEqual(search.outputSchema.$id, undefined);
++ assert.strictEqual(search.outputSchema.type, 'object');
++ assert.ok(search.outputSchema.properties.results);
++
++ const providers = tools[1];
++ assert.deepStrictEqual(providers.inputSchema, { type: 'object', properties: {} });
++ assert.deepStrictEqual(providers.annotations, { readOnlyHint: true });
++});
++
++t('anonymous openvibe_search: network + web answer, DeepSeek is skipped, Wikipedia runs and the result validates', async () => {
++ aiCalls = [];
++ const r = await post(rpc('tools/call', {
++ name: 'openvibe_search',
++ arguments: { q: 'solstice', scopes: ['network', 'web'], limit: 5 },
++ }, 10));
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.result.isError, false);
++ const answer = r.body.result.structuredContent;
++ const valid = contracts.validate('search.fabric-result@1', answer);
++ assert.ok(valid.valid, JSON.stringify(valid.errors));
++ assert.deepStrictEqual(answer.results.filter((x) => x.scope === 'network').map((x) => x.id), ['mcp-public']);
++ assert.strictEqual(answer.route.explain.find((e) => e.step === 'deepseek').skipped, 'paid providers need a signed-in person');
++ assert.strictEqual(answer.route.explain.find((e) => e.step === 'wikipedia').ok, true);
++ assert.deepStrictEqual(aiCalls.map((b) => b.providers), [['wikipedia']], 'only the free provider is called for an anonymous caller');
++
++ const text = r.body.result.content[0].text;
++ assert.match(text, /## network/);
++ assert.match(text, /## web/);
++ assert.match(text, /1\. MCP public solstice — https:\/\/openvibe\.wiki\/p\/mcp-public/);
++ assert.ok(text.includes('MCP source pulsar') || text.includes('Test — Wikipedia'), 'web titles are in the text');
++ assert.match(text, /Route: /);
++ assert.match(text, /cost \$0\.00000/);
++});
++
++t('a signed-in openvibe_search reaches DeepSeek and reports its cost', async () => {
++ aiCalls = [];
++ const r = await post(rpc('tools/call', { name: 'openvibe_search', arguments: { q: 'quasar', scopes: ['web'] } }, 11), asAlice);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.result.isError, false);
++ assert.deepStrictEqual(aiCalls[0].providers, ['deepseek'], 'the signed-in caller climbs to the paid provider');
++ const answer = r.body.result.structuredContent;
++ assert.strictEqual(answer.route.explain.find((e) => e.step === 'deepseek').ok, true);
++ assert.strictEqual(answer.route.cost_usd, 0.00075);
++ assert.ok(r.body.result.content[0].text.includes('Quasar result'));
++});
++
++t('openvibe_search_providers lists one line per provider with price and availability', async () => {
++ const r = await post(rpc('tools/call', { name: 'openvibe_search_providers', arguments: {} }, 12));
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.result.isError, false);
++ const providers = r.body.result.structuredContent.providers;
++ assert.deepStrictEqual(providers.map((p) => p.provider), ['open_index', 'deepseek', 'wikipedia']);
++ const lines = r.body.result.content[0].text.split('\n');
++ assert.strictEqual(lines.length, 3);
++ for (const line of lines) assert.match(line, /available/);
++ assert.match(lines[0], /^open_index: .*free/);
++ assert.match(lines[1], /^deepseek: .*per call/);
++});
++
++t('a bad argument is a tool result with isError true, not a JSON-RPC error', async () => {
++ const r = await post(rpc('tools/call', { name: 'openvibe_search', arguments: { q: 'x', scopes: ['everything'] } }, 13));
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.error, undefined);
++ assert.strictEqual(r.body.result.isError, true);
++ assert.match(r.body.result.content[0].text, /scopes/);
++ const over = await post(rpc('tools/call', { name: 'openvibe_search', arguments: { q: 'x', limit: 21 } }, 131));
++ assert.strictEqual(over.body.result.isError, true, 'the tool schema caps limit at 20');
++ assert.match(over.body.result.content[0].text, /limit/);
++});
++
++t('an unknown tool is -32602 and an unknown method is -32601', async () => {
++ const tool = await post(rpc('tools/call', { name: 'no_such_tool', arguments: {} }, 14));
++ assert.strictEqual(tool.status, 200, tool.text);
++ assert.deepStrictEqual(tool.body.error, { code: -32602, message: 'Unknown tool' });
++ const method = await post(rpc('no/such/method', {}, 15));
++ assert.strictEqual(method.status, 200, method.text);
++ assert.strictEqual(method.body.error.code, -32601);
++});
++
++t('malformed JSON is -32700 with HTTP 400', async () => {
++ const r = await call('POST', '/mcp', { raw: '{"jsonrpc":"2.0",', headers: { 'Content-Type': 'application/json' } });
++ assert.strictEqual(r.status, 400, r.text);
++ assert.strictEqual(r.body.jsonrpc, '2.0');
++ assert.strictEqual(r.body.id, null);
++ assert.deepStrictEqual(r.body.error, { code: -32700, message: 'Parse error' });
++});
++
++t('valid JSON that is not a JSON-RPC request is -32600', async () => {
++ const scalar = await call('POST', '/mcp', { raw: '"hello"', headers: { 'Content-Type': 'application/json' } });
++ assert.strictEqual(scalar.status, 200, scalar.text);
++ assert.deepStrictEqual(scalar.body.error, { code: -32600, message: 'Invalid Request' });
++ const noMethod = await post({ jsonrpc: '2.0', id: 1 });
++ assert.strictEqual(noMethod.status, 200, noMethod.text);
++ assert.strictEqual(noMethod.body.error.code, -32600);
++});
++
++t('a batch of two requests answers an array of two responses', async () => {
++ const r = await post([
++ rpc('ping', undefined, 21),
++ rpc('tools/list', {}, 22),
++ ]);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(Array.isArray(r.body), 'a batch answers an array');
++ assert.strictEqual(r.body.length, 2);
++ assert.deepStrictEqual(r.body.map((x) => x.id), [21, 22]);
++ assert.deepStrictEqual(r.body[0].result, {});
++ assert.strictEqual(r.body[1].result.tools.length, 2);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
++});
++
++t('GET and DELETE answer 405 with Allow: POST; OPTIONS preflights CORS', async () => {
++ for (const method of ['GET', 'DELETE']) {
++ const r = await call(method, '/mcp');
++ assert.strictEqual(r.status, 405, `${method}: ${r.text}`);
++ assert.strictEqual(r.headers.get('allow'), 'POST', `${method}: Allow`);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*', `${method}: CORS`);
++ }
++ const options = await call('OPTIONS', '/mcp');
++ assert.strictEqual(options.status, 204, options.text);
++ assert.strictEqual(options.headers.get('access-control-allow-origin'), '*');
++ assert.match(options.headers.get('access-control-allow-methods'), /POST/);
++ assert.match(options.headers.get('access-control-allow-methods'), /OPTIONS/);
++ const allowed = options.headers.get('access-control-allow-headers');
++ for (const header of ['Content-Type', 'Authorization', 'Mcp-Protocol-Version']) assert.match(allowed, new RegExp(header, 'i'));
++ assert.strictEqual(options.headers.get('access-control-max-age'), '600');
++});
++
++t('an unsupported MCP-Protocol-Version is 400 -32600; a supported one is ignored', async () => {
++ const bad = await call('POST', '/mcp', { body: rpc('ping', undefined, 30), headers: { 'MCP-Protocol-Version': '2024-11-05' } });
++ assert.strictEqual(bad.status, 400, bad.text);
++ assert.deepStrictEqual(bad.body.error, { code: -32600, message: 'Unsupported protocol version' });
++ const good = await call('POST', '/mcp', { body: rpc('ping', undefined, 31), headers: { 'MCP-Protocol-Version': '2025-03-26' } });
++ assert.strictEqual(good.status, 200, good.text);
++ assert.deepStrictEqual(good.body.result, {});
++});
++
++t('initialize and tools/list do not count against search.fabric; each tools/call does', async () => {
++ // The fabric's own budget is fixed at 30 a minute (SEARCH_LIMITS_* only sets other routes).
++ const limited = await boot({ env: AI_ENV, fetchImpl });
++ try {
++ for (let i = 0; i < 5; i++) {
++ const init = await request(limited.base, 'POST', '/mcp', { body: rpc('initialize', { protocolVersion: '2025-06-18' }, i + 1) });
++ assert.strictEqual(init.status, 200, init.text);
++ const list = await request(limited.base, 'POST', '/mcp', { body: rpc('tools/list', {}, 100 + i) });
++ assert.strictEqual(list.status, 200, list.text);
++ }
++ for (let i = 0; i < 30; i++) {
++ const ok = await request(limited.base, 'POST', '/mcp', { body: rpc('tools/call', { name: 'openvibe_search_providers', arguments: {} }, i + 1) });
++ assert.strictEqual(ok.status, 200, `call ${i + 1}: ${ok.text}`);
++ }
++ const refused = await request(limited.base, 'POST', '/mcp', { body: rpc('tools/call', { name: 'openvibe_search_providers', arguments: {} }, 31) });
++ assert.strictEqual(refused.status, 429, refused.text);
++ assert.match(refused.headers.get('content-type'), /application\/problem\+json/);
++ assert.strictEqual(refused.body.code, 'rate_limited');
++ assert.ok(Number(refused.headers.get('retry-after')) > 0);
++ } finally {
++ await limited.stop();
++ }
++});
++
++t('stop', async () => { await svc.stop(); });
++
++t.run();
ddiff --git a/test/vhost.test.js b/test/vhost.test.js
iindex 13e1668..949993a 100644
---- a/test/vhost.test.js
++++ b/test/vhost.test.js
@@@ -47,4 +47,15 @@ t('the query API is GET-only and never cached by nginx; unknown paths get a JSON
assert.strictEqual(JSON.parse(body).code, 'search.not_found');
});
++t('the MCP endpoint is POST (and preflight OPTIONS) only, small, unbuffered and never cached', () => {
++ const m = block('location = /mcp');
++ assert.match(m, /limit_except POST OPTIONS \{ deny all; \}/, 'Streamable HTTP POST, plus the browser preflight');
++ assert.match(m, /limit_req zone=ovsearch_query burst=20 nodelay;/, 'the query zone, burst 20');
++ assert.match(m, /client_max_body_size 64k;/);
++ assert.match(m, /proxy_pass http:\/\/127\.0\.0\.1:4710;/);
++ assert.match(m, /proxy_buffering off;/);
++ assert.match(m, /proxy_no_cache 1;/);
++ assert.match(m, /proxy_cache_bypass 1;/);
++});
++
t.run();