fmrl-mcp 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 +51 -0
- package/dist/api.js +91 -0
- package/dist/config.js +18 -0
- package/dist/credentials.js +42 -0
- package/dist/format.js +22 -0
- package/dist/ids.js +21 -0
- package/dist/index.js +20 -0
- package/dist/keys.js +56 -0
- package/dist/server.js +159 -0
- package/package.json +28 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Too Great LLC
|
|
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,51 @@
|
|
|
1
|
+
# fmrl
|
|
2
|
+
|
|
3
|
+
Share anything your AI made. `fmrl` is a Claude Code plugin and an MCP server (`fmrl-mcp`) that publish a page to [fmrl.site](https://fmrl.site) and hand back a link. No account; the page lasts seven days unless someone keeps it from the page itself.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Claude Code:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
/plugin marketplace add toogreatwtf/fmrl-plugin
|
|
11
|
+
/plugin install fmrl@fmrl-plugin
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Any MCP client (Claude Desktop, Cursor, Codex, Windsurf, and the rest):
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{ "mcpServers": { "fmrl": { "command": "npx", "args": ["-y", "fmrl-mcp"] } } }
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
No key step. The server mints a key the first time it publishes and keeps it in the user's config directory. `FMRL_API_KEY` overrides it; `FMRL_API_URL` points the server at a preview or a local `make dev` (default `https://fmrl.site`).
|
|
21
|
+
|
|
22
|
+
curl, for people who want neither:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
curl -sX POST https://fmrl.site/api/v1/keys
|
|
26
|
+
curl -sX POST https://fmrl.site/api/v1/publish \
|
|
27
|
+
-H "Authorization: Bearer fmrl_…" -H "Content-Type: application/json" \
|
|
28
|
+
-d '{"format":"md","content":"# hello"}'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## What you get
|
|
32
|
+
|
|
33
|
+
In Claude Code, `/fmrl:share` publishes what is at hand — a file you named or something the agent composed — and replies with the link, the expiry, and a manage link that removes the page.
|
|
34
|
+
|
|
35
|
+
Any MCP client gets five tools:
|
|
36
|
+
|
|
37
|
+
| Tool | Does |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `fmrl_publish` | `content`, optional `format` (`html` or `md`; detected when left out), optional `title` → the page's URL, expiry and manage link |
|
|
40
|
+
| `fmrl_publish_file` | `path` to a `.html`, `.htm`, `.md`, `.markdown`, `.mdx` or `.txt` file (2 MiB at most), optional `title` |
|
|
41
|
+
| `fmrl_get` | an id or a viewer URL → status, format, size, expiry, whether it was kept |
|
|
42
|
+
| `fmrl_delete` | an id or a viewer URL → removes a page this key published |
|
|
43
|
+
| `fmrl_whoami` | the key's prefix and this month's quota |
|
|
44
|
+
|
|
45
|
+
## Keys and limits
|
|
46
|
+
|
|
47
|
+
The server mints a key on first use and stores it in `~/.config/fmrl/credentials.json` (`$XDG_CONFIG_HOME/fmrl/credentials.json`; `%APPDATA%\fmrl\credentials.json` on Windows), one key per API base URL, file mode 0600. `FMRL_API_KEY` overrides the file. 25 publishes a month per key, 5 keys a day per network, 2 MiB per page. Content is subject to fmrl.site's [acceptable use policy](https://fmrl.site/aup). Full API reference: [marky.md/api](https://marky.md/api).
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
MIT, Too Great LLC.
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** ApiError is any non-2xx answer: the contract's code and message, plus resets_at on a 402 and retryAfterSeconds on a 429. A network failure (no response at all) is status 0, code "network". */
|
|
2
|
+
export class ApiError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
code;
|
|
5
|
+
resetsAt;
|
|
6
|
+
retryAfterSeconds;
|
|
7
|
+
constructor(status, code, message, resetsAt, retryAfterSeconds) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.resetsAt = resetsAt;
|
|
12
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
13
|
+
this.name = "ApiError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** FmrlApi is the thin HTTP client for /api/v1. It knows nothing about keys on disk. */
|
|
17
|
+
export class FmrlApi {
|
|
18
|
+
root;
|
|
19
|
+
fetchImpl;
|
|
20
|
+
timeoutMs;
|
|
21
|
+
extraHeaders;
|
|
22
|
+
constructor(baseUrl, opts = {}) {
|
|
23
|
+
this.root = baseUrl.replace(/\/+$/, "") + "/api/v1";
|
|
24
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
25
|
+
this.timeoutMs = opts.timeoutMs ?? 30000;
|
|
26
|
+
this.extraHeaders = opts.headers ?? {};
|
|
27
|
+
}
|
|
28
|
+
/** viewerBase is the host pages live on: the configured base URL without /api/v1. */
|
|
29
|
+
get viewerBase() {
|
|
30
|
+
return this.root.slice(0, -"/api/v1".length);
|
|
31
|
+
}
|
|
32
|
+
mint(label) {
|
|
33
|
+
return this.call("POST", "/keys", undefined, { label });
|
|
34
|
+
}
|
|
35
|
+
publish(key, body) {
|
|
36
|
+
const payload = { content: body.content };
|
|
37
|
+
if (body.format !== undefined)
|
|
38
|
+
payload.format = body.format;
|
|
39
|
+
if (body.title !== undefined)
|
|
40
|
+
payload.title = body.title;
|
|
41
|
+
return this.call("POST", "/publish", key, payload);
|
|
42
|
+
}
|
|
43
|
+
get(key, id) {
|
|
44
|
+
return this.call("GET", `/docs/${encodeURIComponent(id)}`, key);
|
|
45
|
+
}
|
|
46
|
+
async delete(key, id) {
|
|
47
|
+
await this.call("DELETE", `/docs/${encodeURIComponent(id)}`, key);
|
|
48
|
+
}
|
|
49
|
+
me(key) {
|
|
50
|
+
return this.call("GET", "/me", key);
|
|
51
|
+
}
|
|
52
|
+
async call(method, path, key, body) {
|
|
53
|
+
const headers = { Accept: "application/json", ...this.extraHeaders };
|
|
54
|
+
if (key)
|
|
55
|
+
headers.Authorization = `Bearer ${key}`;
|
|
56
|
+
if (body !== undefined)
|
|
57
|
+
headers["Content-Type"] = "application/json";
|
|
58
|
+
let res;
|
|
59
|
+
try {
|
|
60
|
+
res = await this.fetchImpl(this.root + path, {
|
|
61
|
+
method,
|
|
62
|
+
headers,
|
|
63
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
64
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
if (e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError")) {
|
|
69
|
+
throw new ApiError(0, "timeout", `No answer from ${this.root} within ${this.timeoutMs / 1000}s.`);
|
|
70
|
+
}
|
|
71
|
+
const cause = e.cause?.message ?? (e instanceof Error ? e.message : String(e));
|
|
72
|
+
throw new ApiError(0, "network", `Couldn't reach ${this.root}: ${cause}`);
|
|
73
|
+
}
|
|
74
|
+
const text = await res.text();
|
|
75
|
+
if (res.status === 204)
|
|
76
|
+
return undefined;
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = text ? JSON.parse(text) : undefined;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
parsed = undefined;
|
|
83
|
+
}
|
|
84
|
+
if (res.ok)
|
|
85
|
+
return parsed;
|
|
86
|
+
const err = parsed?.error;
|
|
87
|
+
const retryAfterHeader = res.headers.get("retry-after");
|
|
88
|
+
const retryAfterSeconds = retryAfterHeader !== null && /^\d+$/.test(retryAfterHeader) ? parseInt(retryAfterHeader, 10) : undefined;
|
|
89
|
+
throw new ApiError(res.status, err?.code ?? `http_${res.status}`, err?.message ?? `${method} ${path} answered ${res.status}.`, err?.resets_at, retryAfterSeconds);
|
|
90
|
+
}
|
|
91
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const DEFAULT_BASE_URL = "https://fmrl.site";
|
|
2
|
+
/** loadConfig reads FMRL_API_URL and FMRL_API_KEY; the client appends /api/v1 to baseUrl. */
|
|
3
|
+
export function loadConfig(env = process.env) {
|
|
4
|
+
const rawUrl = (env.FMRL_API_URL ?? "").trim();
|
|
5
|
+
const baseUrl = (rawUrl === "" ? DEFAULT_BASE_URL : rawUrl).replace(/\/+$/, "");
|
|
6
|
+
let parsed;
|
|
7
|
+
try {
|
|
8
|
+
parsed = new URL(baseUrl);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
throw new Error(`FMRL_API_URL must be an http(s) URL, got ${JSON.stringify(rawUrl)}`);
|
|
12
|
+
}
|
|
13
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
14
|
+
throw new Error(`FMRL_API_URL must be an http(s) URL, got ${JSON.stringify(rawUrl)}`);
|
|
15
|
+
}
|
|
16
|
+
const rawKey = (env.FMRL_API_KEY ?? "").trim();
|
|
17
|
+
return { baseUrl, apiKey: rawKey === "" ? undefined : rawKey };
|
|
18
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
const EMPTY = { version: 1, keys: {} };
|
|
6
|
+
/**
|
|
7
|
+
* credentialsPath is $XDG_CONFIG_HOME/fmrl/credentials.json (default
|
|
8
|
+
* ~/.config) on macOS and Linux, and %APPDATA%\fmrl\credentials.json on
|
|
9
|
+
* Windows. Keyed by API base URL inside, so one file serves every host.
|
|
10
|
+
*/
|
|
11
|
+
export function credentialsPath(env = process.env, platform = process.platform, homedir = os.homedir) {
|
|
12
|
+
if (platform === "win32") {
|
|
13
|
+
const base = (env.APPDATA ?? "").trim() || path.win32.join(homedir(), "AppData", "Roaming");
|
|
14
|
+
return path.win32.join(base, "fmrl", "credentials.json");
|
|
15
|
+
}
|
|
16
|
+
const xdg = (env.XDG_CONFIG_HOME ?? "").trim();
|
|
17
|
+
const base = xdg !== "" ? xdg : path.join(homedir(), ".config");
|
|
18
|
+
return path.join(base, "fmrl", "credentials.json");
|
|
19
|
+
}
|
|
20
|
+
/** readCredentials treats a missing, unreadable, or malformed file as empty. */
|
|
21
|
+
export async function readCredentials(file) {
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
24
|
+
if (parsed && parsed.version === 1 && parsed.keys && typeof parsed.keys === "object") {
|
|
25
|
+
return { version: 1, keys: { ...parsed.keys } };
|
|
26
|
+
}
|
|
27
|
+
return { ...EMPTY, keys: {} };
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return { ...EMPTY, keys: {} };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** writeCredentials writes to a sibling temp file with mode 0600 and renames it into place. */
|
|
34
|
+
export async function writeCredentials(file, data) {
|
|
35
|
+
const dir = path.dirname(file);
|
|
36
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
37
|
+
if (process.platform !== "win32")
|
|
38
|
+
await chmod(dir, 0o700);
|
|
39
|
+
const tmp = path.join(dir, `.credentials.json.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
40
|
+
await writeFile(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
|
41
|
+
await rename(tmp, file);
|
|
42
|
+
}
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
export const ACCEPTED_EXTENSIONS = [".html", ".htm", ".md", ".markdown", ".mdx", ".txt"];
|
|
3
|
+
export const MAX_BYTES = 2 * 1024 * 1024;
|
|
4
|
+
export const TOO_LARGE_MESSAGE = "That's bigger than the 2 MiB limit.";
|
|
5
|
+
const BY_EXTENSION = {
|
|
6
|
+
".html": "html",
|
|
7
|
+
".htm": "html",
|
|
8
|
+
".md": "md",
|
|
9
|
+
".markdown": "md",
|
|
10
|
+
".mdx": "md",
|
|
11
|
+
".txt": "md",
|
|
12
|
+
};
|
|
13
|
+
/** formatForPath maps a file's extension to the format hint, or throws naming the accepted extensions. */
|
|
14
|
+
export function formatForPath(p) {
|
|
15
|
+
const base = p.split(/[\\/]/).pop() ?? "";
|
|
16
|
+
const ext = path.extname(base).toLowerCase();
|
|
17
|
+
const format = ext === "" ? undefined : BY_EXTENSION[ext];
|
|
18
|
+
if (!format) {
|
|
19
|
+
throw new Error(`fmrl_publish_file accepts ${ACCEPTED_EXTENSIONS.join(", ")} files; ${base || p} is not one. Use fmrl_publish with the content instead.`);
|
|
20
|
+
}
|
|
21
|
+
return format;
|
|
22
|
+
}
|
package/dist/ids.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const ID = /^[0-9abcdefghjkmnpqrstvwxyz]{12}$/;
|
|
2
|
+
/** parseDocId accepts a 12-character document id or any fmrl URL that contains one as a path segment. */
|
|
3
|
+
export function parseDocId(input) {
|
|
4
|
+
const s = input.trim();
|
|
5
|
+
if (ID.test(s))
|
|
6
|
+
return s;
|
|
7
|
+
let url;
|
|
8
|
+
try {
|
|
9
|
+
url = new URL(s);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
url = undefined;
|
|
13
|
+
}
|
|
14
|
+
if (url) {
|
|
15
|
+
for (const seg of url.pathname.split("/")) {
|
|
16
|
+
if (ID.test(seg))
|
|
17
|
+
return seg;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
throw new Error(`${JSON.stringify(input)} is not a document id or a fmrl.site URL.`);
|
|
21
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { FmrlApi } from "./api.js";
|
|
4
|
+
import { loadConfig } from "./config.js";
|
|
5
|
+
import { credentialsPath } from "./credentials.js";
|
|
6
|
+
import { KeyStore } from "./keys.js";
|
|
7
|
+
import { createServer } from "./server.js";
|
|
8
|
+
// stdout is the JSON-RPC channel; everything we say goes to stderr.
|
|
9
|
+
const log = (line) => process.stderr.write(line + "\n");
|
|
10
|
+
async function main() {
|
|
11
|
+
const cfg = loadConfig();
|
|
12
|
+
const api = new FmrlApi(cfg.baseUrl);
|
|
13
|
+
const keys = new KeyStore({ api, baseUrl: cfg.baseUrl, apiKeyFromEnv: cfg.apiKey, file: credentialsPath(), log });
|
|
14
|
+
const server = createServer({ api, keys });
|
|
15
|
+
await server.connect(new StdioServerTransport());
|
|
16
|
+
}
|
|
17
|
+
main().catch((e) => {
|
|
18
|
+
log(`fmrl-mcp: ${e instanceof Error ? e.message : String(e)}`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
});
|
package/dist/keys.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { ApiError } from "./api.js";
|
|
2
|
+
import { readCredentials, writeCredentials } from "./credentials.js";
|
|
3
|
+
const LABEL = "fmrl-mcp";
|
|
4
|
+
/**
|
|
5
|
+
* KeyStore resolves the key every call uses: FMRL_API_KEY when set, else the
|
|
6
|
+
* credentials file's entry for this base URL, else a freshly minted key that
|
|
7
|
+
* is saved for next time. A stored key that answers 401 (revoked, or a
|
|
8
|
+
* preview whose memory store restarted) is replaced once and the call
|
|
9
|
+
* retried; a key from the environment is never replaced.
|
|
10
|
+
*/
|
|
11
|
+
export class KeyStore {
|
|
12
|
+
o;
|
|
13
|
+
cached;
|
|
14
|
+
pending;
|
|
15
|
+
constructor(o) {
|
|
16
|
+
this.o = o;
|
|
17
|
+
}
|
|
18
|
+
async getKey() {
|
|
19
|
+
if (this.o.apiKeyFromEnv)
|
|
20
|
+
return this.o.apiKeyFromEnv;
|
|
21
|
+
if (this.cached)
|
|
22
|
+
return this.cached;
|
|
23
|
+
const stored = (await readCredentials(this.o.file)).keys[this.o.baseUrl];
|
|
24
|
+
if (stored?.key) {
|
|
25
|
+
this.cached = stored.key;
|
|
26
|
+
return stored.key;
|
|
27
|
+
}
|
|
28
|
+
return this.mintOnce();
|
|
29
|
+
}
|
|
30
|
+
async withKey(fn) {
|
|
31
|
+
const key = await this.getKey();
|
|
32
|
+
try {
|
|
33
|
+
return await fn(key);
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
if (e instanceof ApiError && e.status === 401 && !this.o.apiKeyFromEnv) {
|
|
37
|
+
const fresh = await this.mintOnce();
|
|
38
|
+
return fn(fresh);
|
|
39
|
+
}
|
|
40
|
+
throw e;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** mintOnce collapses concurrent mint calls into a single in-flight request. */
|
|
44
|
+
mintOnce() {
|
|
45
|
+
return (this.pending ??= this.mint().finally(() => { this.pending = undefined; }));
|
|
46
|
+
}
|
|
47
|
+
async mint() {
|
|
48
|
+
const minted = await this.o.api.mint(LABEL);
|
|
49
|
+
const file = await readCredentials(this.o.file);
|
|
50
|
+
file.keys[this.o.baseUrl] = { key: minted.key, prefix: minted.prefix, created_at: minted.created_at };
|
|
51
|
+
await writeCredentials(this.o.file, file);
|
|
52
|
+
this.cached = minted.key;
|
|
53
|
+
this.o.log?.(`fmrl-mcp: minted key ${minted.prefix}… for ${this.o.baseUrl}, saved to ${this.o.file}`);
|
|
54
|
+
return minted.key;
|
|
55
|
+
}
|
|
56
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { open as fsOpen, stat as fsStat } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { ApiError } from "./api.js";
|
|
7
|
+
import { MAX_BYTES, TOO_LARGE_MESSAGE, formatForPath } from "./format.js";
|
|
8
|
+
import { parseDocId } from "./ids.js";
|
|
9
|
+
export const SEVEN_DAYS = "This page lasts seven days unless someone keeps it on the page itself.";
|
|
10
|
+
function ok(text, structured) {
|
|
11
|
+
return { content: [{ type: "text", text }], structuredContent: structured };
|
|
12
|
+
}
|
|
13
|
+
function fail(message) {
|
|
14
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
15
|
+
}
|
|
16
|
+
function errorText(e) {
|
|
17
|
+
if (e instanceof ApiError) {
|
|
18
|
+
let msg = e.resetsAt && !e.message.includes(e.resetsAt) ? `${e.message} Resets at ${e.resetsAt}.` : e.message;
|
|
19
|
+
if (e.status === 429 && e.retryAfterSeconds) {
|
|
20
|
+
msg += e.retryAfterSeconds < 120
|
|
21
|
+
? ` Try again in ${e.retryAfterSeconds} seconds.`
|
|
22
|
+
: ` Try again in ${Math.ceil(e.retryAfterSeconds / 60)} minutes.`;
|
|
23
|
+
}
|
|
24
|
+
return msg;
|
|
25
|
+
}
|
|
26
|
+
return e instanceof Error ? e.message : String(e);
|
|
27
|
+
}
|
|
28
|
+
function publishText(p) {
|
|
29
|
+
return [
|
|
30
|
+
`Published: ${p.url}`,
|
|
31
|
+
`Expires ${p.expires_at}`,
|
|
32
|
+
SEVEN_DAYS,
|
|
33
|
+
`Manage link (removes the page; give it only to someone who should be able to): ${p.manage_url}`,
|
|
34
|
+
].join("\n");
|
|
35
|
+
}
|
|
36
|
+
function docText(d) {
|
|
37
|
+
const kept = d.pinned ? `kept forever${d.cid ? ` (${d.cid})` : ""}` : `expires ${d.expires_at}`;
|
|
38
|
+
return [`${d.url}: ${d.status}, ${d.format}, ${d.size} bytes, ${kept}.`, SEVEN_DAYS].join("\n");
|
|
39
|
+
}
|
|
40
|
+
function meText(m) {
|
|
41
|
+
const q = m.quota.publishes;
|
|
42
|
+
return [`${m.prefix}…: ${q.used} of ${q.limit} publishes used this month, resets ${q.resets_at}.`, SEVEN_DAYS].join("\n");
|
|
43
|
+
}
|
|
44
|
+
const publishOutput = {
|
|
45
|
+
id: z.string(), url: z.string(), raw_url: z.string(), manage_url: z.string(), expires_at: z.string(), status: z.string(),
|
|
46
|
+
};
|
|
47
|
+
const docOutput = {
|
|
48
|
+
id: z.string(), url: z.string(), status: z.string(), format: z.string(), size: z.number(),
|
|
49
|
+
expires_at: z.string().nullable(), pinned: z.boolean(), cid: z.string().optional(),
|
|
50
|
+
};
|
|
51
|
+
const meOutput = {
|
|
52
|
+
prefix: z.string(), created_at: z.string(),
|
|
53
|
+
quota: z.object({ publishes: z.object({ used: z.number(), limit: z.number(), resets_at: z.string() }) }),
|
|
54
|
+
};
|
|
55
|
+
/** createServer registers the five contract tools on an McpServer. deps.keys owns the key; deps.api speaks HTTP. */
|
|
56
|
+
export function createServer(deps) {
|
|
57
|
+
const { api, keys } = deps;
|
|
58
|
+
const open = deps.open ?? fsOpen;
|
|
59
|
+
const stat = deps.stat ?? fsStat;
|
|
60
|
+
const server = new McpServer({ name: "fmrl", version: "0.1.0" });
|
|
61
|
+
const run = async (fn, render) => {
|
|
62
|
+
try {
|
|
63
|
+
const v = await keys.withKey(fn);
|
|
64
|
+
return ok(render(v), v);
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
return fail(errorText(e));
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
server.registerTool("fmrl_publish", {
|
|
71
|
+
title: "Publish a page to fmrl.site",
|
|
72
|
+
description: "Publish HTML or Markdown as a page on fmrl.site and get its link. The page lasts seven days unless someone keeps it from the page itself. Only call this when the user asked to share, send, or get a link for something.",
|
|
73
|
+
inputSchema: {
|
|
74
|
+
content: z.string().min(1).describe("The HTML or Markdown to publish (2 MiB at most)."),
|
|
75
|
+
format: z.enum(["html", "md"]).optional().describe("html or md; leave out to let the server detect it."),
|
|
76
|
+
title: z.string().optional().describe("Page title; the first heading is used when left out."),
|
|
77
|
+
},
|
|
78
|
+
outputSchema: publishOutput,
|
|
79
|
+
}, async ({ content, format, title }) => run((k) => api.publish(k, { content, format, title }), publishText));
|
|
80
|
+
server.registerTool("fmrl_publish_file", {
|
|
81
|
+
title: "Publish a file to fmrl.site",
|
|
82
|
+
description: "Publish a .html, .htm, .md, .markdown, .mdx or .txt file (2 MiB at most) as a page on fmrl.site and get its link. Only call this when the user asked to share the file.",
|
|
83
|
+
inputSchema: {
|
|
84
|
+
path: z.string().min(1).describe("Absolute path to the file (a leading ~ is expanded)."),
|
|
85
|
+
title: z.string().optional().describe("Page title; the file's first heading is used when left out."),
|
|
86
|
+
},
|
|
87
|
+
outputSchema: publishOutput,
|
|
88
|
+
}, async ({ path: p, title }) => {
|
|
89
|
+
let format;
|
|
90
|
+
let content;
|
|
91
|
+
try {
|
|
92
|
+
const expanded = p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p;
|
|
93
|
+
const resolved = path.resolve(expanded);
|
|
94
|
+
format = formatForPath(resolved);
|
|
95
|
+
const info = await stat(resolved);
|
|
96
|
+
if (info.size > MAX_BYTES)
|
|
97
|
+
return fail(TOO_LARGE_MESSAGE);
|
|
98
|
+
const buf = Buffer.alloc(MAX_BYTES + 1);
|
|
99
|
+
let bytesRead;
|
|
100
|
+
const handle = await open(resolved, "r");
|
|
101
|
+
try {
|
|
102
|
+
({ bytesRead } = await handle.read(buf, 0, MAX_BYTES + 1, 0));
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
await handle.close();
|
|
106
|
+
}
|
|
107
|
+
if (bytesRead > MAX_BYTES)
|
|
108
|
+
return fail(TOO_LARGE_MESSAGE);
|
|
109
|
+
try {
|
|
110
|
+
content = new TextDecoder("utf-8", { fatal: true }).decode(buf.subarray(0, bytesRead));
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return fail("That file isn't UTF-8 text.");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
return fail(errorText(e));
|
|
118
|
+
}
|
|
119
|
+
return run((k) => api.publish(k, { content, format, title }), publishText);
|
|
120
|
+
});
|
|
121
|
+
server.registerTool("fmrl_get", {
|
|
122
|
+
title: "Describe a fmrl.site page",
|
|
123
|
+
description: "Look up a page by id or URL: status, format, size, expiry, and whether it has been kept.",
|
|
124
|
+
inputSchema: { id: z.string().min(1).describe("A document id or any fmrl.site URL for it.") },
|
|
125
|
+
outputSchema: docOutput,
|
|
126
|
+
}, async ({ id }) => {
|
|
127
|
+
let docId;
|
|
128
|
+
try {
|
|
129
|
+
docId = parseDocId(id);
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
return fail(errorText(e));
|
|
133
|
+
}
|
|
134
|
+
return run((k) => api.get(k, docId), docText);
|
|
135
|
+
});
|
|
136
|
+
server.registerTool("fmrl_delete", {
|
|
137
|
+
title: "Remove a fmrl.site page",
|
|
138
|
+
description: "Remove a page this key published, by id or URL. The page answers 410 from then on. Only call this when the user asked to remove it.",
|
|
139
|
+
inputSchema: { id: z.string().min(1).describe("A document id or any fmrl.site URL for it.") },
|
|
140
|
+
outputSchema: { id: z.string(), url: z.string(), removed: z.boolean() },
|
|
141
|
+
}, async ({ id }) => {
|
|
142
|
+
let docId;
|
|
143
|
+
try {
|
|
144
|
+
docId = parseDocId(id);
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
return fail(errorText(e));
|
|
148
|
+
}
|
|
149
|
+
const url = `${api.viewerBase}/${docId}`;
|
|
150
|
+
return run(async (k) => { await api.delete(k, docId); return { id: docId, url, removed: true }; }, (v) => `Removed page ${v.id}. It answers 410 from now on.\n${SEVEN_DAYS}`);
|
|
151
|
+
});
|
|
152
|
+
server.registerTool("fmrl_whoami", {
|
|
153
|
+
title: "This fmrl.site key",
|
|
154
|
+
description: "The key's prefix and how many of this month's free publishes it has used.",
|
|
155
|
+
inputSchema: {},
|
|
156
|
+
outputSchema: meOutput,
|
|
157
|
+
}, async () => run((k) => api.me(k), meText));
|
|
158
|
+
return server;
|
|
159
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fmrl-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for fmrl.site: publish a page from any agent and get a link.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": { "fmrl-mcp": "dist/index.js" },
|
|
8
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
9
|
+
"engines": { "node": ">=20" },
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc -p tsconfig.json",
|
|
12
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"prepublishOnly": "npm run build && npm test"
|
|
15
|
+
},
|
|
16
|
+
"repository": { "type": "git", "url": "https://github.com/toogreatwtf/fmrl-plugin", "directory": "packages/mcp" },
|
|
17
|
+
"homepage": "https://fmrl.site/install",
|
|
18
|
+
"keywords": ["mcp", "fmrl", "share", "publish"],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
21
|
+
"zod": "^3.25.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^20.0.0",
|
|
25
|
+
"typescript": "^5.9.0",
|
|
26
|
+
"vitest": "^3.0.0"
|
|
27
|
+
}
|
|
28
|
+
}
|