@reidar80/webshelf-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/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # @reidar80/webshelf-mcp
2
+
3
+ Model Context Protocol server for [Webshelf](https://webshelf.app). Lets
4
+ any MCP-aware client (Claude Desktop, Cursor, Continue, etc.) list, read,
5
+ upload and manage HTML files on your Webshelf account.
6
+
7
+ ## Install
8
+
9
+ The package runs as a one-off via `npx`, so most users don't install it
10
+ globally:
11
+
12
+ ```bash
13
+ npx -y @reidar80/webshelf-mcp
14
+ ```
15
+
16
+ Or pin a version in your MCP client config (preferred for stability).
17
+
18
+ ## Configure your MCP client
19
+
20
+ ### Claude Desktop
21
+
22
+ Edit `~/Library/Application Support/Claude/claude_desktop_config.json`
23
+ (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows) and
24
+ add:
25
+
26
+ ```json
27
+ {
28
+ "mcpServers": {
29
+ "webshelf": {
30
+ "command": "npx",
31
+ "args": ["-y", "@reidar80/webshelf-mcp"]
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
37
+ Restart Claude. The first time the server starts, it prints a URL to
38
+ stderr — open it, sign in to Webshelf, and approve the connection.
39
+
40
+ ### Other clients
41
+
42
+ Anything that runs MCP servers over stdio works the same way. Point it
43
+ at `npx -y @reidar80/webshelf-mcp`.
44
+
45
+ ## Environment variables
46
+
47
+ | Variable | Default | Purpose |
48
+ |----------|---------|---------|
49
+ | `WEBSHELF_BASE_URL` | `https://webshelf.app` | Override for staging / self-hosted instances. |
50
+ | `WEBSHELF_CLIENT_NAME` | `MCP (<hostname>)` | Label that shows up on your "API sessions" page. |
51
+ | `WEBSHELF_CREDENTIALS_FILE` | `~/.webshelf/credentials.json` | Where the OAuth tokens are cached. |
52
+
53
+ ## Tools
54
+
55
+ | Tool | What it does |
56
+ |------|--------------|
57
+ | `webshelf_whoami` | Identity sanity check. |
58
+ | `webshelf_list_collections` | List collections you can upload into. |
59
+ | `webshelf_list_files` | List your files (or files in a collection). |
60
+ | `webshelf_get_file` | Metadata for a single file. |
61
+ | `webshelf_read_file` | Fetch the HTML body of a file. |
62
+ | `webshelf_create_file` | Upload a new HTML file. |
63
+ | `webshelf_update_file` | Rename, re-describe, or move a file. |
64
+ | `webshelf_delete_file` | Move a file to the recycle bin. |
65
+
66
+ ## How auth works
67
+
68
+ On first launch the server runs the OAuth 2.0 device-authorization
69
+ grant (RFC 8628):
70
+
71
+ 1. It calls `POST /api/oauth/device` to get a one-time `user_code`.
72
+ 2. It prints the verification URL + code to stderr.
73
+ 3. You open the URL in your browser, sign in to Webshelf, and approve
74
+ the connection.
75
+ 4. The server polls `POST /api/oauth/token` until the approval comes
76
+ through, then caches the resulting `access_token` + `refresh_token`
77
+ in `~/.webshelf/credentials.json` (mode 0600).
78
+
79
+ The access token has a 1-hour TTL and refreshes automatically. To
80
+ revoke a session, visit **Settings → API sessions** on webshelf.app and
81
+ hit "Revoke" on the matching row.
82
+
83
+ ## Permissions
84
+
85
+ The MCP server inherits the signed-in user's permissions exactly — it
86
+ cannot read or write any file the user couldn't read or write from the
87
+ browser. There's no separate API-level role.
88
+
89
+ ## Reporting issues
90
+
91
+ Open an issue at https://github.com/reidar80/webshelf/issues. Include
92
+ the MCP client name + version, the tool you were calling, and the full
93
+ error message (with any token values redacted).
package/dist/api.d.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Thin typed client around Webshelf's `/api/v1/*` surface.
3
+ *
4
+ * Owns the auth lifecycle: pulls credentials from auth.ts, attaches the
5
+ * Bearer header, refreshes on 401, retries once. Beyond that, the wire
6
+ * format matches the OpenAPI spec exactly — no client-side renaming.
7
+ */
8
+ export interface ApiFile {
9
+ id: string;
10
+ name: string;
11
+ description: string | null;
12
+ collectionId: string | null;
13
+ ownerId: string;
14
+ protection: "public" | "authenticated" | "inherit" | "individual";
15
+ sizeBytes: number;
16
+ createdAt: string;
17
+ updatedAt: string;
18
+ deletedAt: string | null;
19
+ }
20
+ export interface ApiCollection {
21
+ id: string;
22
+ name: string;
23
+ ownerId: string;
24
+ companyId: string | null;
25
+ company: {
26
+ id: string;
27
+ name: string;
28
+ slug: string;
29
+ } | null;
30
+ protection: "private" | "public";
31
+ createdAt: string;
32
+ }
33
+ export interface ApiClient {
34
+ me(): Promise<{
35
+ user: {
36
+ id: string;
37
+ email: string;
38
+ displayName: string | null;
39
+ };
40
+ }>;
41
+ listFiles(params?: {
42
+ collectionId?: string;
43
+ personal?: boolean;
44
+ limit?: number;
45
+ cursor?: string;
46
+ }): Promise<{
47
+ files: ApiFile[];
48
+ nextCursor: string | null;
49
+ }>;
50
+ getFile(id: string): Promise<{
51
+ file: ApiFile;
52
+ }>;
53
+ getFileContent(id: string): Promise<{
54
+ name: string;
55
+ html: string;
56
+ }>;
57
+ createFile(input: {
58
+ name: string;
59
+ description?: string | null;
60
+ collectionId: string | null;
61
+ protection?: ApiFile["protection"];
62
+ html: string;
63
+ }): Promise<{
64
+ file: ApiFile;
65
+ }>;
66
+ updateFile(id: string, input: {
67
+ name?: string;
68
+ description?: string | null;
69
+ collectionId?: string | null;
70
+ }): Promise<{
71
+ file: ApiFile;
72
+ }>;
73
+ deleteFile(id: string): Promise<{
74
+ deleted: boolean;
75
+ }>;
76
+ listCollections(): Promise<{
77
+ collections: ApiCollection[];
78
+ }>;
79
+ }
80
+ export declare function createApiClient(options: {
81
+ baseUrl: string;
82
+ clientName: string;
83
+ }): ApiClient;
package/dist/api.js ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Thin typed client around Webshelf's `/api/v1/*` surface.
3
+ *
4
+ * Owns the auth lifecycle: pulls credentials from auth.ts, attaches the
5
+ * Bearer header, refreshes on 401, retries once. Beyond that, the wire
6
+ * format matches the OpenAPI spec exactly — no client-side renaming.
7
+ */
8
+ import { ensureCredentials, forceRefresh } from "./auth.js";
9
+ export function createApiClient(options) {
10
+ let credsPromise = ensureCredentials(options.baseUrl, options.clientName);
11
+ async function request(method, path, body, headers = {}) {
12
+ let creds = await credsPromise;
13
+ let res = await fetch(`${creds.baseUrl}${path}`, {
14
+ method,
15
+ headers: {
16
+ authorization: `Bearer ${creds.accessToken}`,
17
+ ...(body !== undefined ? { "content-type": "application/json" } : {}),
18
+ ...headers,
19
+ },
20
+ body: body !== undefined ? JSON.stringify(body) : undefined,
21
+ });
22
+ if (res.status === 401) {
23
+ // Stale access token; refresh and retry once.
24
+ creds = await forceRefresh(creds);
25
+ credsPromise = Promise.resolve(creds);
26
+ res = await fetch(`${creds.baseUrl}${path}`, {
27
+ method,
28
+ headers: {
29
+ authorization: `Bearer ${creds.accessToken}`,
30
+ ...(body !== undefined ? { "content-type": "application/json" } : {}),
31
+ ...headers,
32
+ },
33
+ body: body !== undefined ? JSON.stringify(body) : undefined,
34
+ });
35
+ }
36
+ if (!res.ok) {
37
+ const text = await res.text();
38
+ throw new Error(`HTTP ${res.status} ${method} ${path}: ${text}`);
39
+ }
40
+ const contentType = res.headers.get("content-type") ?? "";
41
+ if (contentType.includes("application/json")) {
42
+ return (await res.json());
43
+ }
44
+ return (await res.text());
45
+ }
46
+ return {
47
+ me: () => request("GET", "/api/v1/me"),
48
+ listFiles: (params = {}) => {
49
+ const search = new URLSearchParams();
50
+ if (params.collectionId)
51
+ search.set("collectionId", params.collectionId);
52
+ if (params.personal)
53
+ search.set("personal", "true");
54
+ if (params.limit)
55
+ search.set("limit", String(params.limit));
56
+ if (params.cursor)
57
+ search.set("cursor", params.cursor);
58
+ const qs = search.toString();
59
+ return request("GET", `/api/v1/files${qs ? `?${qs}` : ""}`);
60
+ },
61
+ getFile: (id) => request("GET", `/api/v1/files/${id}`),
62
+ getFileContent: async (id) => {
63
+ const json = await request("GET", `/api/v1/files/${id}/content?as=base64`);
64
+ const html = Buffer.from(json.contentBase64, "base64").toString("utf8");
65
+ return { name: json.name, html };
66
+ },
67
+ createFile: (input) => request("POST", "/api/v1/files", input),
68
+ updateFile: (id, input) => request("PATCH", `/api/v1/files/${id}`, input),
69
+ deleteFile: (id) => request("DELETE", `/api/v1/files/${id}`),
70
+ listCollections: () => request("GET", "/api/v1/collections"),
71
+ };
72
+ }
package/dist/auth.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * OAuth 2.0 device-authorization flow for @reidar80/webshelf-mcp.
3
+ *
4
+ * Reads/writes a credentials JSON file at:
5
+ * $WEBSHELF_CREDENTIALS_FILE (when set)
6
+ * ~/.webshelf/credentials.json (otherwise)
7
+ *
8
+ * The credentials file contains the refresh + access tokens issued at
9
+ * the end of the device flow. The MCP server consults it on every API
10
+ * call and silently refreshes the access token when it's within 60s of
11
+ * expiry (or when the server returns 401).
12
+ *
13
+ * Bare tokens never leave this file — they're returned only to the
14
+ * fetch wrapper in `api.ts`.
15
+ */
16
+ interface Credentials {
17
+ accessToken: string;
18
+ refreshToken: string;
19
+ /** epoch ms when `accessToken` expires. */
20
+ accessExpiresAtMs: number;
21
+ /** epoch ms when `refreshToken` expires. */
22
+ refreshExpiresAtMs: number;
23
+ /** Base URL of the Webshelf instance these tokens belong to. */
24
+ baseUrl: string;
25
+ }
26
+ export interface DeviceFlowOptions {
27
+ baseUrl: string;
28
+ clientName: string;
29
+ /** Print the user instructions / poll progress. Defaults to stderr. */
30
+ log?: (line: string) => void;
31
+ }
32
+ /**
33
+ * Run the OAuth device flow and persist the resulting tokens. Resolves
34
+ * with the live credentials. Rejects if the user denies, the device
35
+ * code expires, or the network breaks.
36
+ */
37
+ export declare function runDeviceFlow(options: DeviceFlowOptions): Promise<Credentials>;
38
+ /**
39
+ * Return a live credentials object, refreshing if needed. Initiates the
40
+ * device flow when no credentials are present on disk yet.
41
+ */
42
+ export declare function ensureCredentials(baseUrl: string, clientName: string): Promise<Credentials>;
43
+ /** Force a refresh, used by the API client on 401. */
44
+ export declare function forceRefresh(creds: Credentials): Promise<Credentials>;
45
+ export {};
package/dist/auth.js ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * OAuth 2.0 device-authorization flow for @reidar80/webshelf-mcp.
3
+ *
4
+ * Reads/writes a credentials JSON file at:
5
+ * $WEBSHELF_CREDENTIALS_FILE (when set)
6
+ * ~/.webshelf/credentials.json (otherwise)
7
+ *
8
+ * The credentials file contains the refresh + access tokens issued at
9
+ * the end of the device flow. The MCP server consults it on every API
10
+ * call and silently refreshes the access token when it's within 60s of
11
+ * expiry (or when the server returns 401).
12
+ *
13
+ * Bare tokens never leave this file — they're returned only to the
14
+ * fetch wrapper in `api.ts`.
15
+ */
16
+ import { mkdir, readFile, writeFile, chmod } from "node:fs/promises";
17
+ import { dirname, join } from "node:path";
18
+ import { homedir } from "node:os";
19
+ const REFRESH_LEAD_TIME_MS = 60 * 1000;
20
+ function credentialsPath() {
21
+ const explicit = process.env.WEBSHELF_CREDENTIALS_FILE;
22
+ if (explicit)
23
+ return explicit;
24
+ return join(homedir(), ".webshelf", "credentials.json");
25
+ }
26
+ async function readCredentials() {
27
+ try {
28
+ const buf = await readFile(credentialsPath(), "utf8");
29
+ const parsed = JSON.parse(buf);
30
+ if (typeof parsed?.accessToken === "string" &&
31
+ typeof parsed?.refreshToken === "string" &&
32
+ typeof parsed?.accessExpiresAtMs === "number" &&
33
+ typeof parsed?.refreshExpiresAtMs === "number" &&
34
+ typeof parsed?.baseUrl === "string") {
35
+ return parsed;
36
+ }
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ return null;
42
+ }
43
+ async function writeCredentials(creds) {
44
+ const path = credentialsPath();
45
+ await mkdir(dirname(path), { recursive: true });
46
+ await writeFile(path, JSON.stringify(creds, null, 2), "utf8");
47
+ // Try to lock down permissions to the current user. chmod is a no-op
48
+ // on Windows but harmless.
49
+ try {
50
+ await chmod(path, 0o600);
51
+ }
52
+ catch {
53
+ // ignore
54
+ }
55
+ }
56
+ /**
57
+ * Run the OAuth device flow and persist the resulting tokens. Resolves
58
+ * with the live credentials. Rejects if the user denies, the device
59
+ * code expires, or the network breaks.
60
+ */
61
+ export async function runDeviceFlow(options) {
62
+ const log = options.log ?? ((line) => process.stderr.write(`${line}\n`));
63
+ const start = await fetch(`${options.baseUrl}/api/oauth/device`, {
64
+ method: "POST",
65
+ headers: { "content-type": "application/json" },
66
+ body: JSON.stringify({ client_name: options.clientName }),
67
+ });
68
+ if (!start.ok) {
69
+ throw new Error(`device authorization failed: HTTP ${start.status} ${await start.text()}`);
70
+ }
71
+ const auth = (await start.json());
72
+ log("");
73
+ log("┌─ Webshelf authorization ──────────────────────────────");
74
+ log(`│ Open this URL in your browser:`);
75
+ log(`│ ${auth.verification_uri_complete}`);
76
+ log(`│`);
77
+ log(`│ Or visit ${auth.verification_uri} and enter the code:`);
78
+ log(`│ ${auth.user_code}`);
79
+ log("└────────────────────────────────────────────────────────");
80
+ log("");
81
+ const deadline = Date.now() + auth.expires_in * 1000;
82
+ let interval = auth.interval;
83
+ while (Date.now() < deadline) {
84
+ await sleep(interval * 1000);
85
+ const res = await fetch(`${options.baseUrl}/api/oauth/token`, {
86
+ method: "POST",
87
+ headers: { "content-type": "application/json" },
88
+ body: JSON.stringify({
89
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
90
+ device_code: auth.device_code,
91
+ }),
92
+ });
93
+ if (res.ok) {
94
+ const tokens = (await res.json());
95
+ const creds = {
96
+ accessToken: tokens.access_token,
97
+ refreshToken: tokens.refresh_token,
98
+ accessExpiresAtMs: Date.now() + tokens.expires_in * 1000,
99
+ refreshExpiresAtMs: Date.now() + (tokens.refresh_expires_in ?? 30 * 24 * 60 * 60) * 1000,
100
+ baseUrl: options.baseUrl,
101
+ };
102
+ await writeCredentials(creds);
103
+ log("Authorization complete. Token stored.");
104
+ return creds;
105
+ }
106
+ const body = (await res
107
+ .json()
108
+ .catch(() => ({ error: "unknown" })));
109
+ if (body.error === "authorization_pending") {
110
+ // keep polling
111
+ continue;
112
+ }
113
+ if (body.error === "slow_down") {
114
+ // RFC 8628 §3.5: bump the interval by 5s and keep polling.
115
+ interval += 5;
116
+ continue;
117
+ }
118
+ throw new Error(`device authorization rejected: ${body.error}${body.error_description ? ` (${body.error_description})` : ""}`);
119
+ }
120
+ throw new Error("device code expired before user approval");
121
+ }
122
+ /**
123
+ * Refresh an expired or near-expired access token. Returns the updated
124
+ * credentials. Throws if the refresh token itself has expired or been
125
+ * revoked — caller should re-run the device flow in that case.
126
+ */
127
+ async function refreshAccessToken(creds) {
128
+ const res = await fetch(`${creds.baseUrl}/api/oauth/token`, {
129
+ method: "POST",
130
+ headers: { "content-type": "application/json" },
131
+ body: JSON.stringify({
132
+ grant_type: "refresh_token",
133
+ refresh_token: creds.refreshToken,
134
+ }),
135
+ });
136
+ if (!res.ok) {
137
+ throw new Error(`refresh failed: HTTP ${res.status} ${await res.text()}; re-run device flow`);
138
+ }
139
+ const tokens = (await res.json());
140
+ const next = {
141
+ accessToken: tokens.access_token,
142
+ refreshToken: tokens.refresh_token,
143
+ accessExpiresAtMs: Date.now() + tokens.expires_in * 1000,
144
+ refreshExpiresAtMs: Date.now() + (tokens.refresh_expires_in ?? 30 * 24 * 60 * 60) * 1000,
145
+ baseUrl: creds.baseUrl,
146
+ };
147
+ await writeCredentials(next);
148
+ return next;
149
+ }
150
+ /**
151
+ * Return a live credentials object, refreshing if needed. Initiates the
152
+ * device flow when no credentials are present on disk yet.
153
+ */
154
+ export async function ensureCredentials(baseUrl, clientName) {
155
+ let creds = await readCredentials();
156
+ if (!creds || creds.baseUrl !== baseUrl) {
157
+ creds = await runDeviceFlow({ baseUrl, clientName });
158
+ }
159
+ if (creds.accessExpiresAtMs - Date.now() < REFRESH_LEAD_TIME_MS) {
160
+ creds = await refreshAccessToken(creds);
161
+ }
162
+ return creds;
163
+ }
164
+ /** Force a refresh, used by the API client on 401. */
165
+ export async function forceRefresh(creds) {
166
+ return refreshAccessToken(creds);
167
+ }
168
+ function sleep(ms) {
169
+ return new Promise((resolve) => setTimeout(resolve, ms));
170
+ }
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @reidar80/webshelf-mcp — Model Context Protocol server for Webshelf.
4
+ *
5
+ * Speaks MCP over stdio. The first run launches the OAuth 2.0 device
6
+ * flow against `WEBSHELF_BASE_URL` (default https://webshelf.app), prints
7
+ * a one-time URL the user opens in their browser, then waits for the
8
+ * polling exchange to succeed before serving any tool calls.
9
+ *
10
+ * Environment variables:
11
+ * WEBSHELF_BASE_URL — defaults to https://webshelf.app
12
+ * WEBSHELF_CLIENT_NAME — defaults to "MCP (<hostname>)"
13
+ * WEBSHELF_CREDENTIALS_FILE — override credentials cache location
14
+ *
15
+ * Tools exposed to the MCP client:
16
+ * webshelf_whoami — verify the session and surface identity
17
+ * webshelf_list_collections — list collections the caller can write to
18
+ * webshelf_list_files — list files (own / by collection)
19
+ * webshelf_get_file — metadata for a single file
20
+ * webshelf_read_file — fetch HTML contents
21
+ * webshelf_create_file — upload a new HTML file
22
+ * webshelf_update_file — rename / move / re-describe
23
+ * webshelf_delete_file — soft-delete a file (recycle bin)
24
+ */
25
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @reidar80/webshelf-mcp — Model Context Protocol server for Webshelf.
4
+ *
5
+ * Speaks MCP over stdio. The first run launches the OAuth 2.0 device
6
+ * flow against `WEBSHELF_BASE_URL` (default https://webshelf.app), prints
7
+ * a one-time URL the user opens in their browser, then waits for the
8
+ * polling exchange to succeed before serving any tool calls.
9
+ *
10
+ * Environment variables:
11
+ * WEBSHELF_BASE_URL — defaults to https://webshelf.app
12
+ * WEBSHELF_CLIENT_NAME — defaults to "MCP (<hostname>)"
13
+ * WEBSHELF_CREDENTIALS_FILE — override credentials cache location
14
+ *
15
+ * Tools exposed to the MCP client:
16
+ * webshelf_whoami — verify the session and surface identity
17
+ * webshelf_list_collections — list collections the caller can write to
18
+ * webshelf_list_files — list files (own / by collection)
19
+ * webshelf_get_file — metadata for a single file
20
+ * webshelf_read_file — fetch HTML contents
21
+ * webshelf_create_file — upload a new HTML file
22
+ * webshelf_update_file — rename / move / re-describe
23
+ * webshelf_delete_file — soft-delete a file (recycle bin)
24
+ */
25
+ import { hostname } from "node:os";
26
+ import { z } from "zod";
27
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
28
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
29
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
30
+ import { createApiClient } from "./api.js";
31
+ const BASE_URL = process.env.WEBSHELF_BASE_URL?.replace(/\/$/, "") ?? "https://webshelf.app";
32
+ const CLIENT_NAME = process.env.WEBSHELF_CLIENT_NAME ?? `MCP (${hostname()})`;
33
+ const client = createApiClient({ baseUrl: BASE_URL, clientName: CLIENT_NAME });
34
+ const tools = [
35
+ {
36
+ name: "webshelf_whoami",
37
+ description: "Return the email and id of the Webshelf user the MCP server is acting on behalf of. Use as a connectivity sanity check.",
38
+ inputSchema: {
39
+ type: "object",
40
+ additionalProperties: false,
41
+ properties: {},
42
+ },
43
+ handler: async () => {
44
+ const { user } = await client.me();
45
+ return text(`Signed in as ${user.email} (${user.displayName ?? "no display name"})`);
46
+ },
47
+ },
48
+ {
49
+ name: "webshelf_list_collections",
50
+ description: "List collections the authenticated user can see, including which workspace they belong to. Returns id, name, workspace, and protection.",
51
+ inputSchema: {
52
+ type: "object",
53
+ additionalProperties: false,
54
+ properties: {},
55
+ },
56
+ handler: async () => {
57
+ const { collections } = await client.listCollections();
58
+ return text(collections
59
+ .map((c) => `${c.id} ${JSON.stringify(c.name)} workspace=${c.company?.slug ?? "(personal)"} protection=${c.protection}`)
60
+ .join("\n") || "(no collections visible)");
61
+ },
62
+ },
63
+ {
64
+ name: "webshelf_list_files",
65
+ description: "List files. By default returns the caller's own files across all collections. Pass collectionId to list files inside a specific collection (caller must be a member). Pass personal=true to limit to standalone personal files.",
66
+ inputSchema: {
67
+ type: "object",
68
+ additionalProperties: false,
69
+ properties: {
70
+ collectionId: { type: "string", format: "uuid" },
71
+ personal: { type: "boolean" },
72
+ limit: { type: "integer", minimum: 1, maximum: 100 },
73
+ cursor: { type: "string", format: "date-time" },
74
+ },
75
+ },
76
+ handler: async (input) => {
77
+ const parsed = z
78
+ .object({
79
+ collectionId: z.string().uuid().optional(),
80
+ personal: z.boolean().optional(),
81
+ limit: z.number().int().min(1).max(100).optional(),
82
+ cursor: z.string().datetime().optional(),
83
+ })
84
+ .parse(input ?? {});
85
+ const { files, nextCursor } = await client.listFiles(parsed);
86
+ const body = files
87
+ .map((f) => `${f.id} ${JSON.stringify(f.name)} collection=${f.collectionId ?? "(personal)"} ${formatBytes(f.sizeBytes)} ${f.protection} updated=${f.updatedAt}`)
88
+ .join("\n") || "(no files match)";
89
+ return text(nextCursor ? `${body}\n\nnextCursor=${nextCursor}` : body);
90
+ },
91
+ },
92
+ {
93
+ name: "webshelf_get_file",
94
+ description: "Get metadata for a single file by id (no body). Use webshelf_read_file to retrieve the HTML payload.",
95
+ inputSchema: {
96
+ type: "object",
97
+ additionalProperties: false,
98
+ required: ["id"],
99
+ properties: {
100
+ id: { type: "string", format: "uuid" },
101
+ },
102
+ },
103
+ handler: async (input) => {
104
+ const { id } = z.object({ id: z.string().uuid() }).parse(input);
105
+ const { file } = await client.getFile(id);
106
+ return text(JSON.stringify(file, null, 2));
107
+ },
108
+ },
109
+ {
110
+ name: "webshelf_read_file",
111
+ description: "Return the HTML body of a file by id. The response is returned as a text block; the MCP client can save it, render it, or feed it back into a tool call.",
112
+ inputSchema: {
113
+ type: "object",
114
+ additionalProperties: false,
115
+ required: ["id"],
116
+ properties: {
117
+ id: { type: "string", format: "uuid" },
118
+ },
119
+ },
120
+ handler: async (input) => {
121
+ const { id } = z.object({ id: z.string().uuid() }).parse(input);
122
+ const { html, name } = await client.getFileContent(id);
123
+ return text(`Filename: ${name}.html\n\n${html}`);
124
+ },
125
+ },
126
+ {
127
+ name: "webshelf_create_file",
128
+ description: "Upload a new HTML file. Set collectionId to a uuid to place inside a collection (caller must be owner/manager), or null for a standalone personal file. Returns the created file's metadata.",
129
+ inputSchema: {
130
+ type: "object",
131
+ additionalProperties: false,
132
+ required: ["name", "html"],
133
+ properties: {
134
+ name: { type: "string", minLength: 1, maxLength: 200 },
135
+ description: { type: "string", maxLength: 2000 },
136
+ collectionId: { type: ["string", "null"], format: "uuid" },
137
+ protection: {
138
+ type: "string",
139
+ enum: ["public", "authenticated", "inherit", "individual"],
140
+ },
141
+ html: { type: "string" },
142
+ },
143
+ },
144
+ handler: async (input) => {
145
+ const parsed = z
146
+ .object({
147
+ name: z.string().min(1).max(200),
148
+ description: z.string().max(2000).optional(),
149
+ collectionId: z.string().uuid().nullable().default(null),
150
+ protection: z
151
+ .enum(["public", "authenticated", "inherit", "individual"])
152
+ .optional(),
153
+ html: z.string().min(1),
154
+ })
155
+ .parse(input);
156
+ const { file } = await client.createFile(parsed);
157
+ return text(`Created file ${file.id} (${file.name}) — ${formatBytes(file.sizeBytes)}\n${BASE_URL}/app/files/${file.id}`);
158
+ },
159
+ },
160
+ {
161
+ name: "webshelf_update_file",
162
+ description: "Rename, re-describe, or move a file. Pass only the fields you want to change.",
163
+ inputSchema: {
164
+ type: "object",
165
+ additionalProperties: false,
166
+ required: ["id"],
167
+ properties: {
168
+ id: { type: "string", format: "uuid" },
169
+ name: { type: "string", minLength: 1, maxLength: 200 },
170
+ description: { type: ["string", "null"], maxLength: 2000 },
171
+ collectionId: { type: ["string", "null"], format: "uuid" },
172
+ },
173
+ },
174
+ handler: async (input) => {
175
+ const parsed = z
176
+ .object({
177
+ id: z.string().uuid(),
178
+ name: z.string().min(1).max(200).optional(),
179
+ description: z.string().max(2000).nullable().optional(),
180
+ collectionId: z.string().uuid().nullable().optional(),
181
+ })
182
+ .parse(input);
183
+ const { id, ...rest } = parsed;
184
+ const { file } = await client.updateFile(id, rest);
185
+ return text(`Updated file ${file.id}: ${JSON.stringify(file, null, 2)}`);
186
+ },
187
+ },
188
+ {
189
+ name: "webshelf_delete_file",
190
+ description: "Soft-delete a file (moves it to the recycle bin; restorable from the web UI for 30 days).",
191
+ inputSchema: {
192
+ type: "object",
193
+ additionalProperties: false,
194
+ required: ["id"],
195
+ properties: { id: { type: "string", format: "uuid" } },
196
+ },
197
+ handler: async (input) => {
198
+ const { id } = z.object({ id: z.string().uuid() }).parse(input);
199
+ await client.deleteFile(id);
200
+ return text(`Moved ${id} to recycle bin.`);
201
+ },
202
+ },
203
+ ];
204
+ const server = new Server({
205
+ name: "@reidar80/webshelf-mcp",
206
+ version: "0.1.0",
207
+ }, {
208
+ capabilities: {
209
+ tools: {},
210
+ },
211
+ });
212
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
213
+ tools: tools.map((t) => ({
214
+ name: t.name,
215
+ description: t.description,
216
+ inputSchema: t.inputSchema,
217
+ })),
218
+ }));
219
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
220
+ const tool = tools.find((t) => t.name === request.params.name);
221
+ if (!tool) {
222
+ return {
223
+ isError: true,
224
+ content: [
225
+ { type: "text", text: `Unknown tool: ${request.params.name}` },
226
+ ],
227
+ };
228
+ }
229
+ try {
230
+ const result = await tool.handler(request.params.arguments ?? {});
231
+ return { content: [result] };
232
+ }
233
+ catch (err) {
234
+ return {
235
+ isError: true,
236
+ content: [
237
+ {
238
+ type: "text",
239
+ text: err instanceof Error ? err.message : String(err),
240
+ },
241
+ ],
242
+ };
243
+ }
244
+ });
245
+ function text(body) {
246
+ return { type: "text", text: body };
247
+ }
248
+ function formatBytes(n) {
249
+ if (n < 1024)
250
+ return `${n} B`;
251
+ if (n < 1024 * 1024)
252
+ return `${(n / 1024).toFixed(1)} KB`;
253
+ return `${(n / (1024 * 1024)).toFixed(2)} MB`;
254
+ }
255
+ async function main() {
256
+ const transport = new StdioServerTransport();
257
+ await server.connect(transport);
258
+ }
259
+ main().catch((err) => {
260
+ process.stderr.write(`webshelf-mcp failed to start: ${err instanceof Error ? err.message : String(err)}\n`);
261
+ process.exit(1);
262
+ });
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@reidar80/webshelf-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Model Context Protocol server for Webshelf — list, read, upload and manage HTML files hosted on webshelf.app from any MCP-aware client.",
5
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "webshelf",
9
+ "claude",
10
+ "ai"
11
+ ],
12
+ "homepage": "https://webshelf.app",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/reidar80/webshelf.git",
16
+ "directory": "mcp"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/reidar80/webshelf/issues"
20
+ },
21
+ "license": "MIT",
22
+ "author": "Webshelf",
23
+ "type": "module",
24
+ "main": "dist/index.js",
25
+ "types": "dist/index.d.ts",
26
+ "bin": {
27
+ "webshelf-mcp": "dist/index.js"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "README.md"
32
+ ],
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "scripts": {
37
+ "build": "tsc -p tsconfig.json",
38
+ "prepublishOnly": "npm run build",
39
+ "start": "node dist/index.js",
40
+ "typecheck": "tsc -p tsconfig.json --noEmit"
41
+ },
42
+ "dependencies": {
43
+ "@modelcontextprotocol/sdk": "^1.0.4",
44
+ "zod": "^3.24.1"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^22.10.2",
48
+ "typescript": "^5.7.2"
49
+ }
50
+ }