@drop2run/mcp 0.3.0 → 0.4.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 +30 -18
- package/dist/index.js +406 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,32 +33,31 @@ Nothing is installed globally either way — `npx` fetches the package when the
|
|
|
33
33
|
|
|
34
34
|
## Sign in
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
Ask for a publish. If there is no token yet, Claude calls the `login` tool, a browser tab opens, you
|
|
37
|
+
approve the sign-in, and the publish carries on. Nothing to install, nothing to paste.
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
The token lands in `~/.config/drop2run/config.json` with mode `0600` — the same file the `drop2run`
|
|
40
|
+
CLI uses, so signing in once covers both. It is read on every call rather than at startup, so it takes
|
|
41
|
+
effect without restarting the client.
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
Or create a token at <https://dropto.run/account/tokens> and set it in the environment:
|
|
43
|
+
**No browser on this machine?** In a container, over SSH, on a remote host, `login_code` gives a short
|
|
44
|
+
code to enter at <https://dropto.run/device> from any other device.
|
|
46
45
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
**Or set a token yourself.** Create one at <https://dropto.run/account/tokens> and either put it in
|
|
47
|
+
`~/.config/drop2run/config.json` as `{"token": "d2r_..."}`, which needs no restart, or set
|
|
48
|
+
`DROP2RUN_TOKEN` in the environment the client starts the server in. The environment takes precedence
|
|
49
|
+
over the file — and because it is read once at startup, setting it in a shell afterwards changes
|
|
50
|
+
nothing until the client restarts the server.
|
|
50
51
|
|
|
51
52
|
A token is shown once and stored as a hash, so it cannot be read back later — create a new one if you
|
|
52
|
-
lose it.
|
|
53
|
-
reads, so signing in once covers both.
|
|
54
|
-
|
|
55
|
-
The token is read on every call rather than at startup, so one added while the client is running takes
|
|
56
|
-
effect without a restart.
|
|
53
|
+
lose it.
|
|
57
54
|
|
|
58
55
|
## The tools
|
|
59
56
|
|
|
60
57
|
| Tool | What it does |
|
|
61
58
|
|---|---|
|
|
59
|
+
| `login` | Signs in through a browser on this machine and stores the token |
|
|
60
|
+
| `login_code` | Signs in with a short code approved elsewhere, where no browser can be opened |
|
|
62
61
|
| `publish_files` | Publishes files Claude wrote — a page, a markdown note, several files together |
|
|
63
62
|
| `publish_dir` | Publishes a folder, given its absolute path |
|
|
64
63
|
| `list_sites` | Lists the sites on the account |
|
|
@@ -66,6 +65,10 @@ effect without a restart.
|
|
|
66
65
|
Both publish tools take an optional `site` — a subdomain or site id to publish over. Leave it out and a
|
|
67
66
|
new site is created.
|
|
68
67
|
|
|
68
|
+
Both sign-in tools answer "not approved yet" rather than failing while they wait, and calling them
|
|
69
|
+
again keeps waiting on the same sign-in. Neither replaces a token that is already stored unless asked
|
|
70
|
+
to with `replace`.
|
|
71
|
+
|
|
69
72
|
## Behaviour worth knowing
|
|
70
73
|
|
|
71
74
|
**Publishing without a `site` creates a new one.** It never replaces your most recent site by default:
|
|
@@ -96,8 +99,17 @@ needs no credential from us, and none is sent there.
|
|
|
96
99
|
|
|
97
100
|
## If something is wrong
|
|
98
101
|
|
|
99
|
-
**"No Drop2Run access token."** — the server is running but has no token.
|
|
100
|
-
|
|
102
|
+
**"No Drop2Run access token."** — the server is running but has no token. Ask Claude to sign in, which
|
|
103
|
+
calls `login`; or run `npx drop2run login` yourself. Either writes the same file, and no restart is
|
|
104
|
+
needed.
|
|
105
|
+
|
|
106
|
+
**The browser never opened.** `login` says so and returns the URL instead — open it anywhere on this
|
|
107
|
+
machine, then ask Claude to call `login` again. The URL stays valid. On a machine with no browser at
|
|
108
|
+
all, use `login_code`.
|
|
109
|
+
|
|
110
|
+
**You signed in but publishing is still refused.** Check for `DROP2RUN_TOKEN` in the environment the
|
|
111
|
+
client started the server in: it outranks the file that was just written. `login` says so when it finds
|
|
112
|
+
one.
|
|
101
113
|
|
|
102
114
|
**Claude Desktop cannot start it** — Desktop does not inherit your shell's `PATH`. Use the absolute path
|
|
103
115
|
to `npx` (`which npx`) as `command`.
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
import { join, relative, sep } from "node:path";
|
|
2
|
+
import { readFileSync, mkdirSync, writeFileSync, chmodSync, renameSync } from "node:fs";
|
|
3
|
+
import { homedir, hostname } from "node:os";
|
|
4
|
+
import { join, dirname, relative, sep } from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
7
|
+
import { createServer as createServer$1 } from "node:http";
|
|
5
8
|
import { realpath, readdir, stat, readFile } from "node:fs/promises";
|
|
6
9
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
10
|
import { z } from "zod";
|
|
@@ -50,20 +53,199 @@ function loadCredentials(env = process.env, read = (path) => readFileSync(path,
|
|
|
50
53
|
apiBaseUrl: env.DROP2RUN_API_URL?.trim() || stored.apiBaseUrl?.trim() || DEFAULT_API_BASE_URL
|
|
51
54
|
};
|
|
52
55
|
}
|
|
53
|
-
function
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
function resolveApiBaseUrl(env = process.env, read = (path) => readFileSync(path, "utf8")) {
|
|
57
|
+
let stored = {};
|
|
58
|
+
try {
|
|
59
|
+
stored = JSON.parse(read(configPath()));
|
|
60
|
+
} catch {
|
|
61
|
+
}
|
|
62
|
+
const url = env.DROP2RUN_API_URL?.trim() || stored.apiBaseUrl?.trim() || DEFAULT_API_BASE_URL;
|
|
63
|
+
return url.replace(/\/+$/, "");
|
|
64
|
+
}
|
|
65
|
+
function dashboardUrlFor(apiBaseUrl) {
|
|
66
|
+
return apiBaseUrl.replace(/\/+$/, "").replace(/\/api$/, "");
|
|
67
|
+
}
|
|
68
|
+
function saveToken(token, path = configPath()) {
|
|
69
|
+
let existing = {};
|
|
70
|
+
try {
|
|
71
|
+
existing = JSON.parse(readFileSync(path, "utf8"));
|
|
72
|
+
} catch {
|
|
73
|
+
}
|
|
74
|
+
mkdirSync(dirname(path), { recursive: true, mode: 448 });
|
|
75
|
+
const temporary = `${path}.tmp`;
|
|
76
|
+
writeFileSync(temporary, `${JSON.stringify({ ...existing, token }, null, " ")}
|
|
77
|
+
`, {
|
|
78
|
+
mode: 384
|
|
79
|
+
});
|
|
80
|
+
chmodSync(temporary, 384);
|
|
81
|
+
renameSync(temporary, path);
|
|
82
|
+
return path;
|
|
83
|
+
}
|
|
84
|
+
function missingCredentialsMessage(surface = "cli") {
|
|
85
|
+
const signIn = surface === "mcp" ? [
|
|
86
|
+
"Call the `login` tool to sign in through a browser, which stores a token.",
|
|
58
87
|
"",
|
|
88
|
+
"Where no browser can be opened on this machine — a container, a remote host — call",
|
|
89
|
+
"`login_code` instead and pass on the short code it gives you."
|
|
90
|
+
] : ["Run `drop2run login` to sign in through a browser, which stores one for you."];
|
|
91
|
+
const byHand = surface === "mcp" ? [
|
|
92
|
+
"To set one without signing in here, create a token at",
|
|
93
|
+
"https://dropto.run/account/tokens and either:",
|
|
94
|
+
` - put it in ${configPath()} as {"token": "d2r_..."}, which this`,
|
|
95
|
+
" server reads on every call and needs no restart, or",
|
|
96
|
+
` - set ${TOKEN_VARIABLE} in the environment and restart this server, since it reads`,
|
|
97
|
+
" the environment it was started in."
|
|
98
|
+
] : [
|
|
59
99
|
"For CI, or where no browser can be opened, create a token at",
|
|
60
100
|
"https://dropto.run/account/tokens and either:",
|
|
61
101
|
` - set ${TOKEN_VARIABLE} in the environment, or`,
|
|
62
|
-
` - put it in ${configPath()} as {"token": "d2r_..."}
|
|
102
|
+
` - put it in ${configPath()} as {"token": "d2r_..."}`
|
|
103
|
+
];
|
|
104
|
+
return [
|
|
105
|
+
"No Drop2Run access token.",
|
|
106
|
+
"",
|
|
107
|
+
...signIn,
|
|
108
|
+
"",
|
|
109
|
+
...byHand,
|
|
63
110
|
"",
|
|
64
111
|
"The token is shown once when it is created and cannot be recovered afterwards."
|
|
65
112
|
].join("\n");
|
|
66
113
|
}
|
|
114
|
+
function newAttempt() {
|
|
115
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
116
|
+
return {
|
|
117
|
+
verifier,
|
|
118
|
+
challenge: createHash("sha256").update(verifier).digest("base64url"),
|
|
119
|
+
state: randomBytes(16).toString("base64url")
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function clientName(surface) {
|
|
123
|
+
const clean2 = (value) => value.trim().replace(/[^A-Za-z0-9._-]/g, "-");
|
|
124
|
+
const label = `-${clean2(surface).slice(0, 16)}`;
|
|
125
|
+
const raw = clean2(hostname()).slice(0, 64 - label.length);
|
|
126
|
+
return `${raw === "" ? "drop2run" : raw}${label}`;
|
|
127
|
+
}
|
|
128
|
+
function openBrowser(url) {
|
|
129
|
+
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
130
|
+
try {
|
|
131
|
+
const child = spawn(command, [...args], { stdio: "ignore", detached: true });
|
|
132
|
+
child.unref();
|
|
133
|
+
child.on("error", () => {
|
|
134
|
+
});
|
|
135
|
+
return true;
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function listen(state) {
|
|
141
|
+
return new Promise((resolveListener, rejectListener) => {
|
|
142
|
+
let settle = null;
|
|
143
|
+
let fail = null;
|
|
144
|
+
const received = new Promise((resolveCode, rejectCode) => {
|
|
145
|
+
settle = resolveCode;
|
|
146
|
+
fail = rejectCode;
|
|
147
|
+
});
|
|
148
|
+
const server = createServer$1((request2, response) => {
|
|
149
|
+
const url = new URL(request2.url ?? "/", "http://127.0.0.1");
|
|
150
|
+
if (url.pathname !== "/cb") {
|
|
151
|
+
response.writeHead(404, { "Content-Type": "text/plain" });
|
|
152
|
+
response.end("Not found.\n");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const code = url.searchParams.get("code") ?? "";
|
|
156
|
+
const returned = url.searchParams.get("state") ?? "";
|
|
157
|
+
if (returned !== state || code === "") {
|
|
158
|
+
respond(
|
|
159
|
+
response,
|
|
160
|
+
400,
|
|
161
|
+
"Sign-in could not be completed",
|
|
162
|
+
"This did not come from the sign-in that was started here. Nothing has been changed."
|
|
163
|
+
);
|
|
164
|
+
fail?.(new Error("The browser returned a response that did not match this sign-in."));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
respond(response, 200, "Signed in", "You can close this tab now.");
|
|
168
|
+
settle?.({ code });
|
|
169
|
+
});
|
|
170
|
+
server.on("error", (error) => {
|
|
171
|
+
rejectListener(error);
|
|
172
|
+
fail?.(error instanceof Error ? error : new Error(String(error)));
|
|
173
|
+
});
|
|
174
|
+
server.listen(0, "127.0.0.1", () => {
|
|
175
|
+
const address = server.address();
|
|
176
|
+
if (address === null) {
|
|
177
|
+
rejectListener(new Error("Could not open a local port to receive the sign-in."));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
resolveListener({
|
|
181
|
+
port: address.port,
|
|
182
|
+
received,
|
|
183
|
+
close: () => server.close()
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
function respond(response, status, title, detail) {
|
|
189
|
+
response.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
|
|
190
|
+
response.end(
|
|
191
|
+
`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title} — drop2run</title><style>body{font:16px/1.6 system-ui,sans-serif;margin:20vh auto;max-width:28rem;padding:0 1.5rem;color:#12211f}h1{font-size:1.4rem;margin:0 0 .5rem}p{margin:0;color:#64706e}@media(prefers-color-scheme:dark){body{background:#0d1413;color:#e8efee}p{color:#9aa8a6}}</style></head><body><h1>${title}</h1><p>${detail}</p></body></html>
|
|
192
|
+
`
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
function consentUrl(dashboardUrl, attempt2, port, name) {
|
|
196
|
+
const url = new URL("/cli-auth", dashboardUrl);
|
|
197
|
+
url.searchParams.set("challenge", attempt2.challenge);
|
|
198
|
+
url.searchParams.set("state", attempt2.state);
|
|
199
|
+
url.searchParams.set("port", String(port));
|
|
200
|
+
url.searchParams.set("name", name);
|
|
201
|
+
return url.toString();
|
|
202
|
+
}
|
|
203
|
+
async function startDevice(apiBaseUrl, clientName2) {
|
|
204
|
+
const response = await fetch(`${apiBaseUrl}/auth/cli/device`, {
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers: { "Content-Type": "application/json" },
|
|
207
|
+
body: JSON.stringify({ clientName: clientName2 })
|
|
208
|
+
});
|
|
209
|
+
if (!response.ok) throw new Error(await problemDetail(response));
|
|
210
|
+
return await response.json();
|
|
211
|
+
}
|
|
212
|
+
async function pollDevice(apiBaseUrl, deviceCode) {
|
|
213
|
+
let response;
|
|
214
|
+
try {
|
|
215
|
+
response = await fetch(`${apiBaseUrl}/auth/cli/device/token`, {
|
|
216
|
+
method: "POST",
|
|
217
|
+
headers: { "Content-Type": "application/json" },
|
|
218
|
+
body: JSON.stringify({ deviceCode })
|
|
219
|
+
});
|
|
220
|
+
} catch {
|
|
221
|
+
return { state: "pending" };
|
|
222
|
+
}
|
|
223
|
+
if (response.ok) {
|
|
224
|
+
const body = await response.json();
|
|
225
|
+
return { state: "granted", token: { token: body.token, name: body.name, email: body.email } };
|
|
226
|
+
}
|
|
227
|
+
const problem = await response.json().catch(() => null);
|
|
228
|
+
if (problem?.type === "authorization_pending") return { state: "pending" };
|
|
229
|
+
if (problem?.type === "slow_down") return { state: "slow_down" };
|
|
230
|
+
return {
|
|
231
|
+
state: "dead",
|
|
232
|
+
message: problem?.detail ?? `The API answered ${response.status}.`
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
async function problemDetail(response) {
|
|
236
|
+
const problem = await response.json().catch(() => null);
|
|
237
|
+
return problem?.detail ?? `The API answered ${response.status}.`;
|
|
238
|
+
}
|
|
239
|
+
async function exchange(apiBaseUrl, code, verifier) {
|
|
240
|
+
const response = await fetch(`${apiBaseUrl}/auth/cli/token`, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: { "Content-Type": "application/json" },
|
|
243
|
+
body: JSON.stringify({ code, verifier })
|
|
244
|
+
});
|
|
245
|
+
if (!response.ok) throw new Error(await problemDetail(response));
|
|
246
|
+
const body = await response.json();
|
|
247
|
+
return { token: body.token, name: body.name, email: body.email };
|
|
248
|
+
}
|
|
67
249
|
class DeployError extends Error {
|
|
68
250
|
/**
|
|
69
251
|
* @param code Stable code, either an API error type (`quota_exceeded`, `unsafe_path`, …) or one of
|
|
@@ -446,10 +628,10 @@ async function uploadAll(targets, uploadUrl, files, onProgress, signal, deps = {
|
|
|
446
628
|
}
|
|
447
629
|
async function uploadOne(target, file, context) {
|
|
448
630
|
let lastError;
|
|
449
|
-
for (let
|
|
631
|
+
for (let attempt2 = 0; attempt2 <= context.retries; attempt2++) {
|
|
450
632
|
context.signal?.throwIfAborted();
|
|
451
|
-
if (
|
|
452
|
-
await context.delay(BACKOFF_MS[
|
|
633
|
+
if (attempt2 > 0) {
|
|
634
|
+
await context.delay(BACKOFF_MS[attempt2 - 1] ?? BACKOFF_MS.at(-1) ?? 1e3);
|
|
453
635
|
context.signal?.throwIfAborted();
|
|
454
636
|
}
|
|
455
637
|
try {
|
|
@@ -669,15 +851,194 @@ async function publishFiles(credentials, files, site) {
|
|
|
669
851
|
}
|
|
670
852
|
return publish(credentials, async () => collected, site);
|
|
671
853
|
}
|
|
672
|
-
const
|
|
854
|
+
const DEFAULT_WAIT_SECONDS = 120;
|
|
855
|
+
const MAX_WAIT_SECONDS = 240;
|
|
856
|
+
const ATTEMPT_LIFETIME_MS = 15 * 60 * 1e3;
|
|
857
|
+
const MAX_POLL_MS = 30 * 1e3;
|
|
858
|
+
let pendingBrowser = null;
|
|
859
|
+
let pendingDevice = null;
|
|
860
|
+
function dropPendingBrowser() {
|
|
861
|
+
pendingBrowser?.listener.close();
|
|
862
|
+
pendingBrowser = null;
|
|
863
|
+
}
|
|
864
|
+
function waitSecondsFor(requested) {
|
|
865
|
+
if (requested === void 0) return DEFAULT_WAIT_SECONDS;
|
|
866
|
+
return Math.min(MAX_WAIT_SECONDS, Math.max(1, Math.floor(requested)));
|
|
867
|
+
}
|
|
868
|
+
function alreadySignedIn() {
|
|
869
|
+
const credentials = loadCredentials();
|
|
870
|
+
if (credentials === null) return null;
|
|
871
|
+
return [
|
|
872
|
+
"Already signed in — a token is stored, so publishing should work.",
|
|
873
|
+
"",
|
|
874
|
+
`API: ${credentials.apiBaseUrl}`,
|
|
875
|
+
"",
|
|
876
|
+
"If publishing is refused as unauthorized, or this is the wrong account, call this tool again",
|
|
877
|
+
"with replace set to true to sign in over it."
|
|
878
|
+
].join("\n");
|
|
879
|
+
}
|
|
880
|
+
function environmentOverrideNote() {
|
|
881
|
+
if (!process.env[TOKEN_VARIABLE]?.trim()) return "";
|
|
882
|
+
return [
|
|
883
|
+
"",
|
|
884
|
+
`Note: ${TOKEN_VARIABLE} is set in this server's environment and outranks the stored file, so`,
|
|
885
|
+
"that token is still the one in force. Unset it and restart this server to use the new one."
|
|
886
|
+
].join("\n");
|
|
887
|
+
}
|
|
888
|
+
function signedIn(email, name, path) {
|
|
889
|
+
return [
|
|
890
|
+
`Signed in${email === null ? "" : ` as ${email}`}.`,
|
|
891
|
+
"",
|
|
892
|
+
`Token "${name}" saved to ${path}. Publishing works from the next call — nothing needs`,
|
|
893
|
+
"restarting.",
|
|
894
|
+
environmentOverrideNote()
|
|
895
|
+
].join("\n");
|
|
896
|
+
}
|
|
897
|
+
async function signInWithBrowser(waitSeconds, replace = false, open = openBrowser) {
|
|
898
|
+
if (!replace) {
|
|
899
|
+
const already = alreadySignedIn();
|
|
900
|
+
if (already !== null) return already;
|
|
901
|
+
}
|
|
902
|
+
if (pendingBrowser !== null && pendingBrowser.expiresAt <= Date.now()) dropPendingBrowser();
|
|
903
|
+
let current;
|
|
904
|
+
let resumed;
|
|
905
|
+
if (pendingBrowser === null) {
|
|
906
|
+
resumed = false;
|
|
907
|
+
const apiBaseUrl = resolveApiBaseUrl();
|
|
908
|
+
const attempt2 = newAttempt();
|
|
909
|
+
let listener;
|
|
910
|
+
try {
|
|
911
|
+
listener = await listen(attempt2.state);
|
|
912
|
+
} catch (error) {
|
|
913
|
+
throw new Error(
|
|
914
|
+
`Could not open a local port to receive the sign-in: ${error instanceof Error ? error.message : String(error)}. Call login_code instead, which needs no port.`
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
current = {
|
|
918
|
+
attempt: attempt2,
|
|
919
|
+
listener,
|
|
920
|
+
url: consentUrl(dashboardUrlFor(apiBaseUrl), attempt2, listener.port, clientName("mcp")),
|
|
921
|
+
apiBaseUrl,
|
|
922
|
+
expiresAt: Date.now() + ATTEMPT_LIFETIME_MS
|
|
923
|
+
};
|
|
924
|
+
pendingBrowser = current;
|
|
925
|
+
current.listener.received.catch(() => {
|
|
926
|
+
});
|
|
927
|
+
if (!open(current.url)) {
|
|
928
|
+
return [
|
|
929
|
+
"Could not open a browser here. Ask the person to open this URL and approve the sign-in:",
|
|
930
|
+
"",
|
|
931
|
+
current.url,
|
|
932
|
+
"",
|
|
933
|
+
"Then call this tool again to collect the token. The URL stays valid until then."
|
|
934
|
+
].join("\n");
|
|
935
|
+
}
|
|
936
|
+
} else {
|
|
937
|
+
resumed = true;
|
|
938
|
+
current = pendingBrowser;
|
|
939
|
+
}
|
|
940
|
+
try {
|
|
941
|
+
const waited = await waitOrTimeOut(
|
|
942
|
+
current.listener.received,
|
|
943
|
+
waitSecondsFor(waitSeconds) * 1e3
|
|
944
|
+
);
|
|
945
|
+
if (waited === null) {
|
|
946
|
+
return [
|
|
947
|
+
resumed ? "Still not approved." : "A browser has been opened for the sign-in. Not approved yet.",
|
|
948
|
+
"",
|
|
949
|
+
current.url,
|
|
950
|
+
"",
|
|
951
|
+
"Call this tool again to keep waiting. Nothing has been changed yet."
|
|
952
|
+
].join("\n");
|
|
953
|
+
}
|
|
954
|
+
const issued = await exchange(current.apiBaseUrl, waited.code, current.attempt.verifier);
|
|
955
|
+
const path = saveToken(issued.token);
|
|
956
|
+
dropPendingBrowser();
|
|
957
|
+
return signedIn(issued.email, issued.name, path);
|
|
958
|
+
} catch (error) {
|
|
959
|
+
dropPendingBrowser();
|
|
960
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
async function waitOrTimeOut(received, timeoutMs) {
|
|
964
|
+
let timer;
|
|
965
|
+
try {
|
|
966
|
+
return await Promise.race([
|
|
967
|
+
received,
|
|
968
|
+
new Promise((resolve) => {
|
|
969
|
+
timer = setTimeout(() => resolve(null), timeoutMs);
|
|
970
|
+
})
|
|
971
|
+
]);
|
|
972
|
+
} finally {
|
|
973
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
async function signInWithCode(waitSeconds, replace = false) {
|
|
977
|
+
if (!replace) {
|
|
978
|
+
const already = alreadySignedIn();
|
|
979
|
+
if (already !== null) return already;
|
|
980
|
+
}
|
|
981
|
+
if (pendingDevice !== null && pendingDevice.expiresAt <= Date.now()) pendingDevice = null;
|
|
982
|
+
if (pendingDevice === null) {
|
|
983
|
+
const apiBaseUrl = resolveApiBaseUrl();
|
|
984
|
+
const request2 = await startDevice(apiBaseUrl, clientName("mcp"));
|
|
985
|
+
pendingDevice = {
|
|
986
|
+
deviceCode: request2.deviceCode,
|
|
987
|
+
userCode: request2.userCode,
|
|
988
|
+
verificationUri: request2.verificationUri,
|
|
989
|
+
verificationUriComplete: request2.verificationUriComplete,
|
|
990
|
+
apiBaseUrl,
|
|
991
|
+
expiresAt: Date.now() + ATTEMPT_LIFETIME_MS,
|
|
992
|
+
waitMs: Math.max(1, request2.intervalSeconds) * 1e3
|
|
993
|
+
};
|
|
994
|
+
return [
|
|
995
|
+
`Ask the person to open ${request2.verificationUri} and enter this code:`,
|
|
996
|
+
"",
|
|
997
|
+
` ${request2.userCode}`,
|
|
998
|
+
"",
|
|
999
|
+
`Or send them this link, which has the code in it: ${request2.verificationUriComplete}`,
|
|
1000
|
+
"",
|
|
1001
|
+
"Then call this tool again to wait for the approval and collect the token."
|
|
1002
|
+
].join("\n");
|
|
1003
|
+
}
|
|
1004
|
+
const pending = pendingDevice;
|
|
1005
|
+
const deadline = Date.now() + waitSecondsFor(waitSeconds) * 1e3;
|
|
1006
|
+
while (Date.now() < deadline) {
|
|
1007
|
+
await new Promise((done) => {
|
|
1008
|
+
setTimeout(done, pending.waitMs);
|
|
1009
|
+
});
|
|
1010
|
+
const poll = await pollDevice(pending.apiBaseUrl, pending.deviceCode);
|
|
1011
|
+
if (poll.state === "granted") {
|
|
1012
|
+
const path = saveToken(poll.token.token);
|
|
1013
|
+
pendingDevice = null;
|
|
1014
|
+
return signedIn(poll.token.email, poll.token.name, path);
|
|
1015
|
+
}
|
|
1016
|
+
if (poll.state === "dead") {
|
|
1017
|
+
pendingDevice = null;
|
|
1018
|
+
throw new Error(poll.message);
|
|
1019
|
+
}
|
|
1020
|
+
if (poll.state === "slow_down") pending.waitMs = Math.min(pending.waitMs * 2, MAX_POLL_MS);
|
|
1021
|
+
}
|
|
1022
|
+
return [
|
|
1023
|
+
"Not approved yet.",
|
|
1024
|
+
"",
|
|
1025
|
+
`Code ${pending.userCode}, at ${pending.verificationUri}.`,
|
|
1026
|
+
"",
|
|
1027
|
+
"Call this tool again to keep waiting. Nothing has been changed yet."
|
|
1028
|
+
].join("\n");
|
|
1029
|
+
}
|
|
1030
|
+
const SERVER_VERSION = "0.4.0";
|
|
673
1031
|
function say(text, isError = false) {
|
|
674
1032
|
return { content: [{ type: "text", text }], ...isError ? { isError: true } : {} };
|
|
675
1033
|
}
|
|
676
1034
|
async function withCredentials(work) {
|
|
677
1035
|
const credentials = loadCredentials();
|
|
678
|
-
if (credentials === null) return say(missingCredentialsMessage(), true);
|
|
1036
|
+
if (credentials === null) return say(missingCredentialsMessage("mcp"), true);
|
|
1037
|
+
return attempt(() => work(credentials));
|
|
1038
|
+
}
|
|
1039
|
+
async function attempt(work) {
|
|
679
1040
|
try {
|
|
680
|
-
return say(await work(
|
|
1041
|
+
return say(await work());
|
|
681
1042
|
} catch (error) {
|
|
682
1043
|
return say(error instanceof Error ? error.message : String(error), true);
|
|
683
1044
|
}
|
|
@@ -708,11 +1069,41 @@ function createServer() {
|
|
|
708
1069
|
"which publishes as a documents site and is read through a viewer. So one page goes at",
|
|
709
1070
|
"index.html, and a single note is a whole publish that needs no wrapping in HTML.",
|
|
710
1071
|
"",
|
|
1072
|
+
"Publishing needs an access token. If there is none, call login — it opens a browser and",
|
|
1073
|
+
"stores one, and no command line or manual token is involved. Where no browser can be",
|
|
1074
|
+
"opened, login_code gives a short code to approve from another machine.",
|
|
1075
|
+
"",
|
|
711
1076
|
"Publishing over an existing site replaces what it serves, so ask before doing that to a",
|
|
712
1077
|
"site the person did not name."
|
|
713
1078
|
].join("\n")
|
|
714
1079
|
}
|
|
715
1080
|
);
|
|
1081
|
+
server.registerTool(
|
|
1082
|
+
"login",
|
|
1083
|
+
{
|
|
1084
|
+
title: "Sign in to Drop2Run",
|
|
1085
|
+
description: "Signs in through a browser on this machine and stores an access token, which every other tool then uses. Opens the browser here and waits for the person to approve. If it answers that nothing has been approved yet, call it again to keep waiting — that is not a failure. Needs no command line and no token pasted by hand.",
|
|
1086
|
+
inputSchema: {
|
|
1087
|
+
waitSeconds: z.number().int().min(5).max(MAX_WAIT_SECONDS).optional().describe("How long this call waits for the approval before answering. Default 120."),
|
|
1088
|
+
replace: z.boolean().optional().describe(
|
|
1089
|
+
"Sign in even though a token is already stored. Use when switching accounts, or when the stored token is being refused."
|
|
1090
|
+
)
|
|
1091
|
+
}
|
|
1092
|
+
},
|
|
1093
|
+
({ waitSeconds, replace }) => attempt(() => signInWithBrowser(waitSeconds, replace))
|
|
1094
|
+
);
|
|
1095
|
+
server.registerTool(
|
|
1096
|
+
"login_code",
|
|
1097
|
+
{
|
|
1098
|
+
title: "Sign in with a code",
|
|
1099
|
+
description: "Signs in where no browser can be opened on this machine — a container, a remote host. The first call returns a short code and a URL to enter it at, which the person opens anywhere; call it again to wait for the approval and store the token.",
|
|
1100
|
+
inputSchema: {
|
|
1101
|
+
waitSeconds: z.number().int().min(5).max(MAX_WAIT_SECONDS).optional().describe("How long a waiting call polls before answering. Default 120."),
|
|
1102
|
+
replace: z.boolean().optional().describe("Sign in even though a token is already stored.")
|
|
1103
|
+
}
|
|
1104
|
+
},
|
|
1105
|
+
({ waitSeconds, replace }) => attempt(() => signInWithCode(waitSeconds, replace))
|
|
1106
|
+
);
|
|
716
1107
|
server.registerTool(
|
|
717
1108
|
"publish_files",
|
|
718
1109
|
{
|