@xoong/mcp 0.0.0-stage → 0.0.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Duong Tran Xuan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,25 @@
1
- # Temporary Holding Version
1
+ # @xoong/mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An MCP server for xoong: gives AI assistants the spec (`llms.txt`), the operations of your API, `xoong check` and explanations of errors, over stdio.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @xoong/mcp
9
+ ```
10
+
11
+ ## Use
12
+
13
+ ```bash
14
+ npx xoong-mcp
15
+ ```
16
+ Add it to your assistant's MCP configuration as a stdio server running `xoong-mcp`.
17
+
18
+ ## More
19
+
20
+ * Documentation and demos: https://github.com/fpt-duongtx/xoong#readme
21
+ * Source: https://github.com/fpt-duongtx/xoong/tree/main/packages/mcp
22
+ * All packages share one version number and are released together: https://github.com/fpt-duongtx/xoong/releases
23
+ * xoong is a working prototype (0.0.x): the HTML syntax is still Draft.
24
+
25
+ MIT licence.
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@xoong/mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.34",
4
+ "description": "MCP server for xoong: gives AI assistants the spec (llms.txt), the API's operations, `xoong check` and error explanations over stdio.",
5
+ "type": "module",
6
+ "bin": {
7
+ "xoong-mcp": "src/cli.js"
8
+ },
9
+ "exports": {
10
+ ".": "./src/server.js"
11
+ },
12
+ "license": "MIT",
13
+ "dependencies": {
14
+ "@xoong/cli": "0.0.34"
15
+ },
16
+ "files": [
17
+ "src"
18
+ ],
19
+ "keywords": [
20
+ "xoong",
21
+ "web-framework",
22
+ "openapi",
23
+ "custom-elements"
24
+ ],
25
+ "engines": {
26
+ "node": ">=22.12"
27
+ },
28
+ "author": "Duong Tran Xuan <duongtx1505@icloud.com>",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/fpt-duongtx/xoong.git",
32
+ "directory": "packages/mcp"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/fpt-duongtx/xoong/issues"
36
+ },
37
+ "homepage": "https://github.com/fpt-duongtx/xoong#readme",
38
+ "publishConfig": {
39
+ "access": "public"
40
+ }
41
+ }
package/src/cli.js ADDED
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // xoong-mcp [--operations .xoong/operations.json] [--llms llms.txt] [--docs docs]
4
+ import { readFile, access } from 'node:fs/promises';
5
+ import { createInterface } from 'node:readline';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { createServer } from './server.js';
8
+
9
+ const args = process.argv.slice(2);
10
+ const flag = (name, fallback) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : fallback; };
11
+ const opsFile = flag('--operations', '.xoong/operations.json');
12
+ const exists = (p) => access(p).then(() => true, () => false);
13
+
14
+ const server = createServer({
15
+ async llms() {
16
+ // the project's own file first, then the copy that sits at the repository root of this package
17
+ for (const p of [flag('--llms'), 'llms.txt', fileURLToPath(new URL('../../../llms.txt', import.meta.url))].filter(Boolean)) if (await exists(p)) return readFile(p, 'utf8');
18
+ throw new Error('llms.txt not found: pass --llms <file>');
19
+ },
20
+ async operations() {
21
+ try { return JSON.parse(await readFile(opsFile, 'utf8')); } catch { throw new Error(`cannot read ${opsFile}: run \`xoong sync <openapi>\` first or pass --operations <file>`); }
22
+ },
23
+ docsDir: flag('--docs') ?? ((await exists('docs')) ? 'docs' : undefined),
24
+ });
25
+
26
+ const lines = createInterface({ input: process.stdin });
27
+ for await (const line of lines) {
28
+ if (!line.trim()) continue;
29
+ let msg;
30
+ try { msg = JSON.parse(line); } catch { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }) + '\n'); continue; }
31
+ const out = await server.handle(msg);
32
+ if (out) process.stdout.write(JSON.stringify(out) + '\n'); // stdout carries protocol messages only
33
+ }
package/src/server.js ADDED
@@ -0,0 +1,116 @@
1
+ // @ts-check
2
+ /**
3
+ * A small Model Context Protocol server (JSON-RPC 2.0, newline-delimited over stdio).
4
+ * No SDK: the protocol surface xoong needs is `initialize`, `tools/list` and `tools/call`.
5
+ *
6
+ * Tools (the loop an assistant follows: read the spec, write code, check, fix from the message):
7
+ * get_spec the whole llms.txt
8
+ * list_operations operations of the synced API (id, method, path, query, body fields)
9
+ * check run `xoong check` on HTML source text; returns structured findings
10
+ * explain_error what an XO code means and how to fix it
11
+ * search_docs find a term in the docs folder (when one is configured)
12
+ */
13
+ import { readFile, readdir } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+ import { checkHtml, checkScript } from '@xoong/cli/check';
16
+
17
+ export const PROTOCOL_VERSION = '2024-11-05';
18
+
19
+ /** Meaning and fix for every code `xoong check` and `xoong doctor` can print. Kept in step with check.js by a test. */
20
+ export const EXPLANATIONS = {
21
+ XO201: 'An operation name in xo-click, xo-submit, <xo-view get> or <xo-form op> does not exist in the synced API. Use the closest name the message gives, or run `xoong sync` if the API changed.',
22
+ XO202: 'xo-click calls a GET operation. Reads are declared with <xo-view get="...">; xo-click is for mutations.',
23
+ XO203: '<xo-view get> uses an operation that is not GET. Use a GET operation for reading and xo-click/xo-submit for writes.',
24
+ XO210: 'The view\'s operation has a path parameter such as {id}, but the view has no p-id attribute. Add p-id="..." or set view.params in JavaScript.',
25
+ XO222: '<form xo-submit> must be inside an <xo-view>, otherwise it never binds. Wrap it in <xo-view> (without get is fine).',
26
+ XO220: 'xo-submit is only valid on a <form>. Move the attribute to the form element.',
27
+ XO221: 'xo-each, xo-if or xo-body has no path. Give it one, for example xo-each="items".',
28
+ XO230: 'An xo-* element or attribute that xoong does not define, usually a typo. The message names the closest valid one.',
29
+ XO240: 'A button or link triggered by xo-click has no accessible name. Add visible text or aria-label.',
30
+ XO241: 'xo-click is on an element that keyboards cannot reach. Use a <button>, or add role="button" and tabindex="0".',
31
+ XO250: '<xo-table sortable> needs a `sort` query parameter on the view\'s operation, and page-size needs `limit` and `offset`. Add them to the API or remove the attribute.',
32
+ XO253: 'A filter box of <xo-table filters="..."> sets a query parameter of the same name, and the view\'s operation does not declare it. Add the parameter to the API or remove the filter.',
33
+ XO204: 'client.load()/query() were given a write operation, or client.mutate()/stream() a GET. Reads: load/query. Writes: mutate.',
34
+ XO254: 'xo-selected only makes sense on an xo-click button, and xo-select on a checkbox. Move the attribute.',
35
+ XO260: 'A route path must start with "/" (or be "*" for not found) and each path may be declared once per router. Write xo-route="/users/:id".',
36
+ XO261: 'layout="x" on a route (or layout) names a layout that is not declared in the same <xo-router>. Declare <template xo-layout="x">, or use the closest name the message gives.',
37
+ XO262: 'A layout template needs one element with xo-outlet where the route content is shown, for example <main xo-outlet>.',
38
+ XO263: 'p-x=":name" reads a route parameter: it only works inside a <template xo-route="..."> whose path contains :name. Fix the name, or use a fixed value or p-x="?query".',
39
+ XO255: 'This operation has no request schema (for example a multipart body), so <xo-form> can only draw simple fields, and the operation requires nested objects, arrays or unions: the generated form can never satisfy the server. Write the <form xo-submit> by hand.',
40
+ XO251: '<xo-table> must be inside an <xo-view get="...">, which supplies its data.',
41
+ XO252: '<xo-form op> found no simple request fields to generate. Write the <form xo-submit> by hand.',
42
+ };
43
+
44
+ const TOOLS = [
45
+ { name: 'get_spec', description: 'Return xoong\'s whole public API as one short document (llms.txt). Read it before writing xoong code.', inputSchema: { type: 'object', properties: {} } },
46
+ { name: 'list_operations', description: 'List the operations of the synced API: id, method, path, query parameters and request body fields. Use these exact names in xo-click, xo-submit and <xo-view get>.', inputSchema: { type: 'object', properties: { filter: { type: 'string', description: 'Only operations whose id or path contains this text.' } } } },
47
+ { name: 'check', description: 'Run `xoong check` on HTML source text and return findings with line, column, message and fix. Fix every error before showing code to the user.', inputSchema: { type: 'object', properties: { html: { type: 'string', description: 'The HTML source of a screen.' }, script: { type: 'string', description: 'JavaScript or TypeScript source instead of html: checks html`...` templates and client.load/query/mutate/stream calls.' } } } },
48
+ { name: 'explain_error', description: 'Explain an XO error code (for example XO201) and how to fix it.', inputSchema: { type: 'object', properties: { code: { type: 'string' } }, required: ['code'] } },
49
+ { name: 'search_docs', description: 'Search the xoong documentation for a term and return matching lines with file names.', inputSchema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } },
50
+ ];
51
+
52
+ /**
53
+ * @param {{ llms: () => Promise<string>, operations: () => Promise<{ operations: Record<string, any> }>, docsDir?: string }} ctx
54
+ */
55
+ export function createServer(ctx) {
56
+ const text = (t, isError = false) => ({ content: [{ type: 'text', text: t }], ...(isError ? { isError: true } : {}) });
57
+
58
+ /** @param {string} name @param {any} args */
59
+ async function callTool(name, args = {}) {
60
+ switch (name) {
61
+ case 'get_spec': return text(await ctx.llms());
62
+ case 'list_operations': {
63
+ const table = await ctx.operations();
64
+ const f = String(args.filter ?? '').toLowerCase();
65
+ const rows = Object.values(table.operations).filter((o) => !f || o.id.toLowerCase().includes(f) || o.path.toLowerCase().includes(f))
66
+ .map((o) => ({ id: o.id, call: `${o.method} ${o.path}`, ...(o.query ? { query: o.query } : {}), ...(o.bodyFields ? { bodyFields: o.bodyFields.map((b) => b.name + (b.required ? '*' : '')) } : {}), ...(o.stream ? { stream: true } : {}), ...(o.multipart ? { multipart: true } : {}) }));
67
+ return text(JSON.stringify(rows, null, 2));
68
+ }
69
+ case 'check': {
70
+ if (typeof args.html !== 'string' && typeof args.script !== 'string') return text('check needs an "html" or a "script" string', true);
71
+ const table = await ctx.operations();
72
+ const findings = typeof args.script === 'string' ? checkScript(args.script, table, 'screen.ts') : checkHtml(args.html, table, 'screen.html');
73
+ return text(findings.length ? JSON.stringify(findings, null, 2) : 'No findings.');
74
+ }
75
+ case 'explain_error': {
76
+ const code = String(args.code ?? '').toUpperCase();
77
+ return EXPLANATIONS[code] ? text(`${code}: ${EXPLANATIONS[code]}`) : text(`Unknown code "${args.code}". Known: ${Object.keys(EXPLANATIONS).join(', ')}. API audit codes (XO1xx) are explained by \`xoong doctor\`.`, true);
78
+ }
79
+ case 'search_docs': {
80
+ if (!ctx.docsDir) return text('No docs folder is configured (start the server with --docs <folder>).', true);
81
+ const q = String(args.query ?? '').toLowerCase();
82
+ if (!q) return text('search_docs needs a query', true);
83
+ const hits = [];
84
+ for (const file of (await readdir(ctx.docsDir)).filter((f) => f.endsWith('.md')).sort()) {
85
+ (await readFile(join(ctx.docsDir, file), 'utf8')).split('\n').forEach((line, i) => { if (line.toLowerCase().includes(q) && hits.length < 40) hits.push(`${file}:${i + 1}: ${line.trim()}`); });
86
+ }
87
+ return text(hits.length ? hits.join('\n') : `No match for "${args.query}".`);
88
+ }
89
+ default: return text(`Unknown tool "${name}"`, true);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Handle one JSON-RPC message. Returns the response object, or null for notifications.
95
+ * @param {any} msg
96
+ */
97
+ async function handle(msg) {
98
+ if (!msg || typeof msg !== 'object' || typeof msg.method !== 'string') return { jsonrpc: '2.0', id: msg?.id ?? null, error: { code: -32600, message: 'Invalid request' } };
99
+ if (msg.id === undefined) return null; // notification (initialized, cancelled, ...)
100
+ const reply = (result) => ({ jsonrpc: '2.0', id: msg.id, result });
101
+ const fail = (code, message) => ({ jsonrpc: '2.0', id: msg.id, error: { code, message } });
102
+ try {
103
+ switch (msg.method) {
104
+ case 'initialize': return reply({ protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: { name: 'xoong', version: '0.0.1' }, instructions: 'Call get_spec first. Write xoong screens, then call check on the HTML and fix every error from its message.' });
105
+ case 'ping': return reply({});
106
+ case 'tools/list': return reply({ tools: TOOLS });
107
+ case 'tools/call': return reply(await callTool(msg.params?.name, msg.params?.arguments));
108
+ default: return fail(-32601, `Method not found: ${msg.method}`);
109
+ }
110
+ } catch (e) {
111
+ return msg.method === 'tools/call' ? reply(text(`${e instanceof Error ? e.message : e}`, true)) : fail(-32603, String(e instanceof Error ? e.message : e));
112
+ }
113
+ }
114
+
115
+ return { handle, tools: TOOLS };
116
+ }