openvibers/openvibe.design Commits
Add public Design MCP tools (#22)
Changes
@@@ -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@@@ -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.@@@ -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({@@@ -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') },nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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; });
@@ -87,6 +87,15 @@ JavaScript. A page rendered for a signed-in person is `private, no-store`; a pub
@@ -87,6 +87,15 @@ JavaScript. A page rendered for a signed-in person is `private, no-store`; a pub
## API
## 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`)
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
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
`application/problem+json`. Arena reads are public; writes need the session and a same-origin signal
@@ -78,6 +78,18 @@ server {
@@ -78,6 +78,18 @@ server {
proxy_pass http://127.0.0.1:5090;
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 ───────────────────────────────────
# ── Sign-in: GET only ───────────────────────────────────
# /auth/login, /auth/callback, /auth/me and /auth/logout are all GETs here. The app's POST /auth/logout confirm
# /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.
# is defence in depth and is not exposed by nginx; a same-origin navigation signs out directly.
@@ -32,6 +32,7 @@ const { createKeyStore } = require('./auth/keys');
@@ -32,6 +32,7 @@ const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createSso } = require('./auth/sso');
const { createThemeCatalog } = require('./themes');
const { createThemeCatalog } = require('./themes');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
+const { createMcpRoutes } = require('./http/mcp');
const { createArenaApi } = require('./http/arena-api');
const { createArenaApi } = require('./http/arena-api');
const { createDesignApi } = require('./http/design-api');
const { createDesignApi } = require('./http/design-api');
const { createDesigns } = require('./designs');
const { createDesigns } = require('./designs');
@@ -122,6 +123,14 @@ async function createApp(opts = {}) {
@@ -122,6 +123,14 @@ async function createApp(opts = {}) {
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
}));
app.use(cookieParser());
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) ─
// ── Per-IP budget for the API and sign-in (120 a minute) ─
const limiter = rateLimit({
const limiter = rateLimit({
@@ -77,6 +77,8 @@ function createDiscoveryRoutes(ctx) {
@@ -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: '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: '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 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: 'The Arena dataset (JSONL)', url: abs('/api/v1/arena/dataset.jsonl'), note: 'prompts, outputs and votes, no voters' },
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Sitemap', url: abs('/sitemap.xml') },
new file mode 100644
new file mode 100644
@@ -0,0 +1,128 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -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; });