openvibers/openvibe.design Commits

Add public Design MCP tools (#22)

OP OpenVibers committed 14 days ago · a21f2c984b · 6 files, +240 −0

Changes

ddiff --git a/README.md b/README.md
iindex e9f4c52..9a06c4b 100644
---- a/README.md
++++ b/README.md
@@@ -87,6 +87,15 @@ JavaScript. A page rendered for a signed-in person is `private, no-store`; a pub
## API
++### MCP
++
++`POST /mcp` is a public, stateless Streamable HTTP MCP endpoint (protocol `2025-06-18`).
++It offers `openvibe_design_list_themes`, `openvibe_design_get_theme_tokens`,
++`openvibe_design_list_components`, `openvibe_design_get_component`, and
++`openvibe_design_validate_kit_document`. The tools read the same Network catalogue,
++token compiler and component contract as the JSON API. Discover it through
++`/.well-known/agent.json` or `/llms.txt`; no sign-in is needed.
++
The theme surface is proxied to `config.networkUrl` (`OV_NETWORK_INTERNAL_URL`, default `http://127.0.0.1:4000`)
under `/api/themes`; the Arena's own data is under `/api/v1/arena`. Errors are RFC 9457
`application/problem+json`. Arena reads are public; writes need the session and a same-origin signal
ddiff --git a/deploy/nginx/openvibe.design.conf b/deploy/nginx/openvibe.design.conf
iindex e4d9a99..c3c78cb 100644
---- a/deploy/nginx/openvibe.design.conf
++++ b/deploy/nginx/openvibe.design.conf
@@@ -78,6 +78,18 @@ server {
proxy_pass http://127.0.0.1:5090;
}
++ # Public MCP tools over stateless Streamable HTTP. Kit documents may use the validator's full limits.
++ location = /mcp {
++ limit_except POST OPTIONS { deny all; }
++ limit_req zone=design_api burst=20 nodelay;
++ limit_req_status 429;
++ client_max_body_size 1m;
++ proxy_pass http://127.0.0.1:5090;
++ proxy_buffering off;
++ proxy_no_cache 1;
++ proxy_cache_bypass 1;
++ }
++
# ── Sign-in: GET only ───────────────────────────────────
# /auth/login, /auth/callback, /auth/me and /auth/logout are all GETs here. The app's POST /auth/logout confirm
# is defence in depth and is not exposed by nginx; a same-origin navigation signs out directly.
ddiff --git a/server/app.js b/server/app.js
iindex 23ac263..ee511bf 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -32,6 +32,7 @@ const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createThemeCatalog } = require('./themes');
const { createApi } = require('./http/api');
++const { createMcpRoutes } = require('./http/mcp');
const { createArenaApi } = require('./http/arena-api');
const { createDesignApi } = require('./http/design-api');
const { createDesigns } = require('./designs');
@@@ -122,6 +123,14 @@ async function createApp(opts = {}) {
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
app.use(cookieParser());
++ app.use(createMcpRoutes(ctx));
++ const agentLayer = express();
++ require('openvibe-shared/agent-layer').mount(agentLayer, { descriptor: {
++ id: 'design', name: 'OpenVibe.Design', purpose: 'Public theme tokens and component specifications.',
++ home: config.baseUrl, api: '/api/v1', docs: '/developers', mcp: { url: '/mcp' },
++ policy: { auth: ['none'] },
++ } });
++ app.use((req, res, next) => req.path === '/.well-known/agent.json' ? agentLayer(req, res, next) : next());
// ── Per-IP budget for the API and sign-in (120 a minute) ─
const limiter = rateLimit({
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 95681e2..7a5111c 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -77,6 +77,8 @@ function createDiscoveryRoutes(ctx) {
] },
{ title: 'Themes', links: list.filter((t) => t && t.slug).slice(0, 100).map((t) => ({ title: String(t.name || t.slug), url: abs(`/t/${encodeURIComponent(String(t.slug))}`), note: `${t.mode ? String(t.mode) : 'theme'}${t.description ? ` — ${String(t.description)}` : ''}` })) },
{ title: 'Machine-readable', links: [
++ { title: 'MCP tools', url: abs('/mcp'), note: 'POST, Streamable HTTP; public themes, tokens and components' },
++ { title: 'Agent manifest', url: abs('/.well-known/agent.json') },
{ title: 'The theme catalogue (JSON)', url: abs('/api/themes'), note: 'public, cached for 5 minutes' },
{ title: 'The Arena dataset (JSONL)', url: abs('/api/v1/arena/dataset.jsonl'), note: 'prompts, outputs and votes, no voters' },
{ title: 'Sitemap', url: abs('/sitemap.xml') },
ddiff --git a/server/http/mcp.js b/server/http/mcp.js
nnew file mode 100644
iindex 0000000..c6d3b38
---- /dev/null
++++ b/server/http/mcp.js
@@@ -0,0 +1,128 @@
++'use strict';
++
++// Stateless MCP Streamable HTTP endpoint. Every tool reads public Design data only.
++const express = require('express');
++const components = require('openvibe-shared/components');
++const themeTokens = require('../tokens');
++const { version } = require('../../package.json');
++
++const PROTOCOL = '2025-06-18';
++const VERSIONS = new Set([PROTOCOL, '2025-03-26']);
++const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
++const schema = (required, properties) => ({ type: 'object', required, properties, additionalProperties: false });
++const TOOLS = [
++ { name: 'openvibe_design_list_themes', description: 'List public Network themes with their four preview colours.', inputSchema: schema([], {
++ query: { type: 'string', maxLength: 200 }, mode: { type: 'string', enum: ['light', 'dark'] },
++ }) },
++ { name: 'openvibe_design_get_theme_tokens', description: 'Export one public theme as OpenVibe Tokens, as in /api/v1/tokens.', inputSchema: schema(['theme', 'format'], {
++ theme: { type: 'string', minLength: 1, maxLength: 200 },
++ format: { type: 'string', enum: themeTokens.TARGETS.map(t => t.id) },
++ both: { type: 'boolean', default: true },
++ }) },
++ { name: 'openvibe_design_list_components', description: 'List the 11 OpenVibe component specifications in short form.', inputSchema: schema([], {}) },
++ { name: 'openvibe_design_get_component', description: 'Read a complete component specification, including anatomy, props, variants, states, accessibility and native snippets.', inputSchema: schema(['name'], {
++ name: { type: 'string', minLength: 1, maxLength: 100 },
++ }) },
++ { name: 'openvibe_design_validate_kit_document', description: 'Validate a kit document against the component contract and its size limits.', inputSchema: schema(['doc'], {
++ doc: { type: 'object', description: 'A kit component document.' },
++ }) },
++].map(tool => ({ ...tool, annotations: { readOnlyHint: true, openWorldHint: false } }));
++
++const rpc = (id, result) => ({ jsonrpc: '2.0', id, result });
++const error = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
++const toolError = message => ({ content: [{ type: 'text', text: message }], isError: true });
++function toolResult(value) {
++ return {
++ content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value) }],
++ ...(object(value) ? { structuredContent: value } : {}),
++ isError: false,
++ };
++}
++
++async function callTool(name, args, { catalog }) {
++ if (!object(args)) return toolError('Arguments must be an object.');
++ switch (name) {
++ case 'openvibe_design_list_themes': {
++ if (args.query !== undefined && (typeof args.query !== 'string' || args.query.length > 200)) return toolError('query must be a string of at most 200 characters.');
++ if (args.mode !== undefined && !['light', 'dark'].includes(args.mode)) return toolError('mode must be light or dark.');
++ const list = (await catalog.list()).themes;
++ const query = (args.query || '').toLowerCase();
++ return toolResult({ themes: list.filter(t => t && (!args.mode || t.mode === args.mode)
++ && (!query || [t.id, t.slug, t.name].some(v => String(v || '').toLowerCase().includes(query))))
++ .map(t => ({ id: t.id, name: t.name, mode: t.mode, preview: {
++ background: t.variables && t.variables['--bg-primary'],
++ surface: t.variables && t.variables['--bg-card'],
++ text: t.variables && t.variables['--text-primary'],
++ accent: t.variables && t.variables['--accent'],
++ } })) });
++ }
++ case 'openvibe_design_get_theme_tokens': {
++ if (typeof args.theme !== 'string' || !args.theme || args.theme.length > 200) return toolError('theme must be a nonempty string of at most 200 characters.');
++ if (!themeTokens.TARGETS.some(t => t.id === args.format)) return toolError(`format must be one of ${themeTokens.TARGETS.map(t => t.id).join(', ')}.`);
++ if (args.both !== undefined && typeof args.both !== 'boolean') return toolError('both must be a boolean.');
++ const found = themeTokens.resolve((await catalog.list()).themes, args.theme, { both: args.both !== false });
++ if (!found) return toolError('No public theme by that name.');
++ const output = themeTokens.compile(found.docs.length > 1 ? found.docs : found.docs[0], args.format);
++ return toolResult(['json', 'figma'].includes(args.format) ? JSON.parse(output) : output);
++ }
++ case 'openvibe_design_list_components':
++ if (Object.keys(args).length) return toolError('This tool takes no arguments.');
++ return toolResult({ components: components.SPECS.map(({ id, name: label, summary }) => ({ id, name: label, summary })) });
++ case 'openvibe_design_get_component': {
++ if (typeof args.name !== 'string' || !args.name || args.name.length > 100) return toolError('name must be a nonempty string of at most 100 characters.');
++ const spec = components.SPECS.find(s => s.id.toLowerCase() === args.name.toLowerCase() || s.name.toLowerCase() === args.name.toLowerCase());
++ return spec ? toolResult(spec) : toolError('No component by that name.');
++ }
++ case 'openvibe_design_validate_kit_document':
++ if (!Object.prototype.hasOwnProperty.call(args, 'doc')) return toolError('doc is required.');
++ return toolResult(components.validate(args.doc));
++ default:
++ return null;
++ }
++}
++
++function createMcpRoutes(ctx) {
++ const r = express.Router();
++ const json = express.json({ limit: '1mb', type: ['application/json', 'application/*+json'], strict: false });
++ r.use('/mcp', (_req, res, next) => { res.set('Access-Control-Allow-Origin', '*'); res.set('Cache-Control', 'no-store'); next(); });
++ r.options('/mcp', (_req, res) => res.set('Access-Control-Allow-Methods', 'POST, OPTIONS')
++ .set('Access-Control-Allow-Headers', 'Content-Type, Mcp-Protocol-Version')
++ .set('Access-Control-Max-Age', '600').status(204).end());
++ r.post('/mcp', json, async (req, res, next) => {
++ try {
++ if (req.get('mcp-protocol-version') && !VERSIONS.has(req.get('mcp-protocol-version')))
++ return res.status(400).json(error(null, -32600, 'Unsupported protocol version'));
++ const input = req.body;
++ if (!object(input) && (!Array.isArray(input) || !input.length)) return res.json(error(null, -32600, 'Invalid Request'));
++ const batch = Array.isArray(input);
++ const replies = [];
++ for (const msg of batch ? input : [input]) {
++ if (!object(msg) || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string'
++ || (Object.prototype.hasOwnProperty.call(msg, 'id') && msg.id !== null && typeof msg.id !== 'string' && !(typeof msg.id === 'number' && Number.isFinite(msg.id)))) {
++ replies.push(error(null, -32600, 'Invalid Request')); continue;
++ }
++ if (!Object.prototype.hasOwnProperty.call(msg, 'id') || msg.method.startsWith('notifications/')) continue;
++ const id = msg.id;
++ const params = object(msg.params) ? msg.params : {};
++ if (msg.method === 'initialize') replies.push(rpc(id, { protocolVersion: VERSIONS.has(params.protocolVersion) ? params.protocolVersion : PROTOCOL,
++ capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'openvibe-design', title: 'OpenVibe Design', version } }));
++ else if (msg.method === 'ping') replies.push(rpc(id, {}));
++ else if (msg.method === 'tools/list') replies.push(rpc(id, { tools: TOOLS }));
++ else if (msg.method === 'tools/call') {
++ const result = await callTool(params.name, params.arguments || {}, ctx);
++ replies.push(result ? rpc(id, result) : error(id, -32602, 'Unknown tool'));
++ } else replies.push(error(id, -32601, 'Method not found'));
++ }
++ return replies.length ? res.json(batch ? replies : replies[0]) : res.status(202).end();
++ } catch (err) { return next(err); }
++ });
++ r.all('/mcp', (_req, res) => res.set('Allow', 'POST').status(405).json(error(null, -32600, 'Method Not Allowed')));
++ r.use((err, _req, res, next) => {
++ if (err && err.type === 'entity.parse.failed') return res.status(400).json(error(null, -32700, 'Parse error'));
++ if (err && err.type === 'entity.too.large') return res.status(413).json(error(null, -32600, 'Request body too large'));
++ return next(err);
++ });
++ return r;
++}
++
++module.exports = { createMcpRoutes, TOOLS, PROTOCOL };
ddiff --git a/test/mcp.test.js b/test/mcp.test.js
nnew file mode 100644
iindex 0000000..d0eaa20
---- /dev/null
++++ b/test/mcp.test.js
@@@ -0,0 +1,80 @@
++'use strict';
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const components = require('openvibe-shared/components');
++
++(async () => {
++ const t = await boot({ network: { themes: [
++ { id: 'night', slug: 'night', name: 'Night', mode: 'dark', variables: {
++ '--bg-primary': '#101010', '--bg-card': '#202020', '--text-primary': '#ffffff', '--accent': '#ff0000',
++ } },
++ ] } });
++ let id = 0;
++ const send = async (method, params = {}) => t.get('/mcp', { json: { jsonrpc: '2.0', id: ++id, method, params } });
++ const call = async (name, args = {}) => (await send('tools/call', { name, arguments: args })).json().result;
++ try {
++ await check('MCP initialize, list, CORS, discovery and transport errors', async () => {
++ const init = (await send('initialize', { protocolVersion: '2025-06-18' })).json().result;
++ assert.strictEqual(init.protocolVersion, '2025-06-18');
++ assert.strictEqual(init.serverInfo.name, 'openvibe-design');
++ const listed = (await send('tools/list')).json().result.tools;
++ assert.deepStrictEqual(listed.map(x => x.name), [
++ 'openvibe_design_list_themes', 'openvibe_design_get_theme_tokens',
++ 'openvibe_design_list_components', 'openvibe_design_get_component',
++ 'openvibe_design_validate_kit_document',
++ ]);
++ assert.ok(listed.every(x => x.inputSchema && x.annotations.readOnlyHint));
++ assert.strictEqual((await t.get('/mcp', { method: 'OPTIONS' })).status, 204);
++ assert.strictEqual((await t.get('/mcp')).status, 405);
++ assert.strictEqual((await t.get('/mcp', { body: '{', headers: { 'content-type': 'application/json' } })).json().error.code, -32700);
++ assert.strictEqual((await t.get('/mcp', { json: { jsonrpc: '2.0', id: 1, method: 'unknown' } })).json().error.code, -32601);
++ assert.strictEqual((await t.get('/mcp', { json: { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'unknown' } } })).json().error.code, -32602);
++ assert.strictEqual((await t.get('/.well-known/agent.json')).json().mcp.url, 'https://openvibe.design/mcp');
++ assert.match((await t.get('/llms.txt')).text, /https:\/\/openvibe\.design\/mcp/);
++ });
++ await check('list themes good and bad arguments', async () => {
++ const good = await call('openvibe_design_list_themes', { query: 'nig', mode: 'dark' });
++ assert.strictEqual(good.structuredContent.themes.length, 1);
++ assert.deepStrictEqual(good.structuredContent.themes[0].preview, {
++ background: '#101010', surface: '#202020', text: '#ffffff', accent: '#ff0000',
++ });
++ assert.strictEqual((await call('openvibe_design_list_themes', { mode: 'blue' })).isError, true);
++ });
++ await check('theme tokens good and bad arguments', async () => {
++ const output = await call('openvibe_design_get_theme_tokens', { theme: 'night', format: 'css', both: false });
++ assert.match(output.content[0].text, /#101010/);
++ const json = await call('openvibe_design_get_theme_tokens', { theme: 'night', format: 'json' });
++ assert.strictEqual(json.structuredContent.color.bg.base.$value, '#101010');
++ assert.strictEqual((await call('openvibe_design_get_theme_tokens', { theme: 'night', format: 'cobol' })).isError, true);
++ assert.strictEqual((await call('openvibe_design_get_theme_tokens', { theme: 'missing', format: 'css' })).isError, true);
++ });
++ await check('component list and detail good and bad arguments', async () => {
++ const list = await call('openvibe_design_list_components');
++ assert.strictEqual(list.structuredContent.components.length, 11);
++ assert.deepStrictEqual(Object.keys(list.structuredContent.components[0]), ['id', 'name', 'summary']);
++ assert.strictEqual((await call('openvibe_design_list_components', { extra: true })).isError, true);
++ const detail = await call('openvibe_design_get_component', { name: 'button' });
++ for (const key of ['anatomy', 'props', 'variants', 'states', 'a11y', 'native']) assert.ok(detail.structuredContent[key], key);
++ assert.strictEqual((await call('openvibe_design_get_component', { name: 'missing' })).isError, true);
++ });
++ await check('kit validation and contract size limits', async () => {
++ assert.deepStrictEqual((await call('openvibe_design_validate_kit_document', { doc: { type: 'button', props: { label: 'Go' } } })).structuredContent, { ok: true, errors: [] });
++ const invalid = (await call('openvibe_design_validate_kit_document', { doc: { type: 'button', props: { label: '' } } })).structuredContent;
++ assert.strictEqual(invalid.ok, false);
++ assert.match(invalid.errors.join(' '), /button\.props\.label: required/);
++ assert.strictEqual((await call('openvibe_design_validate_kit_document')).isError, true);
++ const oversized = (await call('openvibe_design_validate_kit_document', { doc: { type: 'button', props: { label: 'x'.repeat(components.LIMITS.string + 1) } } })).structuredContent;
++ assert.strictEqual(oversized.ok, false);
++ let deep = { type: 'card', props: { title: 'end' } };
++ for (let n = 0; n < components.LIMITS.depth; n++) deep = { type: 'section', children: [deep] };
++ assert.strictEqual((await call('openvibe_design_validate_kit_document', { doc: deep })).structuredContent.ok, false);
++ const many = { type: 'section', children: Array.from({ length: components.LIMITS.items + 1 }, () => ({ type: 'card', props: { title: 'x' } })) };
++ assert.strictEqual((await call('openvibe_design_validate_kit_document', { doc: many })).structuredContent.ok, false);
++ const branch = depth => depth ? { type: 'section', children: Array.from({ length: 3 }, () => branch(depth - 1)) } : { type: 'section' };
++ const nodes = (await call('openvibe_design_validate_kit_document', { doc: branch(6) })).structuredContent;
++ assert.strictEqual(nodes.ok, false);
++ assert.match(nodes.errors.join(' '), /more than 400 nodes/);
++ });
++ } finally { await t.close(); }
++ done();
++})().catch(err => { console.error(err); process.exitCode = 1; });