openvibers/openvibe.services Commits

POST /mcp: the OpenVibe network as one read-only MCP server (AGT-2) (#207)

OP OpenVibers committed 10 days ago · a2da6cae38 · 6 files, +388 −0

* POST /mcp: the OpenVibe network as one read-only MCP server (AGT-2)

Three tools: openvibe_sites (every open site from the Network frame, as its own agent.json
describes it), openvibe_operations (a site's documented GET operations, from its /openapi.json) and
openvibe_get (one documented GET, called anonymously). The path must match a documented GET
template on the site's own origin; no credential is sent; a redirect is reported, not followed;
15 s and 256 KB a call; 60 calls a minute per address. agent.json and llms.txt name it.

* The SSRF ratchet lists the network MCP server and where its requests go

Changes

ddiff --git a/server/agent-descriptor.js b/server/agent-descriptor.js
iindex 5b1e792..c1e0ce7 100644
---- a/server/agent-descriptor.js
++++ b/server/agent-descriptor.js
@@@ -11,6 +11,8 @@ module.exports = {
api: '/api/v1',
openapi: '/docs/api/services.json',
docs: '/docs',
++ // The network as one read-only MCP server: its sites, their documented GET operations, an anonymous call (AGT-2).
++ mcp: { url: '/mcp' },
policy: {
auth: ['none', 'Network OIDC', 'bearer'],
rateLimit: '300 page requests and 240 API requests a minute per address; sign-in 60 attempts per 15 minutes',
ddiff --git a/server/app.js b/server/app.js
iindex cbc0ffd..2fd1b1c 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -245,6 +245,11 @@ async function createApp(opts = {}) {
},
}));
++ // ── The network as one read-only MCP server (AGT-2, server/http/network-mcp.js) ──
++ // POST /mcp: list the open sites, a site's documented GET operations, and call one anonymously. Its own limit.
++ app.use('/mcp', rateLimit({ windowMs: 60_000, limit: Number(process.env.SERVICES_MCP_PER_MIN) || 60, standardHeaders: true, legacyHeaders: false }));
++ app.use(require('./http/network-mcp').createNetworkMcp({ config, fetchImpl: fetchImpl || globalThis.fetch, now: store.now, log }));
++
// ── API ─────────────────────────────────────────────────
app.use('/api/v1', createApi(ctx));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex a9effb7..681fdf8 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -208,6 +208,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'robots.txt', url: abs('/robots.txt'), note: 'search and AI crawlers are welcome on the public pages' },
{ title: 'Full text for language models', url: abs('/llms-full.txt'), note: 'every fixed public page, one line each' },
{ title: 'Release metadata (JSON)', url: abs('/release.json'), note: 'this service\'s current release, per ADR-016' },
++ { title: 'MCP server (the whole network, read-only)', url: abs('/mcp'), note: 'POST JSON-RPC: openvibe_sites, openvibe_operations, openvibe_get (anonymous GETs of documented operations)' },
];
if (firstContract) machine.push({ title: 'Contract JSON Schema', url: abs(`/docs/contracts/${firstContract}.json`), note: 'append .json to any contract page for its schema' });
if (firstService) machine.push({ title: 'Service OpenAPI', url: abs(`/docs/api/${firstService}.json`), note: 'append .json to any API explorer page for its OpenAPI document' });
ddiff --git a/server/http/network-mcp.js b/server/http/network-mcp.js
nnew file mode 100644
iindex 0000000..e7e79ac
---- /dev/null
++++ b/server/http/network-mcp.js
@@@ -0,0 +1,285 @@
++'use strict';
++/**
++ * POST /mcp: the OpenVibe network as one read-only MCP server (AGT-2). Revision 2025-06-18, Streamable HTTP, stateless:
++ * one JSON-RPC 2.0 request (or a batch) answered once as JSON, no sessions.
++ *
++ * openvibe_sites every open site of the network: id, name, purpose, home, API, OpenAPI document, docs and MCP,
++ * as each site's own /.well-known/agent.json says
++ * openvibe_operations { site } → the site's documented GET operations, from its /openapi.json: path, summary, the
++ * path and query parameters
++ * openvibe_get { site, path, query? } → one documented GET operation, called anonymously: status, content
++ * type and body (JSON when it is JSON), cut at 256 KB
++ *
++ * What keeps it safe:
++ * - The sites are the Network frame's list (https origins only); a site is named by its id or host, never a URL.
++ * - A call is a GET of a path that matches one of the site's documented GET operations (its {params} filled with
++ * one path segment each), on that site's own origin. No other method, host or path.
++ * - It carries no credential: no cookie, no Authorization, nothing of the caller's. What it reads is what anyone
++ * reads. A redirect is reported, not followed.
++ * - Bounded: 15 s and 256 KB a call, 20 query keys of at most 500 characters, and the endpoint's own per-address limit
++ * (60 a minute, server/app.js). The frame, the manifests and the documents are cached for an hour.
++ * - A tool error is a result the caller can read (isError), per MCP; a bad message is a JSON-RPC error.
++ */
++const express = require('express');
++const pkg = require('../../package.json');
++
++const PROTOCOL_VERSION = '2025-06-18';
++const SUPPORTED_VERSIONS = new Set(['2025-06-18', '2025-03-26', '2024-11-05']);
++const CACHE_MS = 60 * 60 * 1000;
++const CALL_TIMEOUT_MS = 15_000;
++const MAX_BODY = 256 * 1024;
++const MAX_TEXT = 20_000;
++const METHODS = ['get', 'put', 'post', 'delete', 'patch', 'head', 'options'];
++
++const TOOLS = [
++ {
++ name: 'openvibe_sites',
++ title: 'OpenVibe sites',
++ description: 'List every open site of the OpenVibe network with what it is for and where its API, OpenAPI document, docs and MCP server are. Use a site\'s id with openvibe_operations and openvibe_get.',
++ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
++ annotations: { readOnlyHint: true, openWorldHint: false },
++ },
++ {
++ name: 'openvibe_operations',
++ title: 'A site\'s operations',
++ description: 'List the GET operations one OpenVibe site documents in its OpenAPI document: path template, summary, path and query parameters.',
++ inputSchema: { type: 'object', properties: { site: { type: 'string', description: 'A site id or host from openvibe_sites, e.g. "deals" or "openvibe.deals"' } }, required: ['site'], additionalProperties: false },
++ annotations: { readOnlyHint: true, openWorldHint: false },
++ },
++ {
++ name: 'openvibe_get',
++ title: 'Read from a site',
++ description: 'Call one documented GET operation of an OpenVibe site, anonymously (public data only). The path must match a path template from openvibe_operations, with each {parameter} filled.',
++ inputSchema: {
++ type: 'object',
++ properties: {
++ site: { type: 'string', description: 'A site id or host from openvibe_sites' },
++ path: { type: 'string', description: 'The full path, e.g. /api/v1/deals/hot or /api/v1/games/blackjack' },
++ query: { type: 'object', description: 'Query parameters (strings, numbers or booleans)', additionalProperties: { type: ['string', 'number', 'boolean'] } },
++ },
++ required: ['site', 'path'],
++ additionalProperties: false,
++ },
++ annotations: { readOnlyHint: true, openWorldHint: false },
++ },
++];
++
++const INSTRUCTIONS = 'Read the OpenVibe network: openvibe_sites lists the sites, openvibe_operations lists one site\'s documented GET operations, and openvibe_get calls one of them anonymously. Only public data is reachable; nothing is written.';
++
++const result = (id, value) => ({ jsonrpc: '2.0', id, result: value });
++const error = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
++const validId = (id) => typeof id === 'string' || (typeof id === 'number' && Number.isFinite(id));
++const toolText = (value) => { const s = JSON.stringify(value, null, 2); return s.length > MAX_TEXT ? `${s.slice(0, MAX_TEXT)}\n… (cut; the whole answer is in structuredContent)` : s; };
++const fail = (id, message) => result(id, { content: [{ type: 'text', text: message }], isError: true });
++
++function createNetworkMcp({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {
++ const networkUrl = String((config && config.network && config.network.url) || 'https://openvibe.network').replace(/\/+$/, '');
++ const cache = new Map(); // key → { at, value }
++
++ async function cached(key, load) {
++ const hit = cache.get(key);
++ if (hit && now() - hit.at < CACHE_MS) return hit.value;
++ try {
++ const value = await load();
++ cache.set(key, { at: now(), value });
++ return value;
++ } catch (err) {
++ if (hit) return hit.value; // the last good answer outlives a failed refresh
++ throw err;
++ }
++ }
++
++ async function getJson(url, ms = 8000) {
++ const r = await fetchImpl(url, { headers: { Accept: 'application/json', 'User-Agent': 'OpenVibe network MCP (openvibe.services)' }, redirect: 'manual', signal: AbortSignal.timeout(ms) });
++ if (r.status !== 200) throw new Error(`${new URL(url).host} answered ${r.status}`);
++ return await r.json();
++ }
++
++ /** The network's open sites: origin by id and by host. */
++ const sites = () => cached('sites', async () => {
++ const frame = await getJson(`${networkUrl}/api/frame`);
++ const origins = [...new Set((frame.nav || []).map((x) => x && x.url).filter((u) => typeof u === 'string' && /^https:\/\/[^/]+/.test(u)).map((u) => new URL(u).origin))].sort();
++ const list = await Promise.all(origins.map(async (origin) => {
++ let m = null;
++ try { m = await getJson(`${origin}/.well-known/agent.json`); } catch { /* a site without a manifest is still listed */ }
++ const host = new URL(origin).host;
++ const sameOrigin = (u) => { try { return u && new URL(u, origin).origin === origin ? new URL(u, origin).href : null; } catch { return null; } };
++ return {
++ id: (m && typeof m.id === 'string' && m.id) || host, host, origin,
++ name: (m && m.name) || host, purpose: (m && m.purpose) || null,
++ api: sameOrigin(m && m.api), openapi: sameOrigin(m && m.openapi) || `${origin}/openapi.json`,
++ docs: (m && m.docs) || null, mcp: (m && m.mcp && m.mcp.url) || null,
++ };
++ }));
++ return list;
++ });
++
++ async function siteOf(name) {
++ const key = String(name || '').trim().toLowerCase();
++ if (!key) return null;
++ return (await sites()).find((s) => s.id.toLowerCase() === key || s.host === key) || null;
++ }
++
++ /** A site's documented GET operations, with each one's full path (the document's server path prefix included). */
++ const operations = (site) => cached(`ops:${site.origin}`, async () => {
++ const doc = await getJson(site.openapi, 10000);
++ let prefix = '';
++ const server = Array.isArray(doc.servers) && doc.servers[0] && doc.servers[0].url;
++ if (server) {
++ try {
++ const u = new URL(server, site.origin);
++ if (u.origin === site.origin) prefix = u.pathname.replace(/\/+$/, '');
++ } catch { /* a relative or odd server URL: the paths stand alone */ }
++ }
++ const ops = [];
++ for (const [p, item] of Object.entries(doc.paths || {})) {
++ const op = item && item.get;
++ if (!op) continue;
++ const vis = [].concat(op['x-openvibe-visibility'] || []);
++ if (vis.length && vis.every((v) => v === 'internal')) continue;
++ const params = [...(Array.isArray(item.parameters) ? item.parameters : []), ...(Array.isArray(op.parameters) ? op.parameters : [])];
++ const full = `${prefix}${p.startsWith('/') ? p : `/${p}`}`;
++ ops.push({
++ path: full, summary: op.summary || op.description || null,
++ path_params: [...full.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]),
++ query_params: params.filter((q) => q && q.in === 'query' && q.name).map((q) => q.name),
++ });
++ }
++ ops.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
++ return ops;
++ });
++
++ /** The documented operation a concrete path matches, or null: an exact path before a template, fewer {params} first. */
++ function matchOperation(ops, path) {
++ for (const op of [...ops].sort((a, b) => a.path_params.length - b.path_params.length)) {
++ const re = new RegExp(`^${op.path.split(/\{[^}]+\}/).map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('[^/?#]+')}$`);
++ if (re.test(path)) return op;
++ }
++ return null;
++ }
++
++ async function readCapped(r) {
++ const reader = r.body && r.body.getReader ? r.body.getReader() : null;
++ if (!reader) { const t = await r.text(); return { text: t.slice(0, MAX_BODY), truncated: t.length > MAX_BODY }; }
++ const chunks = []; let size = 0; let truncated = false;
++ for (;;) {
++ const { done, value } = await reader.read();
++ if (done) break;
++ size += value.length;
++ if (size > MAX_BODY) { chunks.push(value.slice(0, value.length - (size - MAX_BODY))); truncated = true; await reader.cancel().catch(() => {}); break; }
++ chunks.push(value);
++ }
++ return { text: Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8'), truncated };
++ }
++
++ async function callGet(args) {
++ const site = await siteOf(args.site);
++ if (!site) return { error: `No open OpenVibe site is called "${String(args.site || '').slice(0, 80)}": see openvibe_sites.` };
++ const path = String(args.path || '');
++ if (!/^\/[^?#\s\\]*$/.test(path) || /(^|\/)\.\.?(\/|$)/.test(path) || /%2e|%2f|%5c/i.test(path)) return { error: 'The path is one absolute path with no query, no fragment and no dot segments.' };
++ const ops = await operations(site);
++ const op = matchOperation(ops, path);
++ if (!op) return { error: `${site.id} documents no GET operation at ${path.slice(0, 200)}: see openvibe_operations.` };
++ const q = args.query == null ? {} : args.query;
++ if (typeof q !== 'object' || Array.isArray(q)) return { error: 'query is an object of strings, numbers or booleans.' };
++ const entries = Object.entries(q);
++ if (entries.length > 20) return { error: 'At most 20 query parameters.' };
++ const search = new URLSearchParams();
++ for (const [k, v] of entries) {
++ if (!/^[A-Za-z0-9_.\-[\]]{1,64}$/.test(k) || !['string', 'number', 'boolean'].includes(typeof v) || String(v).length > 500) return { error: `The query parameter "${k.slice(0, 64)}" is not a short string, number or boolean.` };
++ search.append(k, String(v));
++ }
++ const url = `${site.origin}${path}${entries.length ? `?${search}` : ''}`;
++ let r;
++ try {
++ r = await fetchImpl(url, { method: 'GET', headers: { Accept: 'application/json, text/plain;q=0.8, */*;q=0.1', 'User-Agent': 'OpenVibe network MCP (openvibe.services)' }, redirect: 'manual', signal: AbortSignal.timeout(CALL_TIMEOUT_MS) });
++ } catch (err) {
++ return { error: `${site.id} did not answer (${err && err.name === 'TimeoutError' ? 'it took more than 15 s' : 'the connection failed'}).` };
++ }
++ const type = String(r.headers.get('content-type') || '');
++ if (r.status >= 300 && r.status < 400) return { value: { site: site.id, url, status: r.status, location: r.headers.get('location') || null } };
++ const { text, truncated } = await readCapped(r);
++ let body = text;
++ if (/json/i.test(type) && !truncated) { try { body = JSON.parse(text); } catch { /* the text stands */ } }
++ return { value: { site: site.id, url, operation: op.path, status: r.status, content_type: type || null, truncated, body } };
++ }
++
++ async function callTool(id, params) {
++ const name = params.name;
++ const args = params.arguments && typeof params.arguments === 'object' && !Array.isArray(params.arguments) ? params.arguments : {};
++ try {
++ if (name === 'openvibe_sites') {
++ const list = (await sites()).map(({ origin, ...s }) => s);
++ return result(id, { content: [{ type: 'text', text: toolText(list) }], structuredContent: { sites: list }, isError: false });
++ }
++ if (name === 'openvibe_operations') {
++ const site = await siteOf(args.site);
++ if (!site) return fail(id, `No open OpenVibe site is called "${String(args.site || '').slice(0, 80)}": see openvibe_sites.`);
++ const ops = await operations(site);
++ const value = { site: site.id, openapi: site.openapi, operations: ops };
++ return result(id, { content: [{ type: 'text', text: toolText(value) }], structuredContent: value, isError: false });
++ }
++ if (name === 'openvibe_get') {
++ const out = await callGet(args);
++ if (out.error) return fail(id, out.error);
++ return result(id, { content: [{ type: 'text', text: toolText(out.value) }], structuredContent: out.value, isError: out.value.status >= 400 });
++ }
++ } catch (err) {
++ log.warn && log.warn(`[network-mcp] ${name}: ${String((err && err.message) || err).slice(0, 200)}`);
++ return fail(id, 'The network could not be read just now; try again in a minute.');
++ }
++ return error(id, -32602, 'Unknown tool');
++ }
++
++ async function handleMessage(msg) {
++ if (!msg || typeof msg !== 'object' || Array.isArray(msg) || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') return error(null, -32600, 'Invalid Request');
++ const hasId = Object.prototype.hasOwnProperty.call(msg, 'id');
++ if (hasId && !validId(msg.id)) return error(null, -32600, 'Invalid Request');
++ if (!hasId || msg.method.startsWith('notifications/')) return null; // a notification has no answer
++ const params = msg.params && typeof msg.params === 'object' && !Array.isArray(msg.params) ? msg.params : {};
++ switch (msg.method) {
++ case 'initialize':
++ return result(msg.id, {
++ protocolVersion: SUPPORTED_VERSIONS.has(params.protocolVersion) ? params.protocolVersion : PROTOCOL_VERSION,
++ capabilities: { tools: { listChanged: false } },
++ serverInfo: { name: 'openvibe-network', title: 'The OpenVibe network', version: pkg.version },
++ instructions: INSTRUCTIONS,
++ });
++ case 'ping': return result(msg.id, {});
++ case 'tools/list': return result(msg.id, { tools: TOOLS });
++ case 'tools/call': return await callTool(msg.id, params);
++ default: return error(msg.id, -32601, 'Method not found');
++ }
++ }
++
++ const router = express.Router();
++ const json = express.json({ limit: '64kb', type: ['application/json', 'application/*+json'], strict: false });
++ // MCP clients in a browser exist: anonymous only, so `*` gives nothing away.
++ router.use('/mcp', (_req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Cache-Control', 'no-store'); next(); });
++ router.options('/mcp', (_req, res) => {
++ res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
++ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Mcp-Protocol-Version');
++ res.setHeader('Access-Control-Max-Age', '600');
++ res.status(204).end();
++ });
++ router.get('/mcp', (_req, res) => res.set('Allow', 'POST, OPTIONS').status(405).json(error(null, -32000, 'POST a JSON-RPC message; this server streams nothing')));
++ router.post('/mcp', json, async (req, res, next) => {
++ try {
++ const version = req.get('mcp-protocol-version');
++ if (version && !SUPPORTED_VERSIONS.has(version)) return res.status(400).json(error(null, -32600, 'Unsupported protocol version'));
++ const body = req.body;
++ if (!body || typeof body !== 'object' || (Array.isArray(body) && !body.length)) return res.json(error(null, -32600, 'Invalid Request'));
++ if (Array.isArray(body) && body.length > 20) return res.json(error(null, -32600, 'At most 20 messages in a batch'));
++ const answers = [];
++ for (const msg of Array.isArray(body) ? body : [body]) {
++ const a = await handleMessage(msg);
++ if (a) answers.push(a);
++ }
++ if (!answers.length) return res.status(202).end();
++ return res.json(Array.isArray(body) ? answers : answers[0]);
++ } catch (err) { return next(err); }
++ });
++ return router;
++}
++
++module.exports = { createNetworkMcp, TOOLS };
ddiff --git a/test/network-mcp.test.js b/test/network-mcp.test.js
nnew file mode 100644
iindex 0000000..8eaa527
---- /dev/null
++++ b/test/network-mcp.test.js
@@@ -0,0 +1,94 @@
++'use strict';
++// AGT-2: POST /mcp, the OpenVibe network as one read-only MCP server (server/http/network-mcp.js), against a fake
++// network: the frame, two sites' manifests and OpenAPI documents, and their endpoints.
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const { createNetworkMcp } = require('../server/http/network-mcp');
++
++const calls = [];
++const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers } });
++async function fakeFetch(url, opts = {}) {
++ calls.push({ url: String(url), opts });
++ const u = new URL(url);
++ if (u.href === 'https://openvibe.network/api/frame') return json({ nav: [{ url: 'https://openvibe.deals/' }, { url: 'https://openvibe.casino/learn' }, { url: 'http://insecure.example/' }, { url: '/relative' }] });
++ if (u.pathname === '/.well-known/agent.json' && u.host === 'openvibe.deals') return json({ id: 'deals', name: 'OpenVibe.Deals', purpose: 'Deals.', home: 'https://openvibe.deals', api: 'https://openvibe.deals/api/v1', openapi: 'https://openvibe.deals/openapi.json' });
++ if (u.pathname === '/.well-known/agent.json' && u.host === 'openvibe.casino') return json({ id: 'casino', name: 'OpenVibe.Casino', purpose: 'Casino.', openapi: 'https://evil.example/openapi.json' });
++ if (u.href === 'https://openvibe.deals/openapi.json') return json({ openapi: '3.1.0', paths: { '/api/v1/deals/hot': { get: { summary: 'The hot deals', parameters: [{ name: 'limit', in: 'query' }] } }, '/api/v1/deals/{id}': { get: { summary: 'One deal' }, delete: { summary: 'Remove' } }, '/api/v1/internal/x': { get: { 'x-openvibe-visibility': ['internal'] } }, '/api/v1/votes': { post: { summary: 'Vote' } } } });
++ if (u.href === 'https://openvibe.casino/openapi.json') return json({ openapi: '3.1.0', servers: [{ url: 'https://openvibe.casino/api/v1' }], paths: { '/games': { get: { summary: 'Every game' } }, '/games/{id}': { get: { summary: 'One game' } } } });
++ if (u.origin === 'https://openvibe.deals' && u.pathname === '/api/v1/deals/hot') return json({ deals: [{ id: 'd1' }], limit: u.searchParams.get('limit'), auth: (opts.headers || {}).Authorization || null });
++ if (u.origin === 'https://openvibe.deals' && u.pathname === '/api/v1/deals/big') return new Response('x'.repeat(300 * 1024), { status: 200, headers: { 'content-type': 'application/json' } });
++ if (u.origin === 'https://openvibe.deals' && u.pathname === '/api/v1/deals/moved') return new Response(null, { status: 301, headers: { location: 'https://elsewhere.example/' } });
++ if (u.origin === 'https://openvibe.casino' && u.pathname === '/api/v1/games/blackjack') return json({ game: { id: 'blackjack' } });
++ return json({ error: 'not found' }, 404);
++}
++
++(async () => {
++ const app = express().use(createNetworkMcp({ config: { network: { url: 'https://openvibe.network' } }, fetchImpl: fakeFetch, log: { warn() {} } }));
++ const server = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const rpc = async (body, headers = {}) => { const r = await fetch(`${base}/mcp`, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) }); return { status: r.status, headers: r.headers, body: r.status === 202 ? null : await r.json() }; };
++ const call = async (name, args) => (await rpc({ jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name, arguments: args } })).body.result;
++ try {
++ // The protocol.
++ const init = await rpc({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 't', version: '1' } } });
++ assert.strictEqual(init.status, 200);
++ assert.strictEqual(init.body.result.protocolVersion, '2025-03-26', 'a supported version is echoed');
++ assert.strictEqual(init.body.result.serverInfo.name, 'openvibe-network');
++ assert.strictEqual(init.headers.get('access-control-allow-origin'), '*');
++ assert.strictEqual((await rpc({ jsonrpc: '2.0', method: 'notifications/initialized' })).status, 202, 'a notification has no answer');
++ const list = (await rpc({ jsonrpc: '2.0', id: 2, method: 'tools/list' })).body.result.tools.map((t) => t.name);
++ assert.deepStrictEqual(list, ['openvibe_sites', 'openvibe_operations', 'openvibe_get']);
++ assert.strictEqual((await rpc({ jsonrpc: '2.0', id: 3, method: 'nope' })).body.error.code, -32601);
++ assert.strictEqual((await rpc([])).body.error.code, -32600);
++ assert.strictEqual((await rpc({ jsonrpc: '2.0', id: 4, method: 'ping' }, { 'mcp-protocol-version': '1999-01-01' })).status, 400);
++ const batch = await rpc([{ jsonrpc: '2.0', id: 5, method: 'ping' }, { jsonrpc: '2.0', method: 'notifications/x' }, { jsonrpc: '2.0', id: 6, method: 'ping' }]);
++ assert.deepStrictEqual(batch.body.map((m) => m.id), [5, 6], 'a batch answers its requests, not its notifications');
++ assert.strictEqual((await fetch(`${base}/mcp`)).status, 405);
++ assert.strictEqual((await fetch(`${base}/mcp`, { method: 'OPTIONS' })).status, 204);
++
++ // openvibe_sites: the frame's https origins only, each from its own manifest; a foreign openapi is not trusted.
++ const sites = (await call('openvibe_sites', {})).structuredContent.sites;
++ assert.deepStrictEqual(sites.map((s) => s.id), ['casino', 'deals']);
++ assert.strictEqual(sites.find((s) => s.id === 'casino').openapi, 'https://openvibe.casino/openapi.json', 'an OpenAPI URL on another origin is replaced by the site\'s own');
++
++ // openvibe_operations: GETs only, internal ones left out, the document's server prefix applied.
++ const deals = (await call('openvibe_operations', { site: 'deals' })).structuredContent.operations;
++ assert.deepStrictEqual(deals.map((o) => o.path), ['/api/v1/deals/hot', '/api/v1/deals/{id}']);
++ assert.deepStrictEqual(deals[0].query_params, ['limit']);
++ const casino = (await call('openvibe_operations', { site: 'openvibe.casino' })).structuredContent.operations;
++ assert.deepStrictEqual(casino.map((o) => o.path), ['/api/v1/games', '/api/v1/games/{id}'], 'the server path prefix is part of the path');
++ assert.strictEqual((await call('openvibe_operations', { site: 'nowhere' })).isError, true);
++
++ // openvibe_get: a documented GET, anonymously, on the site's own origin.
++ const hot = await call('openvibe_get', { site: 'deals', path: '/api/v1/deals/hot', query: { limit: 5 } });
++ assert.strictEqual(hot.isError, false);
++ assert.strictEqual(hot.structuredContent.operation, '/api/v1/deals/hot', 'an exact path wins over a {param} template');
++ assert.deepStrictEqual([hot.structuredContent.status, hot.structuredContent.body.limit, hot.structuredContent.body.auth], [200, '5', null], 'no credential is sent');
++ const sent = calls.find((c) => c.url.startsWith('https://openvibe.deals/api/v1/deals/hot'));
++ assert.ok(!('cookie' in (sent.opts.headers || {})) && !('Authorization' in (sent.opts.headers || {})) && sent.opts.redirect === 'manual' && (sent.opts.method || 'GET') === 'GET');
++ assert.strictEqual((await call('openvibe_get', { site: 'casino', path: '/api/v1/games/blackjack' })).structuredContent.body.game.id, 'blackjack', 'a {param} takes one segment');
++ for (const [args, why] of [
++ [{ site: 'deals', path: '/api/v1/votes' }, 'a POST-only path is not callable'],
++ [{ site: 'deals', path: '/api/v1/internal/x' }, 'an internal operation is not callable'],
++ [{ site: 'deals', path: '/api/v1/deals/a/b' }, 'a parameter is one segment'],
++ [{ site: 'deals', path: '/api/v1/deals/../internal/x' }, 'no dot segments'],
++ [{ site: 'deals', path: '/api/v1/deals/a%2Fb' }, 'no encoded slashes'],
++ [{ site: 'deals', path: 'https://evil.example/x' }, 'a path, never a URL'],
++ [{ site: 'deals', path: '/api/v1/deals/hot?x=1' }, 'the query goes in query'],
++ [{ site: 'evil.example', path: '/' }, 'only the network\'s sites'],
++ [{ site: 'deals', path: '/api/v1/deals/hot', query: { 'bad key': 1 } }, 'query keys are plain'],
++ [{ site: 'deals', path: '/api/v1/deals/hot', query: { q: 'x'.repeat(501) } }, 'query values are short'],
++ ]) assert.strictEqual((await call('openvibe_get', args)).isError, true, why);
++ const big = await call('openvibe_get', { site: 'deals', path: '/api/v1/deals/big' });
++ assert.deepStrictEqual([big.structuredContent.truncated, typeof big.structuredContent.body, big.structuredContent.body.length], [true, 'string', 256 * 1024], 'cut at 256 KB');
++ const moved = await call('openvibe_get', { site: 'deals', path: '/api/v1/deals/moved' });
++ assert.deepStrictEqual([moved.structuredContent.status, moved.structuredContent.location], [301, 'https://elsewhere.example/'], 'a redirect is reported, not followed');
++ assert.ok(!calls.some((c) => c.url.startsWith('https://elsewhere.example')), 'and nothing fetched it');
++ const missing = await call('openvibe_get', { site: 'deals', path: '/api/v1/deals/zzz' });
++ assert.deepStrictEqual([missing.isError, missing.structuredContent.status], [true, 404], 'the site\'s own 404 comes back as an error result');
++ console.log('network mcp: ok');
++ } finally {
++ server.close();
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 1afb4c7..fbfe18a 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -60,6 +60,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'server/domain/limits.js': 'the services\' /limits.json (configured internal URLs)',
'server/domain/project-archive.js': 'no request: a shell snippet in the archive\'s README text',
'server/http/docs.js': 'Network changelog, Tools catalog and Billing policy (configured)',
++ 'server/http/network-mcp.js': 'the Network frame (configured OV_NETWORK_URL), then only the https origins it lists: each one\'s /.well-known/agent.json and /openapi.json (on its own origin), and GETs of the paths those documents name (each {param} one segment); never a caller-chosen host, no credential, redirects not followed (AGT-2)',
'server/http/home.js': 'Network\'s public /api/v1/status (configured OV_NETWORK internal URL) for the home page\'s services-up number; no token',
};
const root = path.join(__dirname, '..');