@reidar80/webshelf-mcp 0.1.0 → 0.2.2

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 CHANGED
@@ -1,8 +1,18 @@
1
1
  # @reidar80/webshelf-mcp
2
2
 
3
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.
4
+ any MCP-aware client (Claude Desktop, Claude Code, Cursor, Continue, etc.)
5
+ list, read, upload and manage HTML and markdown files on your Webshelf
6
+ account.
7
+
8
+ ## Associated repository
9
+
10
+ The Webshelf web app — the host this MCP server talks to — lives in
11
+ [`reidar80/webshelf`](https://github.com/reidar80/webshelf). That repo
12
+ owns the database schema, the Next.js app, and the `/api/v1/*` HTTP
13
+ surface this client wraps. The authoritative API contract is published
14
+ there as [`openapi/webshelf.yaml`](https://github.com/reidar80/webshelf/blob/main/openapi/webshelf.yaml);
15
+ keep `src/api.ts` and `src/index.ts` here in sync with it.
6
16
 
7
17
  ## Install
8
18
 
@@ -15,7 +25,7 @@ npx -y @reidar80/webshelf-mcp
15
25
 
16
26
  Or pin a version in your MCP client config (preferred for stability).
17
27
 
18
- ## Configure your MCP client
28
+ ## Use Webshelf from Claude
19
29
 
20
30
  ### Claude Desktop
21
31
 
@@ -34,8 +44,20 @@ add:
34
44
  }
35
45
  ```
36
46
 
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.
47
+ Restart Claude. The first launch prints a one-time
48
+ `https://webshelf.app/app/device` URL + `user_code` to stderr open it
49
+ in your browser, sign in to Webshelf, and approve the connection.
50
+ Credentials persist in `~/.webshelf/credentials.json` (mode 0600);
51
+ access tokens refresh automatically.
52
+
53
+ ### Claude Code
54
+
55
+ ```bash
56
+ claude mcp add webshelf -- npx -y @reidar80/webshelf-mcp
57
+ ```
58
+
59
+ Then run `claude` as usual — the same device-flow approval happens on
60
+ the first MCP call.
39
61
 
40
62
  ### Other clients
41
63
 
@@ -58,23 +80,48 @@ at `npx -y @reidar80/webshelf-mcp`.
58
80
  | `webshelf_list_collections` | List collections you can upload into. |
59
81
  | `webshelf_list_files` | List your files (or files in a collection). |
60
82
  | `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. |
83
+ | `webshelf_read_file` | Fetch the body of a file (HTML or markdown). |
84
+ | `webshelf_create_file` | Upload a new HTML or markdown file. |
63
85
  | `webshelf_update_file` | Rename, re-describe, or move a file. |
64
86
  | `webshelf_delete_file` | Move a file to the recycle bin. |
65
87
 
66
88
  ## How auth works
67
89
 
68
- On first launch the server runs the OAuth 2.0 device-authorization
69
- grant (RFC 8628):
90
+ On the first tool call without saved credentials the server runs the
91
+ OAuth 2.0 device-authorization grant (RFC 8628):
70
92
 
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.
93
+ 1. It calls `POST /api/oauth/device` to get a one-time `user_code` and
94
+ writes the pending state to `~/.webshelf/credentials.pending.json`.
95
+ 2. The first tool call returns an error message containing the
96
+ verification URL + code — your MCP client (Claude Desktop, Claude
97
+ Code, etc.) shows it to you verbatim. The same prompt is mirrored
98
+ to stderr for log-tailing setups.
73
99
  3. You open the URL in your browser, sign in to Webshelf, and approve
74
100
  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).
101
+ 4. Retry any tool call. The server exchanges the pending device_code
102
+ for tokens in one shot and caches `access_token` + `refresh_token`
103
+ in `~/.webshelf/credentials.json` (mode 0600). The pending file is
104
+ removed.
105
+
106
+ ### Authorize from the command line
107
+
108
+ If you'd rather authorize before configuring your MCP client, run
109
+
110
+ ```
111
+ npx -y @reidar80/webshelf-mcp auth
112
+ ```
113
+
114
+ (or just `npx -y @reidar80/webshelf-mcp` from an interactive terminal —
115
+ the binary detects a TTY and falls through to the same flow). It will
116
+ print the verification URL, sit and poll, and exit with "Authorization
117
+ complete. Token stored." once you approve. The credentials it writes
118
+ are what your MCP client picks up automatically on next launch.
119
+
120
+ This fast-fail design — surface the URL to the user instead of polling
121
+ for the device-code TTL — avoids the four-minute timeouts MCP hosts
122
+ enforce when a server doesn't respond to a tool call. If approval
123
+ takes a while, every subsequent tool call before approval gets the
124
+ same "still pending" error with the URL.
78
125
 
79
126
  The access token has a 1-hour TTL and refreshes automatically. To
80
127
  revoke a session, visit **Settings → API sessions** on webshelf.app and
@@ -86,8 +133,24 @@ The MCP server inherits the signed-in user's permissions exactly — it
86
133
  cannot read or write any file the user couldn't read or write from the
87
134
  browser. There's no separate API-level role.
88
135
 
136
+ ## Development
137
+
138
+ ```bash
139
+ npm install
140
+ npm run typecheck # tsc --noEmit
141
+ npm run build # tsc → dist/
142
+ npm start # node dist/index.js (runs the device flow)
143
+ ```
144
+
145
+ The HTTP surface this client wraps is documented in
146
+ [`openapi/webshelf.yaml`](https://github.com/reidar80/webshelf/blob/main/openapi/webshelf.yaml)
147
+ in the Webshelf web-app repo. Any change to a request/response shape
148
+ there needs a matching update in `src/api.ts` and `src/index.ts` here,
149
+ followed by a `version` bump in `package.json` so the next push to
150
+ `main` publishes a new npm release.
151
+
89
152
  ## Reporting issues
90
153
 
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).
154
+ Open an issue at https://github.com/reidar80/webshelf-mcp/issues.
155
+ Include the MCP client name + version, the tool you were calling, and
156
+ the full error message (with any token values redacted).
package/dist/api.d.ts CHANGED
@@ -4,6 +4,12 @@
4
4
  * Owns the auth lifecycle: pulls credentials from auth.ts, attaches the
5
5
  * Bearer header, refreshes on 401, retries once. Beyond that, the wire
6
6
  * format matches the OpenAPI spec exactly — no client-side renaming.
7
+ *
8
+ * Credentials are resolved lazily — on the first request, not at module
9
+ * load — so that a missing or pending device flow surfaces as a tool
10
+ * error (DeviceFlowPendingError carries the URL the user must visit),
11
+ * never as a four-minute hang while the MCP host waits for the stdio
12
+ * server to respond.
7
13
  */
8
14
  export interface ApiFile {
9
15
  id: string;
@@ -12,6 +18,7 @@ export interface ApiFile {
12
18
  collectionId: string | null;
13
19
  ownerId: string;
14
20
  protection: "public" | "authenticated" | "inherit" | "individual";
21
+ format: "html" | "markdown";
15
22
  sizeBytes: number;
16
23
  createdAt: string;
17
24
  updatedAt: string;
@@ -52,14 +59,16 @@ export interface ApiClient {
52
59
  }>;
53
60
  getFileContent(id: string): Promise<{
54
61
  name: string;
55
- html: string;
62
+ content: string;
63
+ format: ApiFile["format"];
56
64
  }>;
57
65
  createFile(input: {
58
66
  name: string;
59
67
  description?: string | null;
60
68
  collectionId: string | null;
61
69
  protection?: ApiFile["protection"];
62
- html: string;
70
+ format?: ApiFile["format"];
71
+ content: string;
63
72
  }): Promise<{
64
73
  file: ApiFile;
65
74
  }>;
package/dist/api.js CHANGED
@@ -4,12 +4,20 @@
4
4
  * Owns the auth lifecycle: pulls credentials from auth.ts, attaches the
5
5
  * Bearer header, refreshes on 401, retries once. Beyond that, the wire
6
6
  * format matches the OpenAPI spec exactly — no client-side renaming.
7
+ *
8
+ * Credentials are resolved lazily — on the first request, not at module
9
+ * load — so that a missing or pending device flow surfaces as a tool
10
+ * error (DeviceFlowPendingError carries the URL the user must visit),
11
+ * never as a four-minute hang while the MCP host waits for the stdio
12
+ * server to respond.
7
13
  */
8
14
  import { ensureCredentials, forceRefresh } from "./auth.js";
9
15
  export function createApiClient(options) {
10
- let credsPromise = ensureCredentials(options.baseUrl, options.clientName);
11
16
  async function request(method, path, body, headers = {}) {
12
- let creds = await credsPromise;
17
+ // Lazy only kicks the device flow when a tool actually needs the
18
+ // API. Missing/pending credentials surface as DeviceFlowPendingError
19
+ // (handled by the MCP server wrapper), not a hung Promise.
20
+ let creds = await ensureCredentials(options.baseUrl, options.clientName);
13
21
  let res = await fetch(`${creds.baseUrl}${path}`, {
14
22
  method,
15
23
  headers: {
@@ -22,7 +30,6 @@ export function createApiClient(options) {
22
30
  if (res.status === 401) {
23
31
  // Stale access token; refresh and retry once.
24
32
  creds = await forceRefresh(creds);
25
- credsPromise = Promise.resolve(creds);
26
33
  res = await fetch(`${creds.baseUrl}${path}`, {
27
34
  method,
28
35
  headers: {
@@ -61,8 +68,8 @@ export function createApiClient(options) {
61
68
  getFile: (id) => request("GET", `/api/v1/files/${id}`),
62
69
  getFileContent: async (id) => {
63
70
  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 };
71
+ const content = Buffer.from(json.contentBase64, "base64").toString("utf8");
72
+ return { name: json.name, content, format: json.format };
66
73
  },
67
74
  createFile: (input) => request("POST", "/api/v1/files", input),
68
75
  updateFile: (id, input) => request("PATCH", `/api/v1/files/${id}`, input),
package/dist/auth.d.ts CHANGED
@@ -1,17 +1,27 @@
1
1
  /**
2
2
  * OAuth 2.0 device-authorization flow for @reidar80/webshelf-mcp.
3
3
  *
4
- * Reads/writes a credentials JSON file at:
5
- * $WEBSHELF_CREDENTIALS_FILE (when set)
4
+ * Reads/writes credentials JSON files at:
5
+ * $WEBSHELF_CREDENTIALS_FILE (when set, base path)
6
6
  * ~/.webshelf/credentials.json (otherwise)
7
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).
8
+ * Two files are involved:
9
+ * credentials.json the live access + refresh tokens.
10
+ * credentials.pending.json an in-progress device flow (device_code +
11
+ * verification URL) waiting for the human to approve in their browser.
12
12
  *
13
- * Bare tokens never leave this file they're returned only to the
14
- * fetch wrapper in `api.ts`.
13
+ * Why a pending file: when the MCP server runs under a host like Claude
14
+ * Desktop, stderr is captured to a log file the user never opens. If we
15
+ * blocked the first tool call on a 10-minute polling loop (RFC 8628's
16
+ * device-code TTL), the MCP client times out after a few minutes with a
17
+ * "server unresponsive" error — and the user never sees the verification
18
+ * URL printed to stderr. Instead, we fast-fail the first tool call with
19
+ * the URL in the error message (which the MCP client surfaces verbatim
20
+ * to the user). The user approves in their browser, retries any tool,
21
+ * and the retry exchanges the device_code for tokens in one shot.
22
+ *
23
+ * Bare tokens never leave this module — they're returned only via the
24
+ * `Credentials` object to the fetch wrapper in `api.ts`.
15
25
  */
16
26
  interface Credentials {
17
27
  accessToken: string;
@@ -23,6 +33,18 @@ interface Credentials {
23
33
  /** Base URL of the Webshelf instance these tokens belong to. */
24
34
  baseUrl: string;
25
35
  }
36
+ interface PendingDeviceFlow {
37
+ deviceCode: string;
38
+ userCode: string;
39
+ verificationUri: string;
40
+ verificationUriComplete: string;
41
+ /** epoch ms when the device_code stops being redeemable. */
42
+ expiresAtMs: number;
43
+ /** Base URL the device flow was started against. */
44
+ baseUrl: string;
45
+ /** Label the user picked when launching the flow. */
46
+ clientName: string;
47
+ }
26
48
  export interface DeviceFlowOptions {
27
49
  baseUrl: string;
28
50
  clientName: string;
@@ -30,14 +52,23 @@ export interface DeviceFlowOptions {
30
52
  log?: (line: string) => void;
31
53
  }
32
54
  /**
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.
55
+ * Thrown when the caller has no credentials and must approve the device
56
+ * flow in their browser. Carries the verification URL so the MCP client
57
+ * can show it to the user verbatim. After approval, retrying any tool
58
+ * resumes the flow via {@link tryCompletePending}.
36
59
  */
37
- export declare function runDeviceFlow(options: DeviceFlowOptions): Promise<Credentials>;
60
+ export declare class DeviceFlowPendingError extends Error {
61
+ readonly verificationUri: string;
62
+ readonly verificationUriComplete: string;
63
+ readonly userCode: string;
64
+ readonly expiresAtMs: number;
65
+ constructor(pending: PendingDeviceFlow);
66
+ }
38
67
  /**
39
- * Return a live credentials object, refreshing if needed. Initiates the
40
- * device flow when no credentials are present on disk yet.
68
+ * Return live credentials or throw {@link DeviceFlowPendingError} when
69
+ * the user needs to approve the flow first. Never blocks for more than a
70
+ * single HTTP round trip — the long poll that would happen in a vanilla
71
+ * device flow is replaced by "throw → user approves → retry → redeem".
41
72
  */
42
73
  export declare function ensureCredentials(baseUrl: string, clientName: string): Promise<Credentials>;
43
74
  /** Force a refresh, used by the API client on 401. */
package/dist/auth.js CHANGED
@@ -1,19 +1,29 @@
1
1
  /**
2
2
  * OAuth 2.0 device-authorization flow for @reidar80/webshelf-mcp.
3
3
  *
4
- * Reads/writes a credentials JSON file at:
5
- * $WEBSHELF_CREDENTIALS_FILE (when set)
4
+ * Reads/writes credentials JSON files at:
5
+ * $WEBSHELF_CREDENTIALS_FILE (when set, base path)
6
6
  * ~/.webshelf/credentials.json (otherwise)
7
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).
8
+ * Two files are involved:
9
+ * credentials.json the live access + refresh tokens.
10
+ * credentials.pending.json an in-progress device flow (device_code +
11
+ * verification URL) waiting for the human to approve in their browser.
12
12
  *
13
- * Bare tokens never leave this file they're returned only to the
14
- * fetch wrapper in `api.ts`.
13
+ * Why a pending file: when the MCP server runs under a host like Claude
14
+ * Desktop, stderr is captured to a log file the user never opens. If we
15
+ * blocked the first tool call on a 10-minute polling loop (RFC 8628's
16
+ * device-code TTL), the MCP client times out after a few minutes with a
17
+ * "server unresponsive" error — and the user never sees the verification
18
+ * URL printed to stderr. Instead, we fast-fail the first tool call with
19
+ * the URL in the error message (which the MCP client surfaces verbatim
20
+ * to the user). The user approves in their browser, retries any tool,
21
+ * and the retry exchanges the device_code for tokens in one shot.
22
+ *
23
+ * Bare tokens never leave this module — they're returned only via the
24
+ * `Credentials` object to the fetch wrapper in `api.ts`.
15
25
  */
16
- import { mkdir, readFile, writeFile, chmod } from "node:fs/promises";
26
+ import { mkdir, readFile, unlink, writeFile, chmod } from "node:fs/promises";
17
27
  import { dirname, join } from "node:path";
18
28
  import { homedir } from "node:os";
19
29
  const REFRESH_LEAD_TIME_MS = 60 * 1000;
@@ -23,6 +33,9 @@ function credentialsPath() {
23
33
  return explicit;
24
34
  return join(homedir(), ".webshelf", "credentials.json");
25
35
  }
36
+ function pendingPath() {
37
+ return credentialsPath().replace(/\.json$/, ".pending.json");
38
+ }
26
39
  async function readCredentials() {
27
40
  try {
28
41
  const buf = await readFile(credentialsPath(), "utf8");
@@ -44,8 +57,36 @@ async function writeCredentials(creds) {
44
57
  const path = credentialsPath();
45
58
  await mkdir(dirname(path), { recursive: true });
46
59
  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.
60
+ try {
61
+ await chmod(path, 0o600);
62
+ }
63
+ catch {
64
+ // chmod is a no-op on Windows but harmless.
65
+ }
66
+ }
67
+ async function readPending() {
68
+ try {
69
+ const buf = await readFile(pendingPath(), "utf8");
70
+ const parsed = JSON.parse(buf);
71
+ if (typeof parsed?.deviceCode === "string" &&
72
+ typeof parsed?.userCode === "string" &&
73
+ typeof parsed?.verificationUri === "string" &&
74
+ typeof parsed?.verificationUriComplete === "string" &&
75
+ typeof parsed?.expiresAtMs === "number" &&
76
+ typeof parsed?.baseUrl === "string" &&
77
+ typeof parsed?.clientName === "string") {
78
+ return parsed;
79
+ }
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ return null;
85
+ }
86
+ async function writePending(pending) {
87
+ const path = pendingPath();
88
+ await mkdir(dirname(path), { recursive: true });
89
+ await writeFile(path, JSON.stringify(pending, null, 2), "utf8");
49
90
  try {
50
91
  await chmod(path, 0o600);
51
92
  }
@@ -53,76 +94,109 @@ async function writeCredentials(creds) {
53
94
  // ignore
54
95
  }
55
96
  }
97
+ async function clearPending() {
98
+ try {
99
+ await unlink(pendingPath());
100
+ }
101
+ catch {
102
+ // already gone
103
+ }
104
+ }
56
105
  /**
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.
106
+ * Thrown when the caller has no credentials and must approve the device
107
+ * flow in their browser. Carries the verification URL so the MCP client
108
+ * can show it to the user verbatim. After approval, retrying any tool
109
+ * resumes the flow via {@link tryCompletePending}.
60
110
  */
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`, {
111
+ export class DeviceFlowPendingError extends Error {
112
+ verificationUri;
113
+ verificationUriComplete;
114
+ userCode;
115
+ expiresAtMs;
116
+ constructor(pending) {
117
+ super([
118
+ "Webshelf needs you to authorize this MCP server before it can act on your behalf.",
119
+ "",
120
+ ` 1. Open: ${pending.verificationUriComplete}`,
121
+ ` (or visit ${pending.verificationUri} and enter code ${pending.userCode})`,
122
+ " 2. Approve in your browser.",
123
+ " 3. Retry the tool call — it will complete automatically.",
124
+ ].join("\n"));
125
+ this.name = "DeviceFlowPendingError";
126
+ this.verificationUri = pending.verificationUri;
127
+ this.verificationUriComplete = pending.verificationUriComplete;
128
+ this.userCode = pending.userCode;
129
+ this.expiresAtMs = pending.expiresAtMs;
130
+ }
131
+ }
132
+ /**
133
+ * Start a device flow and write the pending state. Returns the pending
134
+ * record so the caller can decide whether to throw it as a user-visible
135
+ * error or wait on it. Does NOT poll.
136
+ */
137
+ async function startDeviceFlow(baseUrl, clientName) {
138
+ const start = await fetch(`${baseUrl}/api/oauth/device`, {
64
139
  method: "POST",
65
140
  headers: { "content-type": "application/json" },
66
- body: JSON.stringify({ client_name: options.clientName }),
141
+ body: JSON.stringify({ client_name: clientName }),
67
142
  });
68
143
  if (!start.ok) {
69
144
  throw new Error(`device authorization failed: HTTP ${start.status} ${await start.text()}`);
70
145
  }
71
146
  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})` : ""}`);
147
+ const pending = {
148
+ deviceCode: auth.device_code,
149
+ userCode: auth.user_code,
150
+ verificationUri: auth.verification_uri,
151
+ verificationUriComplete: auth.verification_uri_complete,
152
+ expiresAtMs: Date.now() + auth.expires_in * 1000,
153
+ baseUrl,
154
+ clientName,
155
+ };
156
+ await writePending(pending);
157
+ // Best-effort stderr breadcrumb so headless runs (CLI tail -f) and host
158
+ // log files contain the URL even when no tool has been called yet.
159
+ process.stderr.write(`\nWebshelf authorization required.\n Visit: ${pending.verificationUriComplete}\n Code: ${pending.userCode}\n\n`);
160
+ return pending;
161
+ }
162
+ /**
163
+ * Attempt to redeem a pending device_code exactly once. Returns the new
164
+ * credentials on success, null when the user hasn't approved yet, and
165
+ * throws when the device code expired or the flow was rejected (in
166
+ * which case the caller should restart).
167
+ */
168
+ async function tryRedeemDeviceCode(pending) {
169
+ const res = await fetch(`${pending.baseUrl}/api/oauth/token`, {
170
+ method: "POST",
171
+ headers: { "content-type": "application/json" },
172
+ body: JSON.stringify({
173
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
174
+ device_code: pending.deviceCode,
175
+ }),
176
+ });
177
+ if (res.ok) {
178
+ const tokens = (await res.json());
179
+ const creds = {
180
+ accessToken: tokens.access_token,
181
+ refreshToken: tokens.refresh_token,
182
+ accessExpiresAtMs: Date.now() + tokens.expires_in * 1000,
183
+ refreshExpiresAtMs: Date.now() + (tokens.refresh_expires_in ?? 30 * 24 * 60 * 60) * 1000,
184
+ baseUrl: pending.baseUrl,
185
+ };
186
+ return creds;
187
+ }
188
+ const body = (await res.json().catch(() => ({ error: "unknown" })));
189
+ if (body.error === "authorization_pending" || body.error === "slow_down") {
190
+ return null;
119
191
  }
120
- throw new Error("device code expired before user approval");
192
+ // expired_token / access_denied / unknown caller should drop pending
193
+ // state and restart from scratch.
194
+ throw new Error(`device authorization rejected: ${body.error ?? "unknown"}${body.error_description ? ` (${body.error_description})` : ""}`);
121
195
  }
122
196
  /**
123
197
  * Refresh an expired or near-expired access token. Returns the updated
124
198
  * credentials. Throws if the refresh token itself has expired or been
125
- * revoked — caller should re-run the device flow in that case.
199
+ * revoked — caller should clear credentials and re-run the device flow.
126
200
  */
127
201
  async function refreshAccessToken(creds) {
128
202
  const res = await fetch(`${creds.baseUrl}/api/oauth/token`, {
@@ -148,23 +222,54 @@ async function refreshAccessToken(creds) {
148
222
  return next;
149
223
  }
150
224
  /**
151
- * Return a live credentials object, refreshing if needed. Initiates the
152
- * device flow when no credentials are present on disk yet.
225
+ * Return live credentials or throw {@link DeviceFlowPendingError} when
226
+ * the user needs to approve the flow first. Never blocks for more than a
227
+ * single HTTP round trip — the long poll that would happen in a vanilla
228
+ * device flow is replaced by "throw → user approves → retry → redeem".
153
229
  */
154
230
  export async function ensureCredentials(baseUrl, clientName) {
155
- let creds = await readCredentials();
156
- if (!creds || creds.baseUrl !== baseUrl) {
157
- creds = await runDeviceFlow({ baseUrl, clientName });
231
+ // 1. Live credentials? Refresh if they're stale and return.
232
+ const existing = await readCredentials();
233
+ if (existing && existing.baseUrl === baseUrl) {
234
+ if (existing.accessExpiresAtMs - Date.now() < REFRESH_LEAD_TIME_MS) {
235
+ const refreshed = await refreshAccessToken(existing);
236
+ await writeCredentials(refreshed);
237
+ return refreshed;
238
+ }
239
+ return existing;
158
240
  }
159
- if (creds.accessExpiresAtMs - Date.now() < REFRESH_LEAD_TIME_MS) {
160
- creds = await refreshAccessToken(creds);
241
+ // 2. Pending flow? Try to complete it — but bounce out fast if not
242
+ // ready. The user might still be in the browser; better to surface
243
+ // the URL than to hold the MCP transport hostage.
244
+ let pending = await readPending();
245
+ if (pending && pending.baseUrl === baseUrl && pending.expiresAtMs > Date.now()) {
246
+ try {
247
+ const redeemed = await tryRedeemDeviceCode(pending);
248
+ if (redeemed) {
249
+ await writeCredentials(redeemed);
250
+ await clearPending();
251
+ return redeemed;
252
+ }
253
+ }
254
+ catch (err) {
255
+ // Device code expired or was denied — fall through to start a new one.
256
+ await clearPending();
257
+ pending = null;
258
+ // Stick the failure on stderr so logs explain why we restarted.
259
+ process.stderr.write(`[webshelf-mcp] pending device flow rejected (${err instanceof Error ? err.message : String(err)}); restarting.\n`);
260
+ }
261
+ if (pending)
262
+ throw new DeviceFlowPendingError(pending);
161
263
  }
162
- return creds;
264
+ // 3. Nothing live or pending — kick off a new flow and bail out so the
265
+ // user can approve. Their next tool call lands in branch (2).
266
+ if (pending && pending.expiresAtMs <= Date.now()) {
267
+ await clearPending();
268
+ }
269
+ const fresh = await startDeviceFlow(baseUrl, clientName);
270
+ throw new DeviceFlowPendingError(fresh);
163
271
  }
164
272
  /** Force a refresh, used by the API client on 401. */
165
273
  export async function forceRefresh(creds) {
166
274
  return refreshAccessToken(creds);
167
275
  }
168
- function sleep(ms) {
169
- return new Promise((resolve) => setTimeout(resolve, ms));
170
- }
package/dist/index.d.ts CHANGED
@@ -17,8 +17,8 @@
17
17
  * webshelf_list_collections — list collections the caller can write to
18
18
  * webshelf_list_files — list files (own / by collection)
19
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
20
+ * webshelf_read_file — fetch file contents (HTML or markdown)
21
+ * webshelf_create_file — upload a new HTML or markdown file
22
22
  * webshelf_update_file — rename / move / re-describe
23
23
  * webshelf_delete_file — soft-delete a file (recycle bin)
24
24
  */
package/dist/index.js CHANGED
@@ -17,8 +17,8 @@
17
17
  * webshelf_list_collections — list collections the caller can write to
18
18
  * webshelf_list_files — list files (own / by collection)
19
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
20
+ * webshelf_read_file — fetch file contents (HTML or markdown)
21
+ * webshelf_create_file — upload a new HTML or markdown file
22
22
  * webshelf_update_file — rename / move / re-describe
23
23
  * webshelf_delete_file — soft-delete a file (recycle bin)
24
24
  */
@@ -84,7 +84,7 @@ const tools = [
84
84
  .parse(input ?? {});
85
85
  const { files, nextCursor } = await client.listFiles(parsed);
86
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}`)
87
+ .map((f) => `${f.id} ${JSON.stringify(f.name)} format=${f.format} collection=${f.collectionId ?? "(personal)"} ${formatBytes(f.sizeBytes)} ${f.protection} updated=${f.updatedAt}`)
88
88
  .join("\n") || "(no files match)";
89
89
  return text(nextCursor ? `${body}\n\nnextCursor=${nextCursor}` : body);
90
90
  },
@@ -108,7 +108,7 @@ const tools = [
108
108
  },
109
109
  {
110
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.",
111
+ description: "Return the contents of a file by id. Returns the raw source — HTML for `format=html` files, markdown for `format=markdown` files. The response is a text block; the MCP client can save it, render it, or feed it back into a tool call.",
112
112
  inputSchema: {
113
113
  type: "object",
114
114
  additionalProperties: false,
@@ -119,17 +119,18 @@ const tools = [
119
119
  },
120
120
  handler: async (input) => {
121
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}`);
122
+ const { content, name, format } = await client.getFileContent(id);
123
+ const ext = format === "markdown" ? "md" : "html";
124
+ return text(`Filename: ${name}.${ext}\nFormat: ${format}\n\n${content}`);
124
125
  },
125
126
  },
126
127
  {
127
128
  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
+ description: "Upload a new file. `format` may be \"html\" (default) or \"markdown\"; pass the bytes in `content`. 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
130
  inputSchema: {
130
131
  type: "object",
131
132
  additionalProperties: false,
132
- required: ["name", "html"],
133
+ required: ["name", "content"],
133
134
  properties: {
134
135
  name: { type: "string", minLength: 1, maxLength: 200 },
135
136
  description: { type: "string", maxLength: 2000 },
@@ -138,7 +139,8 @@ const tools = [
138
139
  type: "string",
139
140
  enum: ["public", "authenticated", "inherit", "individual"],
140
141
  },
141
- html: { type: "string" },
142
+ format: { type: "string", enum: ["html", "markdown"] },
143
+ content: { type: "string" },
142
144
  },
143
145
  },
144
146
  handler: async (input) => {
@@ -150,11 +152,12 @@ const tools = [
150
152
  protection: z
151
153
  .enum(["public", "authenticated", "inherit", "individual"])
152
154
  .optional(),
153
- html: z.string().min(1),
155
+ format: z.enum(["html", "markdown"]).optional(),
156
+ content: z.string().min(1),
154
157
  })
155
158
  .parse(input);
156
159
  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}`);
160
+ return text(`Created ${file.format} file ${file.id} (${file.name}) — ${formatBytes(file.sizeBytes)}\n${BASE_URL}/app/files/${file.id}`);
158
161
  },
159
162
  },
160
163
  {
@@ -203,7 +206,7 @@ const tools = [
203
206
  ];
204
207
  const server = new Server({
205
208
  name: "@reidar80/webshelf-mcp",
206
- version: "0.1.0",
209
+ version: "0.2.2",
207
210
  }, {
208
211
  capabilities: {
209
212
  tools: {},
@@ -252,7 +255,57 @@ function formatBytes(n) {
252
255
  return `${(n / 1024).toFixed(1)} KB`;
253
256
  return `${(n / (1024 * 1024)).toFixed(2)} MB`;
254
257
  }
258
+ async function runCliAuthFlow() {
259
+ // Manual / interactive authorization: kick off a device flow, print
260
+ // the verification URL to stdout, and poll until the user approves
261
+ // (or the device_code expires). Reuses the same `ensureCredentials`
262
+ // surface as the stdio handlers, but in a loop so the CLI can sit
263
+ // and wait — the 4-minute MCP-host timeout doesn't apply here.
264
+ const { ensureCredentials, DeviceFlowPendingError } = await import("./auth.js");
265
+ process.stdout.write("\n┌─ Webshelf authorization ──────────────────\n");
266
+ // Each iteration: ensureCredentials either returns creds (done) or
267
+ // throws DeviceFlowPendingError (still waiting). We poll every 3s.
268
+ const pollIntervalMs = 3000;
269
+ // RFC 8628 device-code TTL ceiling; we'll give up earlier if the
270
+ // server signals expiry via ensureCredentials throwing a non-pending
271
+ // error and re-starting the flow.
272
+ const deadline = Date.now() + 11 * 60 * 1000;
273
+ let lastPrinted = null;
274
+ while (Date.now() < deadline) {
275
+ try {
276
+ await ensureCredentials(BASE_URL, CLIENT_NAME);
277
+ process.stdout.write("│ Authorization complete. Token stored.\n└────────────────────────────────────────\n");
278
+ return;
279
+ }
280
+ catch (err) {
281
+ if (err instanceof DeviceFlowPendingError) {
282
+ if (lastPrinted !== err.verificationUriComplete) {
283
+ process.stdout.write(`│ Open this URL in your browser:\n`);
284
+ process.stdout.write(`│ ${err.verificationUriComplete}\n`);
285
+ process.stdout.write(`│ Or visit ${err.verificationUri}\n`);
286
+ process.stdout.write(`│ and enter code ${err.userCode}\n│\n`);
287
+ process.stdout.write(`│ Waiting for approval…\n`);
288
+ lastPrinted = err.verificationUriComplete;
289
+ }
290
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
291
+ continue;
292
+ }
293
+ throw err;
294
+ }
295
+ }
296
+ throw new Error("device code expired before user approval");
297
+ }
255
298
  async function main() {
299
+ // If a human is at the terminal (no MCP host piping stdio) and the
300
+ // first positional arg is `auth`, or stdin is a TTY without args at
301
+ // all, run the device flow eagerly so they can authorize from the
302
+ // command line. Otherwise speak stdio MCP as normal.
303
+ const arg = process.argv[2];
304
+ const interactive = arg === "auth" || (!arg && process.stdin.isTTY === true);
305
+ if (interactive) {
306
+ await runCliAuthFlow();
307
+ return;
308
+ }
256
309
  const transport = new StdioServerTransport();
257
310
  await server.connect(transport);
258
311
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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.",
3
+ "version": "0.2.2",
4
+ "description": "Model Context Protocol server for Webshelf — list, read, upload and manage HTML and markdown files hosted on webshelf.app from any MCP-aware client.",
5
5
  "keywords": [
6
6
  "mcp",
7
7
  "model-context-protocol",
@@ -12,11 +12,10 @@
12
12
  "homepage": "https://webshelf.app",
13
13
  "repository": {
14
14
  "type": "git",
15
- "url": "git+https://github.com/reidar80/webshelf.git",
16
- "directory": "mcp"
15
+ "url": "git+https://github.com/reidar80/webshelf-mcp.git"
17
16
  },
18
17
  "bugs": {
19
- "url": "https://github.com/reidar80/webshelf/issues"
18
+ "url": "https://github.com/reidar80/webshelf-mcp/issues"
20
19
  },
21
20
  "license": "MIT",
22
21
  "author": "Webshelf",