plopino 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 +21 -0
- package/README.md +118 -0
- package/index.js +100 -0
- package/package.json +35 -0
- package/upload.js +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Plopino
|
|
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
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# plopino
|
|
2
|
+
|
|
3
|
+
MCP server for [Plopino](https://plopino.com) — lets an AI agent publish what it just built
|
|
4
|
+
and hand back a public link, without the user touching a browser.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
agent writes index.html
|
|
8
|
+
↓ publish_html / publish_path
|
|
9
|
+
https://plopino.com/b/xxxxxxxx/
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Tools
|
|
13
|
+
|
|
14
|
+
| Tool | Use it when |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `publish_html` | You have the page as a string. The most direct path for generated HTML. |
|
|
17
|
+
| `publish_path` | The page needs sibling files (CSS, JS, images), or you are sharing something that is already on disk. Point it at a directory and the structure is preserved — no need to zip first. |
|
|
18
|
+
|
|
19
|
+
Both take an optional `update_url`: pass a link returned by an earlier publish and that page's
|
|
20
|
+
content is replaced **while the link stays the same**. This needs a token (see below).
|
|
21
|
+
|
|
22
|
+
Publishing is **anonymous by default**: no account, no configuration, no API key. The returned
|
|
23
|
+
link is public and permanent.
|
|
24
|
+
|
|
25
|
+
## Authentication (optional)
|
|
26
|
+
|
|
27
|
+
Set `PLOPINO_TOKEN` to publish to your account instead of anonymously. You get private
|
|
28
|
+
boards, updates that keep the same link, and you are not subject to the anonymous rate limit.
|
|
29
|
+
|
|
30
|
+
Create a token at **plopino.com/b** (it is shown once — the server only stores a hash), then:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
claude mcp add plopino --env PLOPINO_TOKEN=plp_xxx -- npx -y plopino
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Leaving it unset is a supported configuration, not a degraded one.** Most users never need
|
|
37
|
+
a token — the anonymous flow is the product.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
**Claude Code**
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
claude mcp add plopino -e PLOPINO_TOKEN=plp_xxx -- npx -y plopino
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**No npm?** The server also ships as a single self-contained file on the site. Two lines
|
|
49
|
+
instead of one, but nothing to install:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
curl -fsSL https://plopino.com/plopino-mcp.mjs -o "$HOME/.plopino-mcp.mjs"
|
|
53
|
+
claude mcp add plopino -e PLOPINO_TOKEN=plp_xxx -- node "$HOME/.plopino-mcp.mjs"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
> `"$HOME/…"` is expanded by your shell as you paste, so the absolute path is what gets
|
|
57
|
+
> registered. That matters: MCP clients spawn the server from an arbitrary working
|
|
58
|
+
> directory, so a relative path would break.
|
|
59
|
+
|
|
60
|
+
**Claude Desktop** — add to `claude_desktop_config.json`:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"mcpServers": {
|
|
65
|
+
"plopino": { "command": "npx", "args": ["-y", "plopino"] }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Cursor** — add to `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global):
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"mcpServers": {
|
|
75
|
+
"plopino": { "command": "npx", "args": ["-y", "plopino"] }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Then just ask: *"publish this to Plopino"*, or *"give me a link for that page"*.
|
|
81
|
+
|
|
82
|
+
## Self-hosted / local
|
|
83
|
+
|
|
84
|
+
Set `PLOPINO_BASE_URL` to point at another instance:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
PLOPINO_BASE_URL=http://127.0.0.1:8787 npx -y plopino
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Requirements
|
|
91
|
+
|
|
92
|
+
- Node 18 or newer (uses the built-in `fetch`, `FormData` and `Blob`).
|
|
93
|
+
- The only runtime dependency is the MCP SDK.
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
npm install
|
|
99
|
+
npm test # 路径展开的边界 + 协议握手/工具列表(不需要网络)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
To exercise the full publish path, run a Plopino instance locally and drive the server
|
|
103
|
+
over stdio:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
PLOPINO_BASE_URL=http://127.0.0.1:8787 node index.js
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
> **stdout is the MCP protocol channel.** Anything written there that is not a JSON-RPC
|
|
110
|
+
> message will break the client. All logging must go to stderr — hence `console.error`
|
|
111
|
+
> everywhere and never `console.log`.
|
|
112
|
+
|
|
113
|
+
## Notes
|
|
114
|
+
|
|
115
|
+
- `publish_path` walks directories recursively and **skips symbolic links** — following
|
|
116
|
+
them could escape the directory tree and upload files from elsewhere on the machine.
|
|
117
|
+
- Anonymous uploads are rate-limited per IP by the server side.
|
|
118
|
+
- Nothing is uploaded until a tool is called; the server does no network I/O at startup.
|
package/index.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Plopino MCP server(stdio)。
|
|
3
|
+
//
|
|
4
|
+
// 让 agent 自己把产出发布出去:用户说一句"发布这个",agent 调这里的工具,
|
|
5
|
+
// 拿回一个公开链接。用户不需要知道 plopino.com 怎么用——这正是它作为分发渠道的价值。
|
|
6
|
+
//
|
|
7
|
+
// 认证是**可选**的:不配 PLOPINO_TOKEN 就匿名发布(免注册是这个产品的卖点之一),
|
|
8
|
+
// 配了就发到账号下——可私有、可更新(同一个链接换内容)、不受匿名限流。
|
|
9
|
+
//
|
|
10
|
+
// ⚠️ stdout 是 MCP 协议通道,绝不能往里写日志。任何调试输出都走 stderr
|
|
11
|
+
//(console.error),否则会污染协议、让客户端解析失败。
|
|
12
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
13
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
import { publishFiles, filesFromHtml, filesFromPath, boardIdFromUrl, DEFAULT_BASE } from './upload.js';
|
|
16
|
+
|
|
17
|
+
// 自建实例或本地验证时覆盖。默认走正式站。
|
|
18
|
+
const BASE = process.env.PLOPINO_BASE_URL || DEFAULT_BASE;
|
|
19
|
+
// 在 plopino.com/b 生成(服务端只存哈希,明文只显示一次)
|
|
20
|
+
const TOKEN = (process.env.PLOPINO_TOKEN || '').trim();
|
|
21
|
+
|
|
22
|
+
const server = new McpServer({ name: 'plopino', version: '0.1.0' });
|
|
23
|
+
|
|
24
|
+
// 工具描述是给**模型**看的,不是给人看的——要写清楚「什么时候该用」,
|
|
25
|
+
// 否则模型不知道有这个能力,集成了也不会被调用。
|
|
26
|
+
const ok = (url, note) => ({ content: [{ type: 'text', text: `${url}\n\n${note}` }] });
|
|
27
|
+
const fail = (err) => ({
|
|
28
|
+
content: [{ type: 'text', text: `Could not publish: ${err?.message || err}` }],
|
|
29
|
+
isError: true,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const UPDATE_PARAM = z.string().optional().describe(
|
|
33
|
+
'A URL returned by an earlier publish. When given, the content of that page is replaced '
|
|
34
|
+
+ 'and the link stays the same. Requires the server to be configured with a Plopino token.',
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// update_url 给了就走更新。没有 token 时更新必被服务端拒(401),
|
|
38
|
+
// 与其让用户看到 authentication 报错,不如在这里说清楚缺什么。
|
|
39
|
+
function resolveTarget(updateUrl) {
|
|
40
|
+
if (!updateUrl) return { boardId: null };
|
|
41
|
+
if (!TOKEN) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
'Updating an existing page requires a Plopino token. '
|
|
44
|
+
+ 'Create one at plopino.com/b and set PLOPINO_TOKEN in this server\'s environment.',
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const boardId = boardIdFromUrl(updateUrl);
|
|
48
|
+
if (!boardId) throw new Error(`Not a Plopino page URL: ${updateUrl}`);
|
|
49
|
+
return { boardId };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
server.registerTool('publish_html', {
|
|
53
|
+
title: 'Publish an HTML page and get a link',
|
|
54
|
+
description:
|
|
55
|
+
'Publish an HTML page to a public URL. Use this whenever the user asks to share, send, '
|
|
56
|
+
+ 'publish, or "give me a link to" a page — for example a dashboard, report, chart, or '
|
|
57
|
+
+ 'interactive page you just generated. Returns a permanent public link that opens on any '
|
|
58
|
+
+ 'device. No account or configuration needed. Prefer this over telling the user to save '
|
|
59
|
+
+ 'the file and upload it somewhere themselves.',
|
|
60
|
+
inputSchema: {
|
|
61
|
+
html: z.string().describe('The complete HTML document to publish, including the <html> tag.'),
|
|
62
|
+
update_url: UPDATE_PARAM,
|
|
63
|
+
},
|
|
64
|
+
}, async ({ html, update_url: updateUrl }) => {
|
|
65
|
+
try {
|
|
66
|
+
const { boardId } = resolveTarget(updateUrl);
|
|
67
|
+
const data = await publishFiles(filesFromHtml(html), { baseUrl: BASE, token: TOKEN, boardId });
|
|
68
|
+
return ok(data.url, boardId
|
|
69
|
+
? 'Updated — the link is unchanged.'
|
|
70
|
+
: 'Public and permanent. Anyone with this link can open it.');
|
|
71
|
+
} catch (err) {
|
|
72
|
+
return fail(err);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
server.registerTool('publish_path', {
|
|
77
|
+
title: 'Publish a local file or folder and get a link',
|
|
78
|
+
description:
|
|
79
|
+
'Publish a local file, a zip, or a whole directory to a public URL, preserving the '
|
|
80
|
+
+ 'directory structure. Use this when the page needs sibling files (CSS, JS, images) — '
|
|
81
|
+
+ 'write them into a directory first, then publish that directory. Also use it for '
|
|
82
|
+
+ 'non-HTML files the user wants to share (Markdown, images, PDF, Office documents).',
|
|
83
|
+
inputSchema: {
|
|
84
|
+
path: z.string().describe('Absolute path to a file or directory on this machine.'),
|
|
85
|
+
update_url: UPDATE_PARAM,
|
|
86
|
+
},
|
|
87
|
+
}, async ({ path: p, update_url: updateUrl }) => {
|
|
88
|
+
try {
|
|
89
|
+
const { boardId } = resolveTarget(updateUrl);
|
|
90
|
+
const files = await filesFromPath(p);
|
|
91
|
+
const data = await publishFiles(files, { baseUrl: BASE, token: TOKEN, boardId });
|
|
92
|
+
return ok(data.url, boardId
|
|
93
|
+
? `Updated with ${files.length} file${files.length > 1 ? 's' : ''} — the link is unchanged.`
|
|
94
|
+
: `Published ${files.length} file${files.length > 1 ? 's' : ''}. Public and permanent.`);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return fail(err);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await server.connect(new StdioServerTransport());
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "plopino",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server: publish an HTML page, a file, or a folder to Plopino and get a public URL.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"plopino": "index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js",
|
|
11
|
+
"upload.js",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/AILogbook/boards.git",
|
|
22
|
+
"directory": "mcp"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
26
|
+
"zod": "^4.6.5"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "node --test",
|
|
30
|
+
"build": "esbuild index.js --bundle --platform=node --format=esm --target=node18 --outfile=dist/plopino-mcp.mjs --log-level=warning"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"esbuild": "^0.28.2"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/upload.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// 与 Plopino 上传接口的对接。只用 Node 内置的 fetch / FormData / Blob(Node 18+ 自带),
|
|
2
|
+
// 所以这个包除了 MCP SDK 之外没有别的运行时依赖。
|
|
3
|
+
|
|
4
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_BASE = 'https://plopino.com';
|
|
8
|
+
|
|
9
|
+
// 上传一组文件,返回展板 JSON(含链接)。
|
|
10
|
+
// files: [{ name, data }],name 是**相对路径**——接口按它还原目录结构,
|
|
11
|
+
// 所以目录上传不需要先打包成 zip。
|
|
12
|
+
//
|
|
13
|
+
// token 给了就走认证(展板归到账号下,可私有、可更新、不受匿名限流);
|
|
14
|
+
// 不给就匿名发布。**认证是可选的**——"不用注册"是这个产品的卖点之一,
|
|
15
|
+
// 不能因为接了 MCP 就把它变成必须品。
|
|
16
|
+
// boardId 给了就是**更新已有展板**(链接不变),只有认证用户能做。
|
|
17
|
+
export async function publishFiles(files, { baseUrl = DEFAULT_BASE, token = '', boardId = null, fetchImpl = fetch, signal } = {}) {
|
|
18
|
+
const form = new FormData();
|
|
19
|
+
for (const f of files) form.append('file', new Blob([f.data]), f.name);
|
|
20
|
+
const route = boardId
|
|
21
|
+
? `/api/boards/${encodeURIComponent(boardId)}/upload`
|
|
22
|
+
: '/api/boards/upload';
|
|
23
|
+
const res = await fetchImpl(`${baseUrl.replace(/\/+$/, '')}${route}`, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
|
26
|
+
body: form,
|
|
27
|
+
signal,
|
|
28
|
+
});
|
|
29
|
+
const text = await res.text();
|
|
30
|
+
let data = null;
|
|
31
|
+
try { data = JSON.parse(text); } catch { /* 非 JSON 响应(如反代错误页) */ }
|
|
32
|
+
// 新建时链接在顶层,更新时在 board 里——统一取一次,调用方不必知道这个差别
|
|
33
|
+
const url = data?.url || data?.board?.url;
|
|
34
|
+
if (!res.ok || !url) {
|
|
35
|
+
throw new Error(data?.error || `Upload failed (HTTP ${res.status})`);
|
|
36
|
+
}
|
|
37
|
+
return { ...data, url };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 从之前返回的链接里取出展板 id。agent 手里通常只有 URL、没有 id,
|
|
41
|
+
// 所以更新入口收 URL 比收 id 自然。服务端仍会校验归属,猜来的 id 只会 404。
|
|
42
|
+
export function boardIdFromUrl(url) {
|
|
43
|
+
const m = /\/b\/([A-Za-z0-9_-]+)/.exec(String(url ?? ''));
|
|
44
|
+
return m ? m[1] : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 一个 HTML 字符串 → 单文件展板。文件名固定 index.html,展板根链接才能直接渲染它。
|
|
48
|
+
export function filesFromHtml(html) {
|
|
49
|
+
return [{ name: 'index.html', data: Buffer.from(html, 'utf8') }];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 本地路径 → 文件列表。目录递归展开,name 取相对路径。
|
|
53
|
+
export async function filesFromPath(input) {
|
|
54
|
+
const abs = path.resolve(input);
|
|
55
|
+
const st = await stat(abs); // 路径不存在时抛出原始错误,比"上传失败"可诊断
|
|
56
|
+
if (st.isFile()) return [{ name: path.basename(abs), data: await readFile(abs) }];
|
|
57
|
+
if (!st.isDirectory()) throw new Error(`Not a file or directory: ${input}`);
|
|
58
|
+
|
|
59
|
+
const out = [];
|
|
60
|
+
async function walk(dir, prefix) {
|
|
61
|
+
for (const e of await readdir(dir, { withFileTypes: true })) {
|
|
62
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
63
|
+
const full = path.join(dir, e.name);
|
|
64
|
+
if (e.isDirectory()) await walk(full, rel);
|
|
65
|
+
// 符号链接直接跳过:跟随可能绕出目录树,上传过去也没有意义
|
|
66
|
+
else if (e.isFile()) out.push({ name: rel, data: await readFile(full) });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
await walk(abs, '');
|
|
70
|
+
if (!out.length) throw new Error(`Directory is empty: ${input}`);
|
|
71
|
+
return out;
|
|
72
|
+
}
|