@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 +80 -17
- package/dist/api.d.ts +11 -2
- package/dist/api.js +12 -5
- package/dist/auth.d.ts +45 -14
- package/dist/auth.js +183 -78
- package/dist/index.d.ts +2 -2
- package/dist/index.js +65 -12
- package/package.json +4 -5
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.)
|
|
5
|
-
upload and manage HTML files on your Webshelf
|
|
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
|
-
##
|
|
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
|
|
38
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
76
|
-
|
|
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.
|
|
92
|
-
the MCP client name + version, the tool you were calling, and
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
65
|
-
return { name: json.name,
|
|
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
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
|
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
|
|
40
|
-
*
|
|
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
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
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:
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
.
|
|
108
|
-
.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
152
|
-
*
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
123
|
-
|
|
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
|
|
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", "
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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",
|