protocontent 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/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # protocontent
2
+
3
+ A co-located **stdio MCP server** that lets a coding agent publish local files to
4
+ [protocontent](https://protocontent.com) and get back a tappable, live-updating URL.
5
+
6
+ Because no MCP primitive lets a remote server read the client's disk (and Claude
7
+ Code doesn't echo the HTTP `Mcp-Session-Id`), protocontent runs as a tiny bridge
8
+ *next to the agent* — on your laptop or on a cloud agent VM. It reads the files you
9
+ want to publish from the local filesystem and uploads them to the protocontent HTTP
10
+ API, while holding a stable per-thread "space" id in memory.
11
+
12
+ Zero-config: on first run it mints an anonymous project token and caches it to
13
+ `~/.protocontent/config.json`.
14
+
15
+ ## Install / add to your agent
16
+
17
+ It runs straight from npx — no global install needed.
18
+
19
+ ### Claude Code
20
+
21
+ ```bash
22
+ claude mcp add protocontent -- npx -y protocontent
23
+ ```
24
+
25
+ To pass through a token or a custom API base:
26
+
27
+ ```bash
28
+ claude mcp add protocontent \
29
+ --env PROTOCONTENT_TOKEN=pc_live_xxx \
30
+ --env PROTOCONTENT_API=https://api.protocontent.com \
31
+ -- npx -y protocontent
32
+ ```
33
+
34
+ ### Raw MCP server JSON config
35
+
36
+ For any client that takes an MCP server config block (Claude Desktop, Cursor, etc.):
37
+
38
+ ```json
39
+ {
40
+ "mcpServers": {
41
+ "protocontent": {
42
+ "command": "npx",
43
+ "args": ["-y", "protocontent"],
44
+ "env": {
45
+ "PROTOCONTENT_TOKEN": "pc_live_xxx",
46
+ "PROTOCONTENT_API": "https://api.protocontent.com"
47
+ }
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ Both `env` entries are optional. Omit `PROTOCONTENT_TOKEN` to use a cached or
54
+ freshly-minted anonymous token; omit `PROTOCONTENT_API` to use the default
55
+ (`https://api.protocontent.com`).
56
+
57
+ This works exactly the same on a **cloud agent VM** — the bridge just needs to run in
58
+ the same place the files live, with network access to the protocontent API.
59
+
60
+ ## Configuration
61
+
62
+ | Env var | Default | Purpose |
63
+ | --------------------- | -------------------------------- | -------------------------------------------------------------- |
64
+ | `PROTOCONTENT_API` | `https://api.protocontent.com` | Base URL of the protocontent HTTP API. |
65
+ | `PROTOCONTENT_TOKEN` | _(cached / minted)_ | Bearer token. Falls back to `~/.protocontent/config.json`, then to a freshly-minted anonymous project token. |
66
+ | `CLAUDE_SESSION_ID` | _(unset → random)_ | When set, deterministically seeds this process's space id so it lines up with the agent thread. |
67
+
68
+ ### Spaces
69
+
70
+ Each running bridge process owns one **space** — a DNS-safe id like
71
+ `amber-canyon-7f3` — generated once at startup and held in memory for the process
72
+ lifetime. When `CLAUDE_SESSION_ID` is present the id is derived deterministically
73
+ from it; otherwise it's random. A `spaceLabel` is derived from the current working
74
+ directory's basename. Everything you publish in a session lands in the same space,
75
+ served at `https://<spaceId>.protocontent.com`, which updates live.
76
+
77
+ ## Tools
78
+
79
+ | Tool | Arguments | What it does |
80
+ | ---------------- | ------------------------------------------ | --------------------------------------------------------------------------- |
81
+ | `publish_html` | `{ path?, content?, name?, ttl? }` | Publish a single file (`path`) or inline `content` — exactly one of them. |
82
+ | `publish_folder` | `{ dir, entry?, name?, ttl? }` | Recursively publish a directory (skips `node_modules`, `.git`, dotfiles). |
83
+ | `list` | `{}` | List artifacts in this space with URLs and versions. |
84
+ | `history` | `{ name }` | Show an artifact's version history. |
85
+ | `unpublish` | `{ name }` | Stop an artifact's URL from serving content. |
86
+ | `keep` | `{ name }` | Remove the expiry so an artifact is kept permanently. |
87
+
88
+ Every publish returns a tappable markdown link, the live space URL, and the direct
89
+ artifact URL.
90
+
91
+ ## Develop
92
+
93
+ ```bash
94
+ npm install
95
+ npm run build # tsc -> dist/
96
+ node dist/index.js # run the stdio server directly (it talks MCP over stdio)
97
+ ```
98
+
99
+ Requires Node 18+ (uses the built-in global `fetch`).
100
+
101
+ ## License
102
+
103
+ MIT
package/dist/api.js ADDED
@@ -0,0 +1,73 @@
1
+ // --- low-level request helper -----------------------------------------------
2
+ class ApiError extends Error {
3
+ status;
4
+ body;
5
+ constructor(message, status, body) {
6
+ super(message);
7
+ this.status = status;
8
+ this.body = body;
9
+ this.name = "ApiError";
10
+ }
11
+ }
12
+ async function request(config, method, pathName, body) {
13
+ const url = `${config.apiBase}${pathName}`;
14
+ const headers = {
15
+ authorization: `Bearer ${config.token}`,
16
+ accept: "application/json",
17
+ };
18
+ if (body !== undefined) {
19
+ headers["content-type"] = "application/json";
20
+ }
21
+ let res;
22
+ try {
23
+ res = await fetch(url, {
24
+ method,
25
+ headers,
26
+ body: body !== undefined ? JSON.stringify(body) : undefined,
27
+ });
28
+ }
29
+ catch (err) {
30
+ throw new ApiError(`Network error calling ${method} ${pathName}: ${err.message}`);
31
+ }
32
+ const text = await res.text();
33
+ if (!res.ok) {
34
+ let detail = text;
35
+ try {
36
+ const parsed = JSON.parse(text);
37
+ detail = parsed.error ?? parsed.message ?? text;
38
+ }
39
+ catch {
40
+ // keep raw text
41
+ }
42
+ throw new ApiError(`${method} ${pathName} failed (${res.status} ${res.statusText})` +
43
+ (detail ? `: ${detail}` : ""), res.status, text);
44
+ }
45
+ if (!text)
46
+ return undefined;
47
+ try {
48
+ return JSON.parse(text);
49
+ }
50
+ catch {
51
+ throw new ApiError(`${method} ${pathName} returned non-JSON response: ${text.slice(0, 200)}`);
52
+ }
53
+ }
54
+ function enc(segment) {
55
+ return encodeURIComponent(segment);
56
+ }
57
+ // --- typed endpoint wrappers -------------------------------------------------
58
+ export async function publish(config, req) {
59
+ return request(config, "POST", "/v1/publish", req);
60
+ }
61
+ export async function listSpace(config) {
62
+ return request(config, "GET", `/v1/spaces/${enc(config.spaceId)}/list`);
63
+ }
64
+ export async function artifactHistory(config, name) {
65
+ return request(config, "GET", `/v1/spaces/${enc(config.spaceId)}/artifacts/${enc(name)}/history`);
66
+ }
67
+ export async function unpublishArtifact(config, name) {
68
+ return request(config, "POST", `/v1/spaces/${enc(config.spaceId)}/artifacts/${enc(name)}/unpublish`);
69
+ }
70
+ export async function keepArtifact(config, name) {
71
+ return request(config, "POST", `/v1/spaces/${enc(config.spaceId)}/artifacts/${enc(name)}/keep`);
72
+ }
73
+ export { ApiError };
package/dist/config.js ADDED
@@ -0,0 +1,112 @@
1
+ import { promises as fs } from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { generateSpaceId, slugify } from "./util.js";
5
+ export const DEFAULT_API_BASE = "https://api.protocontent.com";
6
+ /** Resolve the configured API base, trimming any trailing slash. */
7
+ export function getApiBase() {
8
+ const raw = process.env.PROTOCONTENT_API?.trim();
9
+ const base = raw && raw.length > 0 ? raw : DEFAULT_API_BASE;
10
+ return base.replace(/\/+$/, "");
11
+ }
12
+ function configDir() {
13
+ return path.join(os.homedir(), ".protocontent");
14
+ }
15
+ function configPath() {
16
+ return path.join(configDir(), "config.json");
17
+ }
18
+ async function readConfigFile() {
19
+ try {
20
+ const raw = await fs.readFile(configPath(), "utf8");
21
+ const parsed = JSON.parse(raw);
22
+ return parsed && typeof parsed === "object" ? parsed : null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ async function writeConfigFile(cfg) {
29
+ const dir = configDir();
30
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
31
+ const data = JSON.stringify(cfg, null, 2) + "\n";
32
+ // Write then chmod to guarantee 0600 even if umask interfered.
33
+ await fs.writeFile(configPath(), data, { mode: 0o600 });
34
+ try {
35
+ await fs.chmod(configPath(), 0o600);
36
+ }
37
+ catch {
38
+ // best effort — not all filesystems support chmod
39
+ }
40
+ }
41
+ /**
42
+ * Mint an anonymous project token via the public (no-auth) endpoint and
43
+ * cache it to ~/.protocontent/config.json.
44
+ */
45
+ async function mintAnonymousToken(apiBase) {
46
+ let res;
47
+ try {
48
+ res = await fetch(`${apiBase}/v1/projects`, {
49
+ method: "POST",
50
+ headers: { "content-type": "application/json" },
51
+ body: "{}",
52
+ });
53
+ }
54
+ catch (err) {
55
+ throw new Error(`Could not reach protocontent API at ${apiBase} to create an anonymous project: ` +
56
+ `${err.message}`);
57
+ }
58
+ if (!res.ok) {
59
+ const body = await res.text().catch(() => "");
60
+ throw new Error(`Failed to mint anonymous project token (${res.status} ${res.statusText})` +
61
+ (body ? `: ${body}` : ""));
62
+ }
63
+ const json = (await res.json());
64
+ if (!json.token) {
65
+ throw new Error("Project endpoint did not return a token.");
66
+ }
67
+ const cfg = { token: json.token, projectId: json.projectId };
68
+ await writeConfigFile(cfg);
69
+ return { token: json.token, projectId: json.projectId };
70
+ }
71
+ /**
72
+ * Resolve the auth token, in priority order:
73
+ * 1. env PROTOCONTENT_TOKEN
74
+ * 2. ~/.protocontent/config.json
75
+ * 3. mint a new anonymous token (and cache it)
76
+ */
77
+ export async function resolveToken(apiBase) {
78
+ const envToken = process.env.PROTOCONTENT_TOKEN?.trim();
79
+ if (envToken)
80
+ return envToken;
81
+ const cfg = await readConfigFile();
82
+ if (cfg?.token)
83
+ return cfg.token;
84
+ const minted = await mintAnonymousToken(apiBase);
85
+ return minted.token;
86
+ }
87
+ /**
88
+ * Compute the per-process space id and label.
89
+ * Seeded deterministically from CLAUDE_SESSION_ID when present so the
90
+ * space lines up with the agent's thread; otherwise random.
91
+ */
92
+ export function computeSpace() {
93
+ const seed = process.env.CLAUDE_SESSION_ID?.trim();
94
+ const spaceId = generateSpaceId(seed && seed.length > 0 ? seed : undefined);
95
+ let spaceLabel;
96
+ try {
97
+ const base = path.basename(process.cwd());
98
+ const label = slugify(base, "");
99
+ spaceLabel = label.length > 0 ? label : undefined;
100
+ }
101
+ catch {
102
+ spaceLabel = undefined;
103
+ }
104
+ return { spaceId, spaceLabel };
105
+ }
106
+ /** Build the full bridge config once at startup. */
107
+ export async function loadConfig() {
108
+ const apiBase = getApiBase();
109
+ const token = await resolveToken(apiBase);
110
+ const { spaceId, spaceLabel } = computeSpace();
111
+ return { apiBase, token, spaceId, spaceLabel };
112
+ }
package/dist/index.js ADDED
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env node
2
+ import { promises as fs } from "node:fs";
3
+ import * as path from "node:path";
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { z } from "zod";
7
+ import { artifactHistory, keepArtifact, listSpace, publish, unpublishArtifact, } from "./api.js";
8
+ import { loadConfig } from "./config.js";
9
+ import { contentTypeFromName, formatBytes, slugify, slugifyBasename, walkDir, } from "./util.js";
10
+ // Safety rails for a single publish_folder call (not the abuse-limits feature).
11
+ const MAX_FILES_PER_CALL = 500;
12
+ const MAX_TOTAL_BYTES = 50 * 1024 * 1024; // 50 MB
13
+ const MAX_SINGLE_FILE_BYTES = 25 * 1024 * 1024; // 25 MB
14
+ function textResult(text) {
15
+ return { content: [{ type: "text", text }] };
16
+ }
17
+ function errorResult(text) {
18
+ return { content: [{ type: "text", text }], isError: true };
19
+ }
20
+ /** Format an epoch-ms timestamp (or null) as a short human-readable UTC string. */
21
+ function fmtTime(ms) {
22
+ if (ms == null)
23
+ return "";
24
+ return new Date(ms).toISOString().slice(0, 16).replace("T", " ") + " UTC";
25
+ }
26
+ /** Format an epoch-ms expiry as an absolute time plus a relative hint. */
27
+ function fmtExpiry(ms) {
28
+ if (ms == null)
29
+ return "";
30
+ const days = Math.round((ms - Date.now()) / 86400000);
31
+ const rel = days <= 0 ? "soon" : days === 1 ? "in ~1 day" : `in ~${days} days`;
32
+ return `${fmtTime(ms)} (${rel})`;
33
+ }
34
+ /** Compose the standard human-readable success message every tool returns. */
35
+ function publishMessage(what, res) {
36
+ const lines = [];
37
+ lines.push(`Published ${what} (v${res.version}).`);
38
+ // Always surface a tappable markdown link.
39
+ lines.push("");
40
+ lines.push(res.markdown);
41
+ lines.push("");
42
+ lines.push(`Live space (updates in real time): ${res.spaceUrl}`);
43
+ lines.push(`Direct URL: ${res.url}`);
44
+ if (res.expiresAt) {
45
+ lines.push(`Expires ${fmtExpiry(res.expiresAt)} — use \`keep\` to make it permanent.`);
46
+ }
47
+ else {
48
+ lines.push("This artifact has no expiry.");
49
+ }
50
+ return lines.join("\n");
51
+ }
52
+ async function main() {
53
+ let config;
54
+ try {
55
+ config = await loadConfig();
56
+ }
57
+ catch (err) {
58
+ // Fatal: we cannot operate without a token. Surface clearly on stderr.
59
+ process.stderr.write(`[protocontent] startup failed: ${err.message}\n`);
60
+ process.exit(1);
61
+ return;
62
+ }
63
+ process.stderr.write(`[protocontent] space ${config.spaceId}` +
64
+ (config.spaceLabel ? ` (${config.spaceLabel})` : "") +
65
+ ` -> ${config.apiBase}\n`);
66
+ const server = new McpServer({ name: "protocontent", version: "0.1.0" }, {
67
+ instructions: "Publish local files/folders to protocontent and get a shareable, live-updating URL. " +
68
+ "Each agent thread maps to one persistent 'space'. Use publish_html for a single file " +
69
+ "or inline content, publish_folder for a directory (e.g. a built site), and list/history/" +
70
+ "unpublish/keep to manage artifacts. Always show the returned markdown link to the user.",
71
+ });
72
+ // --- publish_html ----------------------------------------------------------
73
+ server.registerTool("publish_html", {
74
+ title: "Publish a single file",
75
+ description: "Publish a single HTML (or other) file or inline content to this space and get a " +
76
+ "tappable, live URL. Provide exactly one of `path` (a file on disk) or `content` " +
77
+ "(inline text). The space page updates live as you republish.",
78
+ inputSchema: {
79
+ path: z
80
+ .string()
81
+ .optional()
82
+ .describe("Absolute or relative path to a local file to publish."),
83
+ content: z
84
+ .string()
85
+ .optional()
86
+ .describe("Inline file content to publish (alternative to `path`)."),
87
+ name: z
88
+ .string()
89
+ .optional()
90
+ .describe("Artifact name (slug). Defaults to the slugified file basename, or 'page'."),
91
+ ttl: z
92
+ .string()
93
+ .optional()
94
+ .describe("Optional time-to-live, e.g. '7d', '24h'. Omit for the default."),
95
+ },
96
+ }, async (args) => {
97
+ const { path: filePath, content, name, ttl } = args;
98
+ if ((filePath && content) || (!filePath && content === undefined)) {
99
+ return errorResult("Provide exactly one of `path` or `content`.");
100
+ }
101
+ let bytes;
102
+ let sourceName;
103
+ if (filePath) {
104
+ const abs = path.resolve(filePath);
105
+ try {
106
+ const stat = await fs.stat(abs);
107
+ if (!stat.isFile()) {
108
+ return errorResult(`Not a file: ${abs}`);
109
+ }
110
+ if (stat.size > MAX_SINGLE_FILE_BYTES) {
111
+ return errorResult(`File is ${formatBytes(stat.size)}, exceeding the ${formatBytes(MAX_SINGLE_FILE_BYTES)} single-file limit.`);
112
+ }
113
+ bytes = await fs.readFile(abs);
114
+ }
115
+ catch (err) {
116
+ return errorResult(`Could not read file ${abs}: ${err.message}`);
117
+ }
118
+ sourceName = path.basename(abs);
119
+ }
120
+ else {
121
+ bytes = Buffer.from(content, "utf8");
122
+ sourceName = "index.html";
123
+ }
124
+ const artifactName = name
125
+ ? slugify(name)
126
+ : filePath
127
+ ? slugifyBasename(sourceName)
128
+ : "page";
129
+ // relPath: a sensible filename. Use the source basename, or default to
130
+ // index.html for inline content.
131
+ const relPath = filePath ? sourceName : "index.html";
132
+ const file = {
133
+ relPath,
134
+ contentBase64: bytes.toString("base64"),
135
+ contentType: contentTypeFromName(relPath),
136
+ };
137
+ try {
138
+ const res = await publish(config, {
139
+ spaceId: config.spaceId,
140
+ spaceLabel: config.spaceLabel,
141
+ name: artifactName,
142
+ ttl,
143
+ files: [file],
144
+ });
145
+ return textResult(publishMessage(`'${artifactName}'`, res));
146
+ }
147
+ catch (err) {
148
+ return errorResult(`Publish failed: ${err.message}`);
149
+ }
150
+ });
151
+ // --- publish_folder --------------------------------------------------------
152
+ server.registerTool("publish_folder", {
153
+ title: "Publish a folder",
154
+ description: "Recursively publish a local directory (e.g. a built static site) to this space and " +
155
+ "get a tappable, live URL. Skips node_modules, .git, and dotfiles. The space page " +
156
+ "updates live as you republish.",
157
+ inputSchema: {
158
+ dir: z.string().describe("Path to the directory to publish."),
159
+ entry: z
160
+ .string()
161
+ .optional()
162
+ .describe("Entry file served at the root. Defaults to 'index.html'."),
163
+ name: z
164
+ .string()
165
+ .optional()
166
+ .describe("Artifact name (slug). Defaults to the slugified directory basename."),
167
+ ttl: z
168
+ .string()
169
+ .optional()
170
+ .describe("Optional time-to-live, e.g. '7d', '24h'. Omit for the default."),
171
+ },
172
+ }, async (args) => {
173
+ const { dir, entry, name, ttl } = args;
174
+ const absDir = path.resolve(dir);
175
+ try {
176
+ const stat = await fs.stat(absDir);
177
+ if (!stat.isDirectory()) {
178
+ return errorResult(`Not a directory: ${absDir}`);
179
+ }
180
+ }
181
+ catch (err) {
182
+ return errorResult(`Could not access directory ${absDir}: ${err.message}`);
183
+ }
184
+ let walked;
185
+ try {
186
+ walked = await walkDir(absDir, {
187
+ maxFiles: MAX_FILES_PER_CALL,
188
+ maxTotalBytes: MAX_TOTAL_BYTES,
189
+ });
190
+ }
191
+ catch (err) {
192
+ return errorResult(err.message);
193
+ }
194
+ if (walked.length === 0) {
195
+ return errorResult(`No publishable files found in ${absDir}.`);
196
+ }
197
+ const files = [];
198
+ for (const f of walked) {
199
+ if (f.size > MAX_SINGLE_FILE_BYTES) {
200
+ return errorResult(`File ${f.relPath} is ${formatBytes(f.size)}, exceeding the ` +
201
+ `${formatBytes(MAX_SINGLE_FILE_BYTES)} single-file limit.`);
202
+ }
203
+ const buf = await fs.readFile(f.absPath);
204
+ files.push({
205
+ relPath: f.relPath,
206
+ contentBase64: buf.toString("base64"),
207
+ contentType: contentTypeFromName(f.relPath),
208
+ });
209
+ }
210
+ const entryFile = entry ?? "index.html";
211
+ const hasEntry = files.some((f) => f.relPath === entryFile);
212
+ const artifactName = name ? slugify(name) : slugify(path.basename(absDir));
213
+ try {
214
+ const res = await publish(config, {
215
+ spaceId: config.spaceId,
216
+ spaceLabel: config.spaceLabel,
217
+ name: artifactName,
218
+ entry: entryFile,
219
+ ttl,
220
+ files,
221
+ });
222
+ let msg = publishMessage(`'${artifactName}' (${files.length} files)`, res);
223
+ if (!hasEntry) {
224
+ msg +=
225
+ `\n\nNote: entry file '${entryFile}' was not found in the folder; ` +
226
+ `the root URL may 404 until you add one or pass a different \`entry\`.`;
227
+ }
228
+ return textResult(msg);
229
+ }
230
+ catch (err) {
231
+ return errorResult(`Publish failed: ${err.message}`);
232
+ }
233
+ });
234
+ // --- list ------------------------------------------------------------------
235
+ server.registerTool("list", {
236
+ title: "List artifacts in this space",
237
+ description: "List all artifacts published in this space, with their live URLs and versions.",
238
+ inputSchema: {},
239
+ }, async () => {
240
+ try {
241
+ const res = await listSpace(config);
242
+ const spaceUrl = `https://${config.spaceId}.protocontent.com`;
243
+ if (!res.artifacts || res.artifacts.length === 0) {
244
+ return textResult(`No artifacts published yet in space ${config.spaceId}.\n` +
245
+ `Space: ${spaceUrl}`);
246
+ }
247
+ const lines = res.artifacts.map((a) => {
248
+ const exp = a.expiresAt ? ` — expires ${fmtExpiry(a.expiresAt)}` : " — no expiry";
249
+ return `- [${a.name} ↗](${a.url}) (v${a.version})${exp}`;
250
+ });
251
+ return textResult(`Artifacts in space ${config.spaceId}:\n` +
252
+ lines.join("\n") +
253
+ `\n\nLive space (updates in real time): ${spaceUrl}`);
254
+ }
255
+ catch (err) {
256
+ return errorResult(`List failed: ${err.message}`);
257
+ }
258
+ });
259
+ // --- history ---------------------------------------------------------------
260
+ server.registerTool("history", {
261
+ title: "Show an artifact's version history",
262
+ description: "Show the published version history of a named artifact in this space.",
263
+ inputSchema: {
264
+ name: z.string().describe("Artifact name (slug) to show history for."),
265
+ },
266
+ }, async (args) => {
267
+ const name = slugify(args.name);
268
+ try {
269
+ const res = await artifactHistory(config, name);
270
+ if (!res.versions || res.versions.length === 0) {
271
+ return textResult(`No version history for '${name}'.`);
272
+ }
273
+ const lines = res.versions
274
+ .slice()
275
+ .sort((a, b) => b.version - a.version)
276
+ .map((v) => `- v${v.version} — ${fmtTime(v.at)} — [open ↗](${v.url})`);
277
+ return textResult(`History for '${name}':\n` + lines.join("\n"));
278
+ }
279
+ catch (err) {
280
+ return errorResult(`History failed: ${err.message}`);
281
+ }
282
+ });
283
+ // --- unpublish -------------------------------------------------------------
284
+ server.registerTool("unpublish", {
285
+ title: "Unpublish an artifact",
286
+ description: "Remove a named artifact from this space so its URL stops serving content.",
287
+ inputSchema: {
288
+ name: z.string().describe("Artifact name (slug) to unpublish."),
289
+ },
290
+ }, async (args) => {
291
+ const name = slugify(args.name);
292
+ try {
293
+ const res = await unpublishArtifact(config, name);
294
+ if (res.ok) {
295
+ return textResult(`Unpublished '${name}'. Its URL no longer serves content.`);
296
+ }
297
+ return errorResult(`Unpublish of '${name}' did not succeed.`);
298
+ }
299
+ catch (err) {
300
+ return errorResult(`Unpublish failed: ${err.message}`);
301
+ }
302
+ });
303
+ // --- keep ------------------------------------------------------------------
304
+ server.registerTool("keep", {
305
+ title: "Keep an artifact permanently",
306
+ description: "Remove the expiry from a named artifact so it is kept permanently.",
307
+ inputSchema: {
308
+ name: z.string().describe("Artifact name (slug) to keep permanently."),
309
+ },
310
+ }, async (args) => {
311
+ const name = slugify(args.name);
312
+ try {
313
+ await keepArtifact(config, name);
314
+ return textResult(`'${name}' will now be kept permanently (expiry removed).`);
315
+ }
316
+ catch (err) {
317
+ return errorResult(`Keep failed: ${err.message}`);
318
+ }
319
+ });
320
+ const transport = new StdioServerTransport();
321
+ await server.connect(transport);
322
+ process.stderr.write("[protocontent] stdio MCP server ready.\n");
323
+ }
324
+ main().catch((err) => {
325
+ process.stderr.write(`[protocontent] fatal: ${err.stack ?? err}\n`);
326
+ process.exit(1);
327
+ });
package/dist/util.js ADDED
@@ -0,0 +1,181 @@
1
+ import { createHash } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import * as path from "node:path";
4
+ /**
5
+ * Map of file extensions (without leading dot) to MIME content types.
6
+ * Small, hand-picked list covering the common cases an agent publishes.
7
+ */
8
+ const CONTENT_TYPE_BY_EXT = {
9
+ html: "text/html; charset=utf-8",
10
+ htm: "text/html; charset=utf-8",
11
+ css: "text/css; charset=utf-8",
12
+ js: "text/javascript; charset=utf-8",
13
+ mjs: "text/javascript; charset=utf-8",
14
+ cjs: "text/javascript; charset=utf-8",
15
+ json: "application/json; charset=utf-8",
16
+ map: "application/json; charset=utf-8",
17
+ xml: "application/xml; charset=utf-8",
18
+ txt: "text/plain; charset=utf-8",
19
+ md: "text/markdown; charset=utf-8",
20
+ csv: "text/csv; charset=utf-8",
21
+ wasm: "application/wasm",
22
+ pdf: "application/pdf",
23
+ png: "image/png",
24
+ jpg: "image/jpeg",
25
+ jpeg: "image/jpeg",
26
+ gif: "image/gif",
27
+ svg: "image/svg+xml",
28
+ webp: "image/webp",
29
+ ico: "image/x-icon",
30
+ avif: "image/avif",
31
+ bmp: "image/bmp",
32
+ woff: "font/woff",
33
+ woff2: "font/woff2",
34
+ ttf: "font/ttf",
35
+ otf: "font/otf",
36
+ eot: "application/vnd.ms-fontobject",
37
+ mp4: "video/mp4",
38
+ webm: "video/webm",
39
+ mp3: "audio/mpeg",
40
+ wav: "audio/wav",
41
+ ogg: "audio/ogg",
42
+ };
43
+ const DEFAULT_CONTENT_TYPE = "application/octet-stream";
44
+ /** Detect a content type from a file name / path using its extension. */
45
+ export function contentTypeFromName(name) {
46
+ const ext = path.extname(name).slice(1).toLowerCase();
47
+ if (!ext)
48
+ return DEFAULT_CONTENT_TYPE;
49
+ return CONTENT_TYPE_BY_EXT[ext] ?? DEFAULT_CONTENT_TYPE;
50
+ }
51
+ /**
52
+ * Turn an arbitrary string into a DNS/URL-safe slug.
53
+ * Lowercases, replaces runs of non-alphanumerics with single hyphens,
54
+ * trims leading/trailing hyphens. Falls back to "page" when empty.
55
+ */
56
+ export function slugify(input, fallback = "page") {
57
+ const slug = input
58
+ .toLowerCase()
59
+ .normalize("NFKD")
60
+ .replace(/[̀-ͯ]/g, "") // strip accents
61
+ .replace(/[^a-z0-9]+/g, "-")
62
+ .replace(/^-+|-+$/g, "")
63
+ .replace(/-{2,}/g, "-");
64
+ return slug || fallback;
65
+ }
66
+ /**
67
+ * Slugify a file basename, dropping its extension first so that
68
+ * "My Plan.html" -> "my-plan".
69
+ */
70
+ export function slugifyBasename(fileName, fallback = "page") {
71
+ const base = path.basename(fileName, path.extname(fileName));
72
+ return slugify(base || fileName, fallback);
73
+ }
74
+ // --- space-id generation ----------------------------------------------------
75
+ const ADJECTIVES = [
76
+ "amber", "azure", "brave", "calm", "clever", "coral", "crimson", "dawn",
77
+ "eager", "ember", "fancy", "fleet", "gentle", "golden", "happy", "hazel",
78
+ "indigo", "ivory", "jade", "jolly", "keen", "lively", "lunar", "maple",
79
+ "mellow", "mint", "misty", "noble", "ocean", "olive", "opal", "pearl",
80
+ "plum", "proud", "quiet", "rapid", "royal", "ruby", "sage", "scarlet",
81
+ "shy", "silent", "silver", "solar", "spry", "starry", "sunny", "swift",
82
+ "teal", "tidal", "topaz", "vivid", "warm", "wild", "wise", "zesty",
83
+ ];
84
+ const NOUNS = [
85
+ "canyon", "harbor", "meadow", "summit", "river", "forest", "valley", "ridge",
86
+ "glade", "haven", "isle", "lagoon", "marsh", "oasis", "prairie", "reef",
87
+ "tundra", "delta", "dune", "fjord", "geyser", "grove", "knoll", "mesa",
88
+ "cove", "bay", "creek", "falls", "glen", "moor", "peak", "shore",
89
+ "spring", "thicket", "vista", "wharf", "willow", "cedar", "birch", "aspen",
90
+ "comet", "ember", "falcon", "heron", "lynx", "otter", "raven", "sparrow",
91
+ "beacon", "compass", "lantern", "anchor", "pebble", "ripple", "breeze", "echo",
92
+ ];
93
+ function pick(arr, index) {
94
+ return arr[index % arr.length];
95
+ }
96
+ /**
97
+ * Generate a DNS-safe space id of the form `word-word-xxx`
98
+ * (e.g. `amber-canyon-7f3`). When `seed` is provided the result is
99
+ * deterministic (so it lines up with an agent thread/session id);
100
+ * otherwise it is random.
101
+ */
102
+ export function generateSpaceId(seed) {
103
+ if (seed && seed.length > 0) {
104
+ const hash = createHash("sha256").update(seed).digest();
105
+ const adjIndex = hash.readUInt16BE(0);
106
+ const nounIndex = hash.readUInt16BE(2);
107
+ // 3-char base36 suffix derived from the next bytes.
108
+ const suffixNum = hash.readUInt32BE(4) % (36 * 36 * 36);
109
+ const suffix = suffixNum.toString(36).padStart(3, "0").slice(-3);
110
+ return `${pick(ADJECTIVES, adjIndex)}-${pick(NOUNS, nounIndex)}-${suffix}`;
111
+ }
112
+ const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
113
+ const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
114
+ const suffix = Math.floor(Math.random() * (36 * 36 * 36))
115
+ .toString(36)
116
+ .padStart(3, "0")
117
+ .slice(-3);
118
+ return `${adj}-${noun}-${suffix}`;
119
+ }
120
+ const DEFAULT_SKIP_DIRS = new Set([
121
+ "node_modules",
122
+ ".git",
123
+ ".hg",
124
+ ".svn",
125
+ ".cache",
126
+ "dist",
127
+ ".next",
128
+ ".turbo",
129
+ ]);
130
+ /**
131
+ * Recursively walk `root`, returning files with POSIX-relative paths.
132
+ * Enforces file-count and total-byte ceilings, throwing a clear error
133
+ * if either is exceeded (a safety rail against absurd uploads).
134
+ */
135
+ export async function walkDir(root, options) {
136
+ const skipDotfiles = options.skipDotfiles ?? true;
137
+ const skipDirs = options.skipDirs ?? DEFAULT_SKIP_DIRS;
138
+ const out = [];
139
+ let totalBytes = 0;
140
+ async function recurse(dir) {
141
+ const entries = await fs.readdir(dir, { withFileTypes: true });
142
+ for (const entry of entries) {
143
+ const name = entry.name;
144
+ if (skipDotfiles && name.startsWith("."))
145
+ continue;
146
+ const abs = path.join(dir, name);
147
+ if (entry.isDirectory()) {
148
+ if (skipDirs.has(name))
149
+ continue;
150
+ await recurse(abs);
151
+ }
152
+ else if (entry.isFile()) {
153
+ const stat = await fs.stat(abs);
154
+ out.push({
155
+ absPath: abs,
156
+ relPath: path.relative(root, abs).split(path.sep).join("/"),
157
+ size: stat.size,
158
+ });
159
+ totalBytes += stat.size;
160
+ if (out.length > options.maxFiles) {
161
+ throw new Error(`Folder has more than ${options.maxFiles} files — too large to publish in one call. ` +
162
+ `Publish a smaller subfolder or split it up.`);
163
+ }
164
+ if (totalBytes > options.maxTotalBytes) {
165
+ throw new Error(`Folder exceeds the ${formatBytes(options.maxTotalBytes)} upload ceiling. ` +
166
+ `Publish a smaller subfolder or split it up.`);
167
+ }
168
+ }
169
+ // symlinks and other special entries are ignored
170
+ }
171
+ }
172
+ await recurse(root);
173
+ return out;
174
+ }
175
+ export function formatBytes(bytes) {
176
+ if (bytes < 1024)
177
+ return `${bytes} B`;
178
+ if (bytes < 1024 * 1024)
179
+ return `${(bytes / 1024).toFixed(0)} KB`;
180
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
181
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "protocontent",
3
+ "version": "0.1.0",
4
+ "description": "Co-located stdio MCP bridge for protocontent — read local files and publish them to the protocontent HTTP API.",
5
+ "type": "module",
6
+ "bin": {
7
+ "protocontent": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "prepublishOnly": "npm run build",
16
+ "start": "node dist/index.js"
17
+ },
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "keywords": [
22
+ "mcp",
23
+ "model-context-protocol",
24
+ "protocontent",
25
+ "publishing",
26
+ "stdio"
27
+ ],
28
+ "author": "Jaron Heard",
29
+ "license": "MIT",
30
+ "dependencies": {
31
+ "@modelcontextprotocol/sdk": "^1.0.0",
32
+ "zod": "^3.23.8"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^20.11.0",
36
+ "typescript": "^5.4.0"
37
+ }
38
+ }