kodza-mcp 0.0.0-stage → 0.1.0
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 +39 -0
- package/README.md +142 -2
- package/http.js +97 -0
- package/index.js +29 -0
- package/kodza.js +234 -0
- package/package.json +32 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Kodza License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 fcwebdesign (Kodza)
|
|
4
|
+
|
|
5
|
+
Kodza components are original works. Their code (the HTML, CSS and JavaScript of
|
|
6
|
+
each component, the React version Kodza provides, and what your AI receives through
|
|
7
|
+
the Kodza MCP server) is free to use.
|
|
8
|
+
|
|
9
|
+
You may:
|
|
10
|
+
- use them in any number of projects, personal or commercial, including client work;
|
|
11
|
+
- copy, modify and adapt them, and combine them with your own code;
|
|
12
|
+
- ship them as part of a website, an application, a theme, a template or a plugin
|
|
13
|
+
you build, as long as that product offers more than the components themselves;
|
|
14
|
+
- install and run the Kodza MCP server, and let an AI assistant fetch the
|
|
15
|
+
components and add them to your project;
|
|
16
|
+
- keep them in your own or your team's snippets;
|
|
17
|
+
- show a component or two in a free article, tutorial or code playground,
|
|
18
|
+
with a link to Kodza.
|
|
19
|
+
No attribution is required in your projects. A link back is always appreciated.
|
|
20
|
+
|
|
21
|
+
You may not:
|
|
22
|
+
- sell, sublicense or redistribute the components themselves, alone, in a bundle
|
|
23
|
+
or as a ported version (React, Vue, Svelte...), Kodza's own React version
|
|
24
|
+
included, free or paid, modified or not;
|
|
25
|
+
- offer them through another catalogue, registry, marketplace or MCP server;
|
|
26
|
+
- make them the core of a paid course, membership or tutorial series;
|
|
27
|
+
- repackage them with superficial changes, or present them as your own work;
|
|
28
|
+
- use the Kodza name and logo to promote something that is not Kodza.
|
|
29
|
+
|
|
30
|
+
Not covered by this license: the Kodza website and its design, the demo pages and
|
|
31
|
+
their copy, the preview images and videos, the house images in /media (placeholders:
|
|
32
|
+
swap them for your own before you ship), and the Kodza name and logo.
|
|
33
|
+
All rights reserved.
|
|
34
|
+
|
|
35
|
+
Copies that break these terms will be reported and taken down.
|
|
36
|
+
|
|
37
|
+
THE COMPONENTS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
38
|
+
IMPLIED. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
39
|
+
LIABILITY ARISING FROM THE COMPONENTS OR THEIR USE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,143 @@
|
|
|
1
|
-
#
|
|
1
|
+
# kodza-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An MCP server for [Kodza](https://kodza.dev): free, premium UI components and effects in
|
|
4
|
+
plain HTML, CSS and vanilla JavaScript. Your AI assistant finds the component, gets its exact code
|
|
5
|
+
and knows how to wire it into a plain page, WordPress, Shopify or React. No framework, no build
|
|
6
|
+
step, no API key.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
Kodza runs at `https://kodza.dev/api/mcp`. Nothing to install: give your assistant the address.
|
|
11
|
+
|
|
12
|
+
**Claude Code**
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
claude mcp add -t http kodza https://kodza.dev/api/mcp
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**Codex** (the CLI, the IDE extension and the app share `~/.codex/config.toml`)
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
codex mcp add kodza --url https://kodza.dev/api/mcp
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Cursor** (`.cursor/mcp.json` for one project, `~/.cursor/mcp.json` for all)
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"mcpServers": {
|
|
29
|
+
"kodza": { "url": "https://kodza.dev/api/mcp" }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
From a project file, the server starts switched off: turn it on in *Customize → MCPs*.
|
|
35
|
+
|
|
36
|
+
**VS Code**
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
code --add-mcp '{"name":"kodza","type":"http","url":"https://kodza.dev/api/mcp"}'
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
That one is for every project. For one project only, in `.vscode/mcp.json`:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"servers": {
|
|
47
|
+
"kodza": { "type": "http", "url": "https://kodza.dev/api/mcp" }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Run it locally
|
|
53
|
+
|
|
54
|
+
Rather keep the search on your machine, or using a client that only starts local servers (like
|
|
55
|
+
Claude Desktop's `claude_desktop_config.json`)? The same server runs through npx (Node 18 or newer):
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"mcpServers": {
|
|
60
|
+
"kodza": { "command": "npx", "args": ["-y", "kodza-mcp"] }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
In a terminal: `claude mcp add kodza -- npx -y kodza-mcp`, `codex mcp add kodza -- npx -y kodza-mcp`.
|
|
66
|
+
|
|
67
|
+
**Server won't start?** `spawn npx ENOENT`, or `No such file or directory` in VS Code: the editor
|
|
68
|
+
can't find your Node. Give `command` the full path from `which npx`, and the server
|
|
69
|
+
`"env": { "PATH": "<that folder>:/usr/bin:/bin" }`: `npx` needs `node` right next to it.
|
|
70
|
+
|
|
71
|
+
### Then ask
|
|
72
|
+
|
|
73
|
+
Ask for what you need: *"add a button that fills from the cursor"*, *"a sticky header that
|
|
74
|
+
hides while I read, in my WordPress theme"*, *"a date picker for this form"*.
|
|
75
|
+
|
|
76
|
+
## Tools
|
|
77
|
+
|
|
78
|
+
| Tool | What it does |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `search_components` | Finds components by what they do or look like (`query`), or lists a `category`. Returns slugs. |
|
|
81
|
+
| `get_component` | One component: description, API (CSS variables and their defaults, modifier classes, `data-` attributes, events), what each key does (for the components that take the keyboard), its dark-mode values, how to install it (`target`: `html`, `artifact` for a Claude artifact, `wordpress`, `shopify`, `react`), its files and the full code. `inline: false` leaves the CSS and JS out of the code, for an assistant that downloads the files. |
|
|
82
|
+
|
|
83
|
+
## Exact files, not retyped code
|
|
84
|
+
|
|
85
|
+
An assistant that can run commands should not retype a component: a CSS with its theme guard
|
|
86
|
+
runs to several hundred lines, retyping it costs thousands of tokens and can drop or change a
|
|
87
|
+
line. Each component's CSS, JS and HTML are served as files, byte for byte what the Kodza site
|
|
88
|
+
copies, and `get_component` gives the commands:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
curl -fsSL --create-dirs -o kodza/kodza-drawer.css https://kodza.dev/registry/drawer/kodza-drawer.css
|
|
92
|
+
curl -fsSL --create-dirs -o kodza/kodza-drawer.js https://kodza.dev/registry/drawer/kodza-drawer.js
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The install notes then load those files (a `<link>` and a `<script defer>`, `wp_enqueue_style`
|
|
96
|
+
and `wp_enqueue_script`, Shopify `assets/`, Next.js `public/`). Only the HTML is pasted and
|
|
97
|
+
adapted. The code stays inline by default, for assistants that can't run anything (a chat, a
|
|
98
|
+
Claude artifact); `inline: false` drops the CSS and JS blocks to save the reading too.
|
|
99
|
+
|
|
100
|
+
## How Kodza components behave
|
|
101
|
+
|
|
102
|
+
- Three pieces, pasted as they are: HTML, CSS, and often a small vanilla JS file.
|
|
103
|
+
- The CSS only targets `kodza-` classes: it never touches the rest of the page or the theme.
|
|
104
|
+
- Restyle with the `--kz-…` variables, the modifier classes and the `data-` attributes.
|
|
105
|
+
- Dark mode: every component comes with its values for a dark background, ready for
|
|
106
|
+
`@media (prefers-color-scheme: dark)` or your own dark selector.
|
|
107
|
+
- They take the page's font. The Kodza look is Geist (Google Fonts).
|
|
108
|
+
- Any number of instances per page; one copy of the JS serves them all.
|
|
109
|
+
- Content added after the page load (AJAX, SPA, a builder preview):
|
|
110
|
+
`document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }))` starts it,
|
|
111
|
+
`'kodza:destroy'` stops it and puts the original markup back.
|
|
112
|
+
- `prefers-reduced-motion`, keyboard and WCAG AA are checked on every component.
|
|
113
|
+
|
|
114
|
+
## What Kodza counts
|
|
115
|
+
|
|
116
|
+
Kodza counts which components AI assistants fetch, to know what is actually used: the component,
|
|
117
|
+
the assistant's name (as it gives it when connecting, like `claude-code` or `cursor-vscode`) and
|
|
118
|
+
the target, when one was asked for (`html`, `artifact`, `wordpress`, `shopify`, `react`). That's
|
|
119
|
+
all.
|
|
120
|
+
|
|
121
|
+
Through kodza.dev/api/mcp, your search words reach the server to run the search, and are thrown
|
|
122
|
+
away with the answer: never stored, never logged. Your files and your code stay on your machine:
|
|
123
|
+
the tools only take a few words and a slug. With the local server (npx), the search itself runs on
|
|
124
|
+
your machine.
|
|
125
|
+
|
|
126
|
+
## Options
|
|
127
|
+
|
|
128
|
+
`KODZA_REGISTRY` points the local server at another copy of the catalogue: a URL, or a local folder
|
|
129
|
+
that contains `registry/` and `components/` (the library's build output). Default:
|
|
130
|
+
`https://kodza.dev`. The download commands then point at that copy too (`file://` for a folder).
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
KODZA_REGISTRY=http://localhost:5180 npx -y kodza-mcp
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Hosting your own copy: `http.js` exports `handle(request, { source })`, which takes a web
|
|
137
|
+
`Request` and returns its `Response` (the server is stateless, with no dependency).
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
The Kodza License, like the rest of Kodza: use the components in any project, personal,
|
|
142
|
+
client or commercial, no attribution required; don't resell or redistribute them as such.
|
|
143
|
+
Full text in [LICENSE](LICENSE).
|
package/http.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Kodza MCP server, the hosted one (kodza.dev/api/mcp): the same tools as the local server
|
|
2
|
+
// (kodza.js), over Streamable HTTP, without state and without the MCP SDK. Each POST carries one
|
|
3
|
+
// JSON-RPC message (or a batch) and gets its answer as plain JSON; there is no event stream to
|
|
4
|
+
// open (GET answers 405), no session to keep, nothing for the user to install.
|
|
5
|
+
//
|
|
6
|
+
// The assistant's name, given once when it connects, comes back to it inside its session id: the
|
|
7
|
+
// server keeps nothing, and later calls still say who asks (README, "What Kodza counts"). Nothing
|
|
8
|
+
// the assistant sends is stored or logged here.
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { INSTRUCTIONS, TOOLS, VERSION, registry } from './kodza.js';
|
|
11
|
+
|
|
12
|
+
// The protocol versions it speaks, newest first: these two tools travel the same way in each
|
|
13
|
+
const PROTOCOLS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
|
|
14
|
+
|
|
15
|
+
// Assistants that run in a browser (an inspector, a web app) call it from another origin
|
|
16
|
+
const CORS = {
|
|
17
|
+
'access-control-allow-origin': '*',
|
|
18
|
+
'access-control-allow-methods': 'GET, POST, DELETE, OPTIONS',
|
|
19
|
+
'access-control-allow-headers': 'content-type, accept, authorization, mcp-session-id, mcp-protocol-version, last-event-id',
|
|
20
|
+
'access-control-expose-headers': 'mcp-session-id',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const reply = (status, body, extra = {}) =>
|
|
24
|
+
new Response(body === undefined ? null : JSON.stringify(body), {
|
|
25
|
+
status,
|
|
26
|
+
headers: { ...CORS, ...(body !== undefined && { 'content-type': 'application/json' }), ...extra },
|
|
27
|
+
});
|
|
28
|
+
const ok = (id, result) => ({ jsonrpc: '2.0', id, result });
|
|
29
|
+
const failure = (id, code, message) => ({ jsonrpc: '2.0', id: id ?? null, error: { code, message } });
|
|
30
|
+
|
|
31
|
+
// The session id: random, then the assistant's name (ASCII, in base64url)
|
|
32
|
+
const ascii = (s) => String(s || '').replace(/[^\x20-\x7e]/g, '').slice(0, 64);
|
|
33
|
+
const sessionFor = (client) => `${randomUUID()}${client ? `.${Buffer.from(client).toString('base64url')}` : ''}`;
|
|
34
|
+
const clientOf = (session) => {
|
|
35
|
+
const encoded = String(session || '').split('.')[1];
|
|
36
|
+
return encoded ? ascii(Buffer.from(encoded, 'base64url').toString()) : '';
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// One HTTP request (a web Request, as Vercel and Node give it) to its Response
|
|
40
|
+
export async function handle(request, { source = 'https://kodza.dev' } = {}) {
|
|
41
|
+
if (request.method === 'OPTIONS') return reply(204);
|
|
42
|
+
if (request.method !== 'POST') {
|
|
43
|
+
return reply(405, failure(null, -32000, 'Method not allowed: this server answers POST only.'), { allow: 'POST, OPTIONS' });
|
|
44
|
+
}
|
|
45
|
+
let input;
|
|
46
|
+
try {
|
|
47
|
+
input = JSON.parse(await request.text());
|
|
48
|
+
} catch {
|
|
49
|
+
return reply(400, failure(null, -32700, 'Parse error'));
|
|
50
|
+
}
|
|
51
|
+
const batch = Array.isArray(input);
|
|
52
|
+
const messages = batch ? input : [input];
|
|
53
|
+
if (!messages.length) return reply(400, failure(null, -32600, 'Invalid request'));
|
|
54
|
+
|
|
55
|
+
const kodza = registry(source);
|
|
56
|
+
let client = clientOf(request.headers.get('mcp-session-id'));
|
|
57
|
+
let session = null;
|
|
58
|
+
|
|
59
|
+
async function answer({ id, method, params }) {
|
|
60
|
+
const p = params && typeof params === 'object' ? params : {};
|
|
61
|
+
switch (method) {
|
|
62
|
+
case 'initialize': {
|
|
63
|
+
client = ascii(p.clientInfo?.name);
|
|
64
|
+
session = sessionFor(client);
|
|
65
|
+
return ok(id, {
|
|
66
|
+
protocolVersion: PROTOCOLS.includes(p.protocolVersion) ? p.protocolVersion : PROTOCOLS[0],
|
|
67
|
+
capabilities: { tools: { listChanged: false } },
|
|
68
|
+
serverInfo: { name: 'kodza', title: 'Kodza', version: VERSION },
|
|
69
|
+
instructions: INSTRUCTIONS,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
case 'ping':
|
|
73
|
+
return ok(id, {});
|
|
74
|
+
case 'tools/list':
|
|
75
|
+
return ok(id, { tools: TOOLS });
|
|
76
|
+
case 'tools/call': {
|
|
77
|
+
const result = await kodza.call(p.name, p.arguments, client);
|
|
78
|
+
return result ? ok(id, result) : failure(id, -32602, `Unknown tool: ${p.name}`);
|
|
79
|
+
}
|
|
80
|
+
default:
|
|
81
|
+
return failure(id, -32601, `Method not found: ${method}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const answers = [];
|
|
86
|
+
for (const message of messages) {
|
|
87
|
+
if (!message || typeof message !== 'object' || message.jsonrpc !== '2.0') {
|
|
88
|
+
answers.push(failure(message?.id, -32600, 'Invalid request'));
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// Notifications, and the client's own responses, get no answer
|
|
92
|
+
if (typeof message.method !== 'string' || message.id === undefined) continue;
|
|
93
|
+
answers.push(await answer(message));
|
|
94
|
+
}
|
|
95
|
+
if (!answers.length) return reply(202);
|
|
96
|
+
return reply(200, batch ? answers : answers[0], session ? { 'mcp-session-id': session } : {});
|
|
97
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Kodza MCP server, the local one: lets an AI coding assistant find a Kodza component and get its
|
|
3
|
+
// exact code, its API (CSS variables, modifiers, data attributes) and how to wire it into a plain
|
|
4
|
+
// HTML page, WordPress, Shopify or React. Run by npx, it talks to the assistant over stdio; the
|
|
5
|
+
// search runs on the user's machine. The tools themselves are in kodza.js, shared with the hosted
|
|
6
|
+
// server (http.js).
|
|
7
|
+
//
|
|
8
|
+
// KODZA_REGISTRY points it at another copy of the catalogue: a URL, or a local folder (handy while
|
|
9
|
+
// working on the library).
|
|
10
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
11
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
12
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
13
|
+
import { INSTRUCTIONS, TOOLS, VERSION, registry } from './kodza.js';
|
|
14
|
+
|
|
15
|
+
const kodza = registry(process.env.KODZA_REGISTRY || 'https://kodza.dev');
|
|
16
|
+
|
|
17
|
+
const server = new Server(
|
|
18
|
+
{ name: 'kodza', title: 'Kodza', version: VERSION },
|
|
19
|
+
{ capabilities: { tools: {} }, instructions: INSTRUCTIONS }
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: TOOLS }));
|
|
23
|
+
|
|
24
|
+
server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
|
|
25
|
+
const result = await kodza.call(params.name, params.arguments, server.getClientVersion()?.name);
|
|
26
|
+
return result || { content: [{ type: 'text', text: `Unknown tool "${params.name}".` }], isError: true };
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
await server.connect(new StdioServerTransport());
|
package/kodza.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// The Kodza tools, shared by the two servers: index.js (stdio, run by npx on the user's machine)
|
|
2
|
+
// and http.js (Streamable HTTP, hosted on kodza.dev). No dependency: the hosted one runs without
|
|
3
|
+
// the MCP SDK.
|
|
4
|
+
//
|
|
5
|
+
// The data is the static registry published with the site (registry/index.json, one
|
|
6
|
+
// registry/<slug>.json and one components/<slug>.md per component, and the code to copy as
|
|
7
|
+
// files in registry/<slug>/). The source is its URL, or a local folder (handy while working on
|
|
8
|
+
// the library).
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { pathToFileURL } from 'node:url';
|
|
12
|
+
|
|
13
|
+
export const VERSION = '0.1.0';
|
|
14
|
+
export const TARGETS = ['html', 'artifact', 'wordpress', 'shopify', 'react'];
|
|
15
|
+
const INDEX_TTL = 30 * 60 * 1000;
|
|
16
|
+
// Where a missing component is requested and voted for (a public repo, no code)
|
|
17
|
+
const REQUEST_URL = 'https://github.com/kodza-dev/requests/discussions/new?category=requests';
|
|
18
|
+
|
|
19
|
+
export const INSTRUCTIONS = `Kodza is a free library of premium UI components and effects in plain HTML, CSS and vanilla JavaScript (no framework, no build step), made to be pasted into any site: a plain HTML page, WordPress, Shopify, or a React app.
|
|
20
|
+
Use it when the user wants a polished UI element or effect (buttons, links, text effects, sliders and galleries, cards, navigation, scroll effects, preloaders and page transitions, forms, overlays, feedback, data display) without adding a framework: search_components to find one, then get_component for its exact code.
|
|
21
|
+
Rules for the code you get: if you can run shell commands, download the CSS and JS files with the commands of the component's Files section (byte for byte what the Kodza site copies) instead of retyping them, and call get_component with inline: false to leave them out of the answer; only the HTML is yours to adapt and paste. Without a shell, paste the code as it is (HTML, CSS, JS), including the generated theme guard at the end of the CSS (the :not(#_) lines keep the site's own CSS from restyling buttons and fields); restyle through its --kz-* CSS variables, modifier classes and data- attributes rather than by editing its CSS; one copy of the JS per page serves every instance; for content inserted after page load, dispatch kodza:init on document with { detail: { root } } (and kodza:destroy before removing it).
|
|
22
|
+
Components take the page's font; the Kodza look is Geist (Google Fonts). Each component comes with its dark-mode values (the "Dark mode" section): use them when the page has a dark theme. Building a Claude artifact (claude.ai)? Ask get_component with target "artifact": the artifact blocks images from other sites, among other limits.`;
|
|
23
|
+
|
|
24
|
+
export const TOOLS = [
|
|
25
|
+
{
|
|
26
|
+
name: 'search_components',
|
|
27
|
+
title: 'Search Kodza components',
|
|
28
|
+
description: 'Find Kodza UI components (plain HTML/CSS/vanilla JS) by what they do or look like, e.g. "button fill hover", "image slider", "sticky header", "date picker". Leave query empty to list a whole category. Returns slugs to pass to get_component.',
|
|
29
|
+
inputSchema: {
|
|
30
|
+
type: 'object',
|
|
31
|
+
properties: {
|
|
32
|
+
query: { type: 'string', description: 'Words describing the component or effect, in English' },
|
|
33
|
+
category: { type: 'string', description: 'buttons, links, text, images, cards, navigation, scroll, transitions, forms, overlays, feedback or data' },
|
|
34
|
+
limit: { type: 'integer', minimum: 1, maximum: 50, description: 'How many results, 12 by default' },
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'get_component',
|
|
41
|
+
title: 'Get a Kodza component',
|
|
42
|
+
description: 'The full code of one Kodza component (HTML, CSS and, when it has one, vanilla JS), its API (CSS variables with defaults, modifier classes, data attributes, events), what each key does when it takes the keyboard, its dark-mode values and how to install it. Its CSS and JS are also served as exact files, with the curl commands to download them: if you can run shell commands, download them instead of retyping the code, and pass inline: false to leave them out of the answer. Give target to get only the instructions for that stack.',
|
|
43
|
+
inputSchema: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: {
|
|
46
|
+
slug: { type: 'string', description: 'The component slug, from search_components (e.g. "button-fill")' },
|
|
47
|
+
target: { type: 'string', enum: TARGETS, description: 'Where it goes: html (a plain page), artifact (a Claude artifact on claude.ai), wordpress, shopify or react. All of them when omitted' },
|
|
48
|
+
inline: { type: 'boolean', description: 'false: leave the CSS and JS out of the Code section (you download those files with the given commands). true by default, for assistants that cannot run commands' },
|
|
49
|
+
},
|
|
50
|
+
required: ['slug'],
|
|
51
|
+
},
|
|
52
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
// The arguments against the tool's schema: the message of the first mistake, or null
|
|
57
|
+
function invalid(tool, args) {
|
|
58
|
+
const { properties, required = [] } = tool.inputSchema;
|
|
59
|
+
for (const key of required) if (args[key] === undefined) return `${key} is required`;
|
|
60
|
+
for (const [key, value] of Object.entries(args)) {
|
|
61
|
+
const spec = properties[key];
|
|
62
|
+
if (!spec || value === undefined) continue;
|
|
63
|
+
const type = spec.type === 'integer' ? Number.isInteger(value) && 'integer' : typeof value;
|
|
64
|
+
if (type !== spec.type) return `${key} must be ${spec.type === 'integer' ? 'an' : 'a'} ${spec.type}`;
|
|
65
|
+
if (spec.enum && !spec.enum.includes(value)) return `${key} must be one of ${spec.enum.join(', ')}`;
|
|
66
|
+
if (value < spec.minimum || value > spec.maximum) return `${key} must be between ${spec.minimum} and ${spec.maximum}`;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// What the Kodza site counts (README, "What Kodza counts"): the assistant's name, as its client
|
|
72
|
+
// gives it when connecting, and the target asked for. Never the query, never the user's code
|
|
73
|
+
const ascii = (s) => String(s || '').replace(/[^\x20-\x7e]/g, '').slice(0, 64);
|
|
74
|
+
const headers = (client, target) => ({
|
|
75
|
+
'user-agent': `kodza-mcp/${VERSION}`,
|
|
76
|
+
...(ascii(client) && { 'x-kodza-client': ascii(client) }),
|
|
77
|
+
...(target && { 'x-kodza-target': target }),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------- one registry
|
|
81
|
+
|
|
82
|
+
const registries = new Map();
|
|
83
|
+
|
|
84
|
+
// The tools on one source, kept (with its index) for the life of the process
|
|
85
|
+
export function registry(source) {
|
|
86
|
+
const key = String(source).replace(/\/$/, '');
|
|
87
|
+
if (!registries.has(key)) registries.set(key, open(key));
|
|
88
|
+
return registries.get(key);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function open(source) {
|
|
92
|
+
const remote = /^https?:\/\//.test(source);
|
|
93
|
+
// Where the component files are downloaded from: the source itself (a local folder as file://)
|
|
94
|
+
const filesBase = remote ? source : pathToFileURL(path.resolve(source)).href;
|
|
95
|
+
let index = null;
|
|
96
|
+
let loadedAt = 0;
|
|
97
|
+
|
|
98
|
+
async function read(file, client, target) {
|
|
99
|
+
if (remote) {
|
|
100
|
+
const res = await fetch(`${source}/${file}`, { headers: headers(client, target) });
|
|
101
|
+
if (res.status === 404) return null;
|
|
102
|
+
if (!res.ok) throw new Error(`Kodza registry: ${res.status} on ${file}`);
|
|
103
|
+
return res.text();
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
return await fs.readFile(path.join(source, file), 'utf8');
|
|
107
|
+
} catch (err) {
|
|
108
|
+
if (err.code === 'ENOENT') return null;
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function getIndex(client) {
|
|
114
|
+
if (!index || Date.now() - loadedAt > INDEX_TTL) {
|
|
115
|
+
const text = await read('registry/index.json', client);
|
|
116
|
+
try {
|
|
117
|
+
index = JSON.parse(text);
|
|
118
|
+
} catch {
|
|
119
|
+
throw new Error(`No Kodza registry at ${source}`);
|
|
120
|
+
}
|
|
121
|
+
loadedAt = Date.now();
|
|
122
|
+
}
|
|
123
|
+
return index;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function search({ query = '', category, limit = 12 }, client) {
|
|
127
|
+
const { components, categories } = await getIndex(client);
|
|
128
|
+
if (category && !categories.some((c) => c.id === category)) {
|
|
129
|
+
return `Unknown category "${category}". Categories: ${categories.map((c) => `${c.id} (${c.count})`).join(', ')}.`;
|
|
130
|
+
}
|
|
131
|
+
const pool = components.filter((c) => !category || c.category === category);
|
|
132
|
+
const terms = queryTerms(query);
|
|
133
|
+
const found = terms.length
|
|
134
|
+
? pool.map((c) => ({ c, s: score(c, terms) })).filter((r) => r.s > 0).sort((a, b) => b.s - a.s).map((r) => r.c)
|
|
135
|
+
: pool;
|
|
136
|
+
if (!found.length) {
|
|
137
|
+
return `Nothing in Kodza matches "${query}"${category ? ` in ${category}` : ''}. Try fewer or broader words, or browse a category: ${categories.map((c) => c.id).join(', ')}.\n\nStill missing? The user can request it, and vote for it, here: ${REQUEST_URL}`;
|
|
138
|
+
}
|
|
139
|
+
const shown = found.slice(0, Math.max(1, Math.min(limit, 50)));
|
|
140
|
+
return [
|
|
141
|
+
`${found.length} Kodza component${found.length > 1 ? 's' : ''}${terms.length ? ` for "${query}"` : ''}${category ? ` in ${category}` : ''}${found.length > shown.length ? ` (first ${shown.length})` : ''}:`,
|
|
142
|
+
'',
|
|
143
|
+
...shown.map(line),
|
|
144
|
+
'',
|
|
145
|
+
'Next: get_component with a slug for the code, the API and how to install it.',
|
|
146
|
+
].join('\n');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function getComponent({ slug, target, inline = true }, client) {
|
|
150
|
+
const clean = String(slug).trim().toLowerCase().replace(/^\/+|\/+$/g, '').replace(/^components\//, '');
|
|
151
|
+
// The index decides what exists (a host may answer any path with its home page)
|
|
152
|
+
const { components, site } = await getIndex(client);
|
|
153
|
+
let markdown = components.some((c) => c.slug === clean) ? await read(`components/${clean}.md`, client, target) : null;
|
|
154
|
+
if (markdown?.startsWith('# ')) {
|
|
155
|
+
// Files from the registry actually read, not from the public site
|
|
156
|
+
const from = `${String(site || '').replace(/\/$/, '')}/registry/${clean}/`;
|
|
157
|
+
if (site && !from.startsWith(`${filesBase}/`)) markdown = markdown.replaceAll(from, `${filesBase}/registry/${clean}/`);
|
|
158
|
+
return forTarget(inline ? markdown : withoutFileCode(markdown), target);
|
|
159
|
+
}
|
|
160
|
+
const hint = await search({ query: clean.replace(/[-_]/g, ' '), limit: 5 }, client);
|
|
161
|
+
return `No Kodza component "${slug}".\n\n${hint}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const run = { search_components: search, get_component: getComponent };
|
|
165
|
+
|
|
166
|
+
// One tools/call: its result, or null for a tool Kodza doesn't have. A wrong argument or a
|
|
167
|
+
// registry out of reach is a tool error the assistant can read, with nothing it sent in it
|
|
168
|
+
async function call(name, args, client) {
|
|
169
|
+
const tool = TOOLS.find((t) => t.name === name);
|
|
170
|
+
if (!tool) return null;
|
|
171
|
+
const given = args && typeof args === 'object' && !Array.isArray(args) ? args : {};
|
|
172
|
+
const wrong = invalid(tool, given);
|
|
173
|
+
if (wrong) return { content: [{ type: 'text', text: `Invalid arguments for ${name}: ${wrong}.` }], isError: true };
|
|
174
|
+
try {
|
|
175
|
+
return { content: [{ type: 'text', text: await run[name](given, client) }] };
|
|
176
|
+
} catch (err) {
|
|
177
|
+
const known = /^(Kodza registry|No Kodza registry)/.test(err.message);
|
|
178
|
+
return { content: [{ type: 'text', text: `${known ? err.message : 'Kodza registry out of reach'}. Try again in a moment.` }], isError: true };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { call };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------- search
|
|
186
|
+
|
|
187
|
+
const STOP = new Set('a an the and or of for to in on with my our your some any that this it is be like nice cool simple component components effect effects ui kodza'.split(' '));
|
|
188
|
+
// Lowercase words, without accents, cut to a rough stem (loader, loading, loads → load)
|
|
189
|
+
const stem = (w) => (w.length > 4 ? w.replace(/(ings?|ers?|ed|es|s)$/, '') : w);
|
|
190
|
+
const words = (s) => s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').split(/[^a-z0-9]+/).filter(Boolean).map(stem);
|
|
191
|
+
const queryTerms = (s) => s.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 1 && !STOP.has(w)).map(stem);
|
|
192
|
+
|
|
193
|
+
// How well a component answers the query: words matched first, then where they matched
|
|
194
|
+
function score(component, terms) {
|
|
195
|
+
const title = words(component.title);
|
|
196
|
+
const slug = words(component.slug);
|
|
197
|
+
const tags = component.tags.flatMap(words);
|
|
198
|
+
const description = words(component.description);
|
|
199
|
+
const hit = (list, term) => list.some((w) => w === term || (term.length > 3 && (w.startsWith(term) || term.startsWith(w) && w.length > 3)));
|
|
200
|
+
let matched = 0;
|
|
201
|
+
let points = 0;
|
|
202
|
+
for (const term of terms) {
|
|
203
|
+
const p = (hit(title, term) ? 6 : 0) + (hit(slug, term) ? 3 : 0) + (hit(tags, term) ? 4 : 0) + (hit(description, term) ? 1 : 0) + (component.category === term ? 2 : 0);
|
|
204
|
+
if (p) matched++;
|
|
205
|
+
points += p;
|
|
206
|
+
}
|
|
207
|
+
// At least half of the words have to match somewhere
|
|
208
|
+
return matched * 2 >= terms.length ? matched * 100 + points : 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const line = (c) => `- \`${c.slug}\` — **${c.title}** (${c.category}${c.js ? '' : ', CSS only'}): ${c.description}`;
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------- one component
|
|
214
|
+
|
|
215
|
+
// Keep only the integration notes for the target the assistant is working with
|
|
216
|
+
function forTarget(markdown, target) {
|
|
217
|
+
if (!target) return markdown;
|
|
218
|
+
// An artifact is a plain page with its own limits: it gets both sections
|
|
219
|
+
const labels = { html: ['Plain HTML page'], artifact: ['Plain HTML page', 'Claude artifact'], wordpress: ['WordPress'], shopify: ['Shopify'], react: ['React / Next.js'] };
|
|
220
|
+
return markdown.replace(/## Use it\n([\s\S]*?)(?=\n## Code)/, (block) => {
|
|
221
|
+
const keep = block.split(/\n(?=### )/).filter((part) => !part.startsWith('### ') || labels[target].some((label) => part.startsWith(`### ${label}`)));
|
|
222
|
+
return keep.join('\n');
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Without the CSS and JS blocks of the Code section: the assistant downloads those files
|
|
227
|
+
function withoutFileCode(markdown) {
|
|
228
|
+
const at = markdown.indexOf('\n## Code\n');
|
|
229
|
+
if (at < 0 || !markdown.includes('\n## Files\n')) return markdown;
|
|
230
|
+
const left = [];
|
|
231
|
+
const code = markdown.slice(at).replace(/\n```(css|js)\n[\s\S]*?\n```\n/g, (block, lang) => (left.push(lang.toUpperCase()), ''));
|
|
232
|
+
const note = `\nThe ${left.join(' and the ')} ${left.length > 1 ? 'are' : 'is'} left out here (inline: false): download ${left.length > 1 ? 'them' : 'it'} with the commands of the Files section. The HTML below is the part to adapt and paste.\n`;
|
|
233
|
+
return markdown.slice(0, at) + code.replace('\n## Code\n', `\n## Code\n${note}`);
|
|
234
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kodza-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for Kodza: free premium UI components in plain HTML, CSS and vanilla JS, ready for AI coding assistants.",
|
|
5
|
+
"mcpName": "dev.kodza/kodza",
|
|
6
|
+
"homepage": "https://kodza.dev/docs/",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"kodza-mcp": "index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"kodza.js",
|
|
14
|
+
"http.js",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
22
|
+
"keywords": [
|
|
23
|
+
"mcp",
|
|
24
|
+
"ui",
|
|
25
|
+
"components",
|
|
26
|
+
"vanilla",
|
|
27
|
+
"css",
|
|
28
|
+
"wordpress"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
32
|
+
"zod": "^4.6.7"
|
|
33
|
+
}
|
|
34
|
+
}
|