@fennecstudio/x-tkn-mcp 1.0.0 → 1.3.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 +57 -13
- package/dist/index.js +375 -45
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,9 +6,10 @@ Lets an AI agent hand a secret to a person as a one-time link, with no way to re
|
|
|
6
6
|
npx -y @fennecstudio/x-tkn-mcp
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
- **No read tool.** The server has `create_secret`, `check_status
|
|
10
|
-
`revoke_secret`. Once a secret is handed over, the agent
|
|
11
|
-
was opened and can cancel it. It cannot see the secret
|
|
9
|
+
- **No read tool.** The server has `create_secret`, `check_status`,
|
|
10
|
+
`revoke_secret` and `claim_sandbox`. Once a secret is handed over, the agent
|
|
11
|
+
can see whether it was opened and can cancel it. It cannot see the secret
|
|
12
|
+
again.
|
|
12
13
|
- **Encrypted on your machine.** `create_secret` encrypts with AES-256-GCM
|
|
13
14
|
before anything is sent. X-TKN stores only ciphertext. The key travels in the
|
|
14
15
|
link's `#fragment`, which browsers never send to a server.
|
|
@@ -22,18 +23,43 @@ recipient can read it.
|
|
|
22
23
|
|
|
23
24
|
## Setup
|
|
24
25
|
|
|
25
|
-
You need Node 20 or later
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
You need Node 20 or later. An X-TKN API key is optional.
|
|
27
|
+
|
|
28
|
+
**With no key**, the server starts a sandbox on its first tool call, with no
|
|
29
|
+
signup. Nothing is minted at startup. The key is saved in
|
|
30
|
+
`$XDG_CONFIG_HOME/x-tkn/sandbox.json` (`~/.config/x-tkn/sandbox.json` by
|
|
31
|
+
default), the directory 0700 and the file 0600, and later starts reuse it until
|
|
32
|
+
it expires. The key never appears in a tool result or in the server's log.
|
|
33
|
+
Servers that start together share one sandbox: while one mints, it holds a
|
|
34
|
+
`sandbox.json.lock` beside the file, and the others wait for it and use its key.
|
|
35
|
+
|
|
36
|
+
- A sandbox lasts 24 hours, and so does every secret it creates:
|
|
37
|
+
`create_secret` refuses an `expiresIn` that runs past the sandbox's end.
|
|
38
|
+
Every result on a sandbox key ends with a line giving that end.
|
|
39
|
+
- When it ends, the next call starts a new sandbox and says so. Codes from the
|
|
40
|
+
old one no longer work with `check_status` or `revoke_secret`.
|
|
41
|
+
- To keep a sandbox, call `claim_sandbox`. It returns a one-time link for the
|
|
42
|
+
person who should keep it, and the key never leaves this machine. A sandbox
|
|
43
|
+
has to be claimed before it can be upgraded. The sandbox key still ends on
|
|
44
|
+
schedule after a claim, so a claimed account goes on with a Developer key
|
|
45
|
+
created for it and set here.
|
|
46
|
+
- Once a sandbox has spent its operations, the server does not start another,
|
|
47
|
+
because that would lose the old one's codes. Call `claim_sandbox`, or set a
|
|
48
|
+
key.
|
|
49
|
+
|
|
50
|
+
**For a key that stays**, Developer keys are free: sign up at
|
|
51
|
+
[x-tkn.com](https://x-tkn.com) and create one in the account console. A key in
|
|
52
|
+
`X_TKN_API_KEY` always wins: then nothing is minted and nothing is written.
|
|
28
53
|
|
|
29
54
|
**Claude Code**
|
|
30
55
|
|
|
31
56
|
```bash
|
|
32
|
-
claude mcp add x-tkn
|
|
57
|
+
claude mcp add x-tkn -- npx -y @fennecstudio/x-tkn-mcp # a sandbox
|
|
58
|
+
claude mcp add x-tkn -e X_TKN_API_KEY=your-key -- npx -y @fennecstudio/x-tkn-mcp # your key
|
|
33
59
|
```
|
|
34
60
|
|
|
35
61
|
**Claude Desktop, Cursor, Windsurf and other clients** take the same block in
|
|
36
|
-
their MCP config file:
|
|
62
|
+
their MCP config file. Leave `env` out for a sandbox:
|
|
37
63
|
|
|
38
64
|
```json
|
|
39
65
|
{
|
|
@@ -47,9 +73,6 @@ their MCP config file:
|
|
|
47
73
|
}
|
|
48
74
|
```
|
|
49
75
|
|
|
50
|
-
Without a key the server still starts, and every tool call answers with how to
|
|
51
|
-
get one.
|
|
52
|
-
|
|
53
76
|
## Tools
|
|
54
77
|
|
|
55
78
|
### `create_secret`
|
|
@@ -59,12 +82,13 @@ Encrypts a secret and returns a link that opens it once.
|
|
|
59
82
|
| Argument | Required | What it does |
|
|
60
83
|
| ---------------- | -------- | -------------------------------------------------------------------------------------------------------------- |
|
|
61
84
|
| `secret` | yes | The text to hand over. |
|
|
62
|
-
| `expiresIn` | no | `{ days, hours, minutes }`. Defaults to the plan's: 7 days on Developer, 30 on Team.
|
|
85
|
+
| `expiresIn` | no | `{ days, hours, minutes }`. Defaults to the plan's: 7 days on Developer, 30 on Team. On a sandbox key, defaults to the sandbox's end and cannot be later. |
|
|
63
86
|
| `passphrase` | no | The recipient has to type this before the secret opens. 8 to 256 characters. Team and above. |
|
|
64
87
|
| `recipientEmail` | no | The recipient has to enter a 6-digit code sent to this address. Team and above. Not with `passphrase`. |
|
|
65
88
|
| `note` | no | For your own records in the X-TKN console. Stored unencrypted and never shown to the recipient. |
|
|
66
89
|
|
|
67
|
-
Returns the `link`, a `code` for
|
|
90
|
+
Returns the `link`, a `code` for `check_status` and `revoke_secret`, and
|
|
91
|
+
`expiresAt`.
|
|
68
92
|
|
|
69
93
|
Send a passphrase by a different channel than the link. Anyone holding both can
|
|
70
94
|
open the secret.
|
|
@@ -74,11 +98,31 @@ open the secret.
|
|
|
74
98
|
Takes the `code`. Returns `waiting`, `opened`, `expired` or `revoked`, with
|
|
75
99
|
`openedAt` once it has been opened. It never returns the secret.
|
|
76
100
|
|
|
101
|
+
Each call is one metered operation, and a sandbox has 100 in all. Call it no
|
|
102
|
+
more often than every 30 seconds: at one call every 5 seconds, a link nobody
|
|
103
|
+
opens spends a whole sandbox in under nine minutes.
|
|
104
|
+
|
|
77
105
|
### `revoke_secret`
|
|
78
106
|
|
|
79
107
|
Takes the `code`. Stops the link working, immediately and for good. A secret
|
|
80
108
|
that was already opened stays opened.
|
|
81
109
|
|
|
110
|
+
### `claim_sandbox`
|
|
111
|
+
|
|
112
|
+
Takes no arguments. Returns a `claimUrl` and its `expiresAt`: a one-time link a
|
|
113
|
+
person opens to claim the sandbox this server's key belongs to, so the account
|
|
114
|
+
outlives its 24 hours. Claiming keeps the sandbox's secrets.
|
|
115
|
+
|
|
116
|
+
Give the link only to the person who should keep the sandbox. It works once,
|
|
117
|
+
until `expiresAt`: 30 minutes, or the sandbox's end if that is sooner. Calling
|
|
118
|
+
`claim_sandbox` again voids the earlier link. It is not metered.
|
|
119
|
+
|
|
120
|
+
The sandbox key still ends when the sandbox would have, claimed or not. For a
|
|
121
|
+
key that lasts, create a Developer key for the account in the account console
|
|
122
|
+
and set `X_TKN_API_KEY`. On a key that is not an unclaimed sandbox (one already
|
|
123
|
+
claimed, merged into another account or ended, or a Developer key), it says so
|
|
124
|
+
and claims nothing.
|
|
125
|
+
|
|
82
126
|
## What X-TKN sees
|
|
83
127
|
|
|
84
128
|
The ciphertext, the expiry, your `note`, and, if you set them, a hash of the
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
|
|
6
|
+
// src/env.ts
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { isAbsolute, join } from "node:path";
|
|
9
|
+
|
|
6
10
|
// ../js/src/errors.ts
|
|
7
11
|
var XTknError = class extends Error {
|
|
8
12
|
/** HTTP status. `0` when the request never got a response. */
|
|
@@ -211,12 +215,15 @@ var XTkn = class _XTkn {
|
|
|
211
215
|
* agent gets from `npm install` to a working request without a human
|
|
212
216
|
* clicking a link in an email.
|
|
213
217
|
*
|
|
214
|
-
* The account is capped and temporary: 100 operations
|
|
215
|
-
* `secret` tokens. {@link XTkn.claim}
|
|
216
|
-
*
|
|
218
|
+
* The account is capped and temporary: 100 operations and 24 hours. It may
|
|
219
|
+
* create `secret` tokens, none of which outlives it. {@link XTkn.claim}, or
|
|
220
|
+
* the link {@link XTkn.createClaimLink} returns, turns it into an ordinary
|
|
221
|
+
* Developer account with 10,000 operations a month and no expiry.
|
|
217
222
|
*
|
|
218
223
|
* `account.apiKey` is returned once and cannot be read back. Persist it if
|
|
219
|
-
* the process may restart before the account is claimed.
|
|
224
|
+
* the process may restart before the account is claimed. It stops working at
|
|
225
|
+
* `account.expiresAt` whether or not the account was claimed: a claimed
|
|
226
|
+
* account needs a Developer key of its own, created in the console.
|
|
220
227
|
*
|
|
221
228
|
* @example
|
|
222
229
|
* ```ts
|
|
@@ -243,9 +250,14 @@ var XTkn = class _XTkn {
|
|
|
243
250
|
* client authenticates as is the one claimed — there is no account id to
|
|
244
251
|
* pass and no way to name somebody else's.
|
|
245
252
|
*
|
|
246
|
-
* Existing tokens
|
|
247
|
-
*
|
|
248
|
-
*
|
|
253
|
+
* Existing tokens survive. The sandbox key still ends when the sandbox would
|
|
254
|
+
* have, so create a Developer key for the account. When that email already
|
|
255
|
+
* owns an account under the same sign-in, the sandbox is merged into it
|
|
256
|
+
* instead: the result has `merged: true` and that account's `customerId`,
|
|
257
|
+
* with no `class` or `monthlyOperations`, and this client's key now acts as
|
|
258
|
+
* that account until it lapses. Throws {@link XTknRequestError} with a 409
|
|
259
|
+
* when the email's account is under a different sign-in; sign in with that
|
|
260
|
+
* one, then claim again.
|
|
249
261
|
*
|
|
250
262
|
* @example
|
|
251
263
|
* ```ts
|
|
@@ -262,6 +274,32 @@ var XTkn = class _XTkn {
|
|
|
262
274
|
headers: { "X-Firebase-Token": firebaseIdToken }
|
|
263
275
|
});
|
|
264
276
|
}
|
|
277
|
+
/**
|
|
278
|
+
* Get a one-time link a person opens to claim this sandbox account.
|
|
279
|
+
*
|
|
280
|
+
* {@link XTkn.claim} needs the person's sign-in in your own code. This needs
|
|
281
|
+
* only the sandbox key, so an agent holding the key can hand its person the
|
|
282
|
+
* link instead. The claim is the same either way: existing tokens survive,
|
|
283
|
+
* and the sandbox key still ends when the sandbox would have.
|
|
284
|
+
*
|
|
285
|
+
* The link works once, until `expiresAt`: 30 minutes, or the sandbox's end
|
|
286
|
+
* if that is sooner. Minting another voids it. Not counted against the
|
|
287
|
+
* sandbox's operations.
|
|
288
|
+
*
|
|
289
|
+
* Throws {@link XTknError} with a 409 when this key's account is not an
|
|
290
|
+
* unclaimed sandbox: already claimed, merged into another account, ended, or
|
|
291
|
+
* never a sandbox.
|
|
292
|
+
*
|
|
293
|
+
* @example
|
|
294
|
+
* ```ts
|
|
295
|
+
* const { client } = await XTkn.createSandbox()
|
|
296
|
+
* const { claimUrl, expiresAt } = await client.createClaimLink()
|
|
297
|
+
* console.log(`Open this before ${expiresAt} to keep the sandbox: ${claimUrl}`)
|
|
298
|
+
* ```
|
|
299
|
+
*/
|
|
300
|
+
async createClaimLink() {
|
|
301
|
+
return this.request("POST", "/accounts/claim");
|
|
302
|
+
}
|
|
265
303
|
/**
|
|
266
304
|
* Create a token.
|
|
267
305
|
*
|
|
@@ -587,24 +625,23 @@ function unwrap(parsed) {
|
|
|
587
625
|
return parsed;
|
|
588
626
|
}
|
|
589
627
|
|
|
590
|
-
// src/
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
}
|
|
601
|
-
function clientFrom(env) {
|
|
602
|
-
return new XTkn({ apiKey: apiKeyFrom(env), headers: { "User-Agent": USER_AGENT } });
|
|
628
|
+
// src/connection.ts
|
|
629
|
+
function keyConnection(client) {
|
|
630
|
+
return {
|
|
631
|
+
async call(work) {
|
|
632
|
+
try {
|
|
633
|
+
return { ok: true, value: await work(client), notes: [] };
|
|
634
|
+
} catch (error) {
|
|
635
|
+
return { ok: false, error, origin: "key", notes: [] };
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
};
|
|
603
639
|
}
|
|
604
640
|
|
|
605
|
-
// src/
|
|
606
|
-
import {
|
|
607
|
-
import {
|
|
641
|
+
// src/sandbox.ts
|
|
642
|
+
import { randomBytes } from "node:crypto";
|
|
643
|
+
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
644
|
+
import { dirname } from "node:path";
|
|
608
645
|
|
|
609
646
|
// ../js/src/vendor/token-envelope.ts
|
|
610
647
|
var PREFIX_V1 = "xtkn.v1.";
|
|
@@ -723,8 +760,21 @@ function buildSecretLink(code2, key, options = {}) {
|
|
|
723
760
|
// src/tools.ts
|
|
724
761
|
var SECRET_TYPE = "secret";
|
|
725
762
|
var SIGNUP_URL = "https://x-tkn.com";
|
|
726
|
-
var
|
|
727
|
-
var
|
|
763
|
+
var SANDBOX_EXPIRY_REFUSAL = "Sandbox accounts cannot mint tokens that outlive them.";
|
|
764
|
+
var NO_KEY_MESSAGE = `No X-TKN API key. Set ${API_KEY_ENV_VARS[0]} in this MCP server's environment. To start now with no signup, POST to ${DEFAULT_BASE_URL}/accounts/sandbox for a sandbox key. It lasts 24 hours, and so does every secret it creates. For a key that stays, Developer keys are free: sign up at ${SIGNUP_URL} and create one in the account console.`;
|
|
765
|
+
var SANDBOX_KEY_MESSAGE = `This is a sandbox key. A sandbox account is deleted with its secrets 24 hours after it was created, so no secret it creates can expire later than that. Leave expiresIn out to get the latest expiry it allows. For longer, call claim_sandbox and give the link it returns to the person who should keep the account, or set ${API_KEY_ENV_VARS[0]} to a free Developer key from ${SIGNUP_URL}.`;
|
|
766
|
+
function sandboxStartupMessage(path) {
|
|
767
|
+
return `No ${API_KEY_ENV_VARS[0]} set, so this server uses a sandbox: the first tool call reuses the one saved in ${path}, or starts a new one that lasts 24 hours. For a key that stays, set ${API_KEY_ENV_VARS[0]} to a free Developer key from ${SIGNUP_URL}.`;
|
|
768
|
+
}
|
|
769
|
+
function sandboxLine(sandbox, path) {
|
|
770
|
+
const ends = `Sandbox key: it and its secrets end at ${sandbox.expiresAt}.`;
|
|
771
|
+
if (sandbox.unsaved) {
|
|
772
|
+
return `${ends} It could not be saved to ${path} (${sandbox.unsaved}), so this server loses it when it stops. To keep the sandbox, call claim_sandbox before then.`;
|
|
773
|
+
}
|
|
774
|
+
return `${ends} Its key is saved in ${path}. To keep the sandbox, call claim_sandbox for a link to give the person who should have it. A sandbox has to be claimed before it can be upgraded.`;
|
|
775
|
+
}
|
|
776
|
+
var SANDBOX_RENEWED_MESSAGE = `A new sandbox started: the last sandbox key expired or X-TKN refused it. Codes create_secret returned before this belong to the old sandbox, and check_status and revoke_secret cannot reach them. If that sandbox was claimed, the account lives on: create a Developer key for it in the account console at ${SIGNUP_URL} and set ${API_KEY_ENV_VARS[0]}.`;
|
|
777
|
+
var SANDBOX_MINT_REFUSED_MESSAGE = `X-TKN is not starting another sandbox from this address for now. Set ${API_KEY_ENV_VARS[0]} in this MCP server's environment to a free Developer key from ${SIGNUP_URL}, or try again in a few minutes.`;
|
|
728
778
|
async function createSecret(client, args) {
|
|
729
779
|
if (args.passphrase !== void 0 && args.recipientEmail !== void 0) {
|
|
730
780
|
throw new XTknConfigError("Set passphrase or recipientEmail, not both. A secret takes one recipient check.");
|
|
@@ -759,6 +809,16 @@ async function checkStatus(client, code2) {
|
|
|
759
809
|
async function revokeSecret(client, code2) {
|
|
760
810
|
return statusOf(await client.revokeToken(code2));
|
|
761
811
|
}
|
|
812
|
+
var CLAIMED_KEY_STILL_ENDS = `A claimed sandbox key still ends when the sandbox would have. For a key that lasts, create a Developer key for the account in the account console at ${SIGNUP_URL} and set ${API_KEY_ENV_VARS[0]}.`;
|
|
813
|
+
var NOT_AN_UNCLAIMED_SANDBOX_MESSAGE = "This key is not an unclaimed sandbox, so there is nothing to claim: it is a sandbox key that was already claimed, merged into another account or has ended, or not a sandbox key at all. " + CLAIMED_KEY_STILL_ENDS;
|
|
814
|
+
async function claimSandbox(client) {
|
|
815
|
+
try {
|
|
816
|
+
return await client.createClaimLink();
|
|
817
|
+
} catch (err) {
|
|
818
|
+
if (err instanceof XTknError && err.status === 409) throw new XTknError(NOT_AN_UNCLAIMED_SANDBOX_MESSAGE, 0);
|
|
819
|
+
throw err;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
762
822
|
function statusOf(token) {
|
|
763
823
|
const uses = token.uses ?? 0;
|
|
764
824
|
const opened = token.isUsed ?? (token.maxUses != null && uses >= token.maxUses);
|
|
@@ -771,16 +831,23 @@ function statusOf(token) {
|
|
|
771
831
|
createdAt: token.createdAt ?? null
|
|
772
832
|
};
|
|
773
833
|
}
|
|
774
|
-
function describeError(err) {
|
|
834
|
+
function describeError(err, origin = "key") {
|
|
835
|
+
if (origin === "mint" && err instanceof XTknRateLimitError) return SANDBOX_MINT_REFUSED_MESSAGE;
|
|
836
|
+
if (origin === "sandbox" && err instanceof XTknRateLimitError) {
|
|
837
|
+
return `X-TKN is refusing more requests on this sandbox key (${err.message}). To go on, call claim_sandbox and give the link it returns to a person: claiming the sandbox lifts its cap. Or set ${API_KEY_ENV_VARS[0]} to a free Developer key from ${SIGNUP_URL}.`;
|
|
838
|
+
}
|
|
839
|
+
if (origin === "sandbox" && err instanceof XTknAuthError && err.status === 401) {
|
|
840
|
+
return `X-TKN refused this server's sandbox key (${err.message}). Set ${API_KEY_ENV_VARS[0]} to a free Developer key from ${SIGNUP_URL}.`;
|
|
841
|
+
}
|
|
775
842
|
if (err instanceof XTknPaymentRequiredError) {
|
|
776
|
-
if (err.upgradeMethod === "claim") return SANDBOX_KEY_MESSAGE;
|
|
777
843
|
const plan = err.requiredPlan ? `the ${err.requiredPlan} plan` : "a paid plan";
|
|
844
|
+
if (err.upgradeMethod === "claim") {
|
|
845
|
+
return `${err.message} This needs ${plan}, and this is a sandbox key: a person has to claim the account before it can be upgraded. Call claim_sandbox for the link that does it.`;
|
|
846
|
+
}
|
|
778
847
|
const where = err.upgradeUrl ? ` A person can upgrade here: ${err.upgradeUrl}` : "";
|
|
779
848
|
return `${err.message} This needs ${plan}.${where}`;
|
|
780
849
|
}
|
|
781
|
-
if (err instanceof XTknRequestError && err.message.startsWith(
|
|
782
|
-
return SANDBOX_KEY_MESSAGE;
|
|
783
|
-
}
|
|
850
|
+
if (err instanceof XTknRequestError && err.message.startsWith(SANDBOX_EXPIRY_REFUSAL)) return SANDBOX_KEY_MESSAGE;
|
|
784
851
|
if (err instanceof XTknConfigError && err.message.startsWith("No API key")) return NO_KEY_MESSAGE;
|
|
785
852
|
if (err instanceof XTknAuthError && err.status === 401) {
|
|
786
853
|
return `X-TKN refused the API key in ${API_KEY_ENV_VARS[0]} (${err.message}). It may be mistyped or revoked.`;
|
|
@@ -794,12 +861,247 @@ function describeError(err) {
|
|
|
794
861
|
return "The tool failed unexpectedly.";
|
|
795
862
|
}
|
|
796
863
|
|
|
864
|
+
// src/sandbox.ts
|
|
865
|
+
async function readSandboxCache(path, baseUrl) {
|
|
866
|
+
let parsed;
|
|
867
|
+
try {
|
|
868
|
+
parsed = JSON.parse(await readFile(path, "utf8"));
|
|
869
|
+
} catch {
|
|
870
|
+
return void 0;
|
|
871
|
+
}
|
|
872
|
+
if (!parsed || typeof parsed !== "object") return void 0;
|
|
873
|
+
const { apiKey, expiresAt, baseUrl: cachedBaseUrl } = parsed;
|
|
874
|
+
if (typeof apiKey !== "string" || apiKey === "") return void 0;
|
|
875
|
+
if (typeof expiresAt !== "string" || Number.isNaN(Date.parse(expiresAt))) return void 0;
|
|
876
|
+
if (cachedBaseUrl !== baseUrl) return void 0;
|
|
877
|
+
return { apiKey, expiresAt, baseUrl };
|
|
878
|
+
}
|
|
879
|
+
async function writeSandboxCache(path, sandbox) {
|
|
880
|
+
await prepareDir(dirname(path));
|
|
881
|
+
const temp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
882
|
+
try {
|
|
883
|
+
await writeFile(temp, `${JSON.stringify(sandbox, null, 2)}
|
|
884
|
+
`, { mode: 384, flag: "wx" });
|
|
885
|
+
await rename(temp, path);
|
|
886
|
+
} catch (err) {
|
|
887
|
+
await rm(temp, { force: true });
|
|
888
|
+
throw err;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
async function prepareDir(dir) {
|
|
892
|
+
await mkdir(dir, { recursive: true, mode: 448 });
|
|
893
|
+
await chmod(dir, 448);
|
|
894
|
+
}
|
|
895
|
+
var LOCK_STALE_MS = DEFAULT_TIMEOUT_MS + 1e4;
|
|
896
|
+
var LOCK_POLL_MS = 25;
|
|
897
|
+
async function withCacheLock(path, work) {
|
|
898
|
+
const lock = `${path}.lock`;
|
|
899
|
+
const token = randomBytes(8).toString("hex");
|
|
900
|
+
if (!await acquireLock(path, lock, token)) return work();
|
|
901
|
+
try {
|
|
902
|
+
return await work();
|
|
903
|
+
} finally {
|
|
904
|
+
await releaseLock(lock, token);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
async function acquireLock(path, lock, token) {
|
|
908
|
+
try {
|
|
909
|
+
await prepareDir(dirname(path));
|
|
910
|
+
for (; ; ) {
|
|
911
|
+
try {
|
|
912
|
+
await writeFile(lock, token, { mode: 384, flag: "wx" });
|
|
913
|
+
return true;
|
|
914
|
+
} catch (err) {
|
|
915
|
+
if (err.code !== "EEXIST") return false;
|
|
916
|
+
}
|
|
917
|
+
let age;
|
|
918
|
+
try {
|
|
919
|
+
age = Date.now() - (await stat(lock)).mtimeMs;
|
|
920
|
+
} catch (err) {
|
|
921
|
+
if (err.code === "ENOENT") continue;
|
|
922
|
+
return false;
|
|
923
|
+
}
|
|
924
|
+
if (age > LOCK_STALE_MS) await rm(lock, { force: true });
|
|
925
|
+
else await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS));
|
|
926
|
+
}
|
|
927
|
+
} catch {
|
|
928
|
+
return false;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
async function releaseLock(lock, token) {
|
|
932
|
+
try {
|
|
933
|
+
if (await readFile(lock, "utf8") === token) await rm(lock, { force: true });
|
|
934
|
+
} catch {
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
var SandboxConnection = class {
|
|
938
|
+
path;
|
|
939
|
+
baseUrl;
|
|
940
|
+
clientOptions;
|
|
941
|
+
now;
|
|
942
|
+
held;
|
|
943
|
+
pending;
|
|
944
|
+
constructor(options) {
|
|
945
|
+
this.path = options.path;
|
|
946
|
+
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
947
|
+
this.clientOptions = { baseUrl: this.baseUrl, fetch: options.fetch, headers: options.headers };
|
|
948
|
+
this.now = options.now ?? Date.now;
|
|
949
|
+
}
|
|
950
|
+
async call(work) {
|
|
951
|
+
let acquired;
|
|
952
|
+
try {
|
|
953
|
+
acquired = await this.acquire();
|
|
954
|
+
} catch (error) {
|
|
955
|
+
return { ok: false, error, origin: "mint", notes: [] };
|
|
956
|
+
}
|
|
957
|
+
const { sandbox, fresh, renewed } = acquired;
|
|
958
|
+
try {
|
|
959
|
+
return { ok: true, value: await work(this.clientFor(sandbox)), notes: this.notes(sandbox, renewed) };
|
|
960
|
+
} catch (error) {
|
|
961
|
+
if (fresh || !isUnauthorized(error)) {
|
|
962
|
+
return { ok: false, error, origin: "sandbox", notes: this.notes(sandbox, renewed) };
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
let replacement;
|
|
966
|
+
try {
|
|
967
|
+
replacement = await this.replace(sandbox);
|
|
968
|
+
} catch (error) {
|
|
969
|
+
return { ok: false, error, origin: "mint", notes: [] };
|
|
970
|
+
}
|
|
971
|
+
try {
|
|
972
|
+
return { ok: true, value: await work(this.clientFor(replacement)), notes: this.notes(replacement, true) };
|
|
973
|
+
} catch (error) {
|
|
974
|
+
return { ok: false, error, origin: "sandbox", notes: this.notes(replacement, true) };
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* The sandbox for this call: the one held while it lasts, then the file's,
|
|
979
|
+
* then a new one.
|
|
980
|
+
*
|
|
981
|
+
* `fresh` is whether it was minted for this call. `renewed` is whether it
|
|
982
|
+
* replaced a sandbox this server or the file had, so the codes from that one
|
|
983
|
+
* are gone.
|
|
984
|
+
*/
|
|
985
|
+
async acquire() {
|
|
986
|
+
const held = this.held;
|
|
987
|
+
if (held && !this.expired(held)) return { sandbox: held, fresh: false, renewed: false };
|
|
988
|
+
const sandbox = await this.single(() => this.load());
|
|
989
|
+
return {
|
|
990
|
+
sandbox,
|
|
991
|
+
fresh: sandbox.minted,
|
|
992
|
+
renewed: held ? sandbox.apiKey !== held.apiKey : sandbox.replaced
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
async load() {
|
|
996
|
+
const cached = await readSandboxCache(this.path, this.baseUrl);
|
|
997
|
+
if (cached && !this.expired(cached)) return this.adopt(cached, false);
|
|
998
|
+
return withCacheLock(this.path, async () => {
|
|
999
|
+
const again = await readSandboxCache(this.path, this.baseUrl);
|
|
1000
|
+
if (again && !this.expired(again)) return this.adopt(again, cached !== void 0);
|
|
1001
|
+
return this.mint(cached !== void 0 || again !== void 0);
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* A sandbox in place of `refused`, unless a concurrent call, or another
|
|
1006
|
+
* server, already made one.
|
|
1007
|
+
*/
|
|
1008
|
+
replace(refused) {
|
|
1009
|
+
return this.single(async () => {
|
|
1010
|
+
const held = this.held;
|
|
1011
|
+
if (held && held.apiKey !== refused.apiKey && !this.expired(held)) return held;
|
|
1012
|
+
return withCacheLock(this.path, async () => {
|
|
1013
|
+
const cached = await readSandboxCache(this.path, this.baseUrl);
|
|
1014
|
+
if (cached && cached.apiKey !== refused.apiKey && !this.expired(cached)) return this.adopt(cached, true);
|
|
1015
|
+
return this.mint(true);
|
|
1016
|
+
});
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
/** Hold a sandbox another server minted, which this one found in the file. */
|
|
1020
|
+
adopt(cached, replaced) {
|
|
1021
|
+
this.held = { ...cached, minted: false, replaced };
|
|
1022
|
+
return this.held;
|
|
1023
|
+
}
|
|
1024
|
+
async mint(replaced) {
|
|
1025
|
+
const { account } = await XTkn.createSandbox(this.clientOptions);
|
|
1026
|
+
if (typeof account?.apiKey !== "string" || !account.apiKey || Number.isNaN(Date.parse(account.expiresAt))) {
|
|
1027
|
+
throw new XTknError("X-TKN started a sandbox but returned no key or expiry for it", 0);
|
|
1028
|
+
}
|
|
1029
|
+
const sandbox = { apiKey: account.apiKey, expiresAt: account.expiresAt, baseUrl: this.baseUrl };
|
|
1030
|
+
let unsaved;
|
|
1031
|
+
try {
|
|
1032
|
+
await writeSandboxCache(this.path, sandbox);
|
|
1033
|
+
} catch (err) {
|
|
1034
|
+
unsaved = err?.code ?? "write failed";
|
|
1035
|
+
}
|
|
1036
|
+
this.held = { ...sandbox, minted: true, replaced, unsaved };
|
|
1037
|
+
return this.held;
|
|
1038
|
+
}
|
|
1039
|
+
/** One mint or file read at a time. Concurrent first calls share the one sandbox. */
|
|
1040
|
+
single(start) {
|
|
1041
|
+
this.pending ??= start().finally(() => {
|
|
1042
|
+
this.pending = void 0;
|
|
1043
|
+
});
|
|
1044
|
+
return this.pending;
|
|
1045
|
+
}
|
|
1046
|
+
expired(sandbox) {
|
|
1047
|
+
return Date.parse(sandbox.expiresAt) <= this.now();
|
|
1048
|
+
}
|
|
1049
|
+
clientFor(sandbox) {
|
|
1050
|
+
return new XTkn({ ...this.clientOptions, apiKey: sandbox.apiKey });
|
|
1051
|
+
}
|
|
1052
|
+
notes(sandbox, renewed) {
|
|
1053
|
+
const line = sandboxLine(sandbox, this.path);
|
|
1054
|
+
return renewed ? [SANDBOX_RENEWED_MESSAGE, line] : [line];
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
function isUnauthorized(err) {
|
|
1058
|
+
return err instanceof XTknAuthError && err.status === 401;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// src/version.ts
|
|
1062
|
+
var VERSION = "1.3.0";
|
|
1063
|
+
|
|
1064
|
+
// src/env.ts
|
|
1065
|
+
var USER_AGENT = `x-tkn-mcp/${VERSION}`;
|
|
1066
|
+
function apiKeyFrom(env) {
|
|
1067
|
+
for (const name of API_KEY_ENV_VARS) {
|
|
1068
|
+
if (env[name]) return env[name];
|
|
1069
|
+
}
|
|
1070
|
+
return void 0;
|
|
1071
|
+
}
|
|
1072
|
+
function clientFrom(env, options = {}) {
|
|
1073
|
+
return new XTkn({ ...options, apiKey: apiKeyFrom(env), headers: { "User-Agent": USER_AGENT } });
|
|
1074
|
+
}
|
|
1075
|
+
function sandboxCachePath(env, home = homedir()) {
|
|
1076
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
1077
|
+
const configHome = xdg && isAbsolute(xdg) ? xdg : join(home, ".config");
|
|
1078
|
+
return join(configHome, "x-tkn", "sandbox.json");
|
|
1079
|
+
}
|
|
1080
|
+
function connectionFrom(env, options = {}) {
|
|
1081
|
+
const { home, now, ...client } = options;
|
|
1082
|
+
if (apiKeyFrom(env)) return keyConnection(clientFrom(env, client));
|
|
1083
|
+
return new SandboxConnection({
|
|
1084
|
+
...client,
|
|
1085
|
+
path: sandboxCachePath(env, home),
|
|
1086
|
+
headers: { "User-Agent": USER_AGENT },
|
|
1087
|
+
now
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
|
|
797
1091
|
// src/server.ts
|
|
1092
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1093
|
+
import { z } from "zod";
|
|
798
1094
|
var INSTRUCTIONS = [
|
|
799
1095
|
"X-TKN hands a secret to a person as a one-time link.",
|
|
800
1096
|
"create_secret encrypts the secret on this machine. X-TKN stores only ciphertext, and the key travels in the link after the #.",
|
|
801
1097
|
"There is no tool to read a secret back. After handing one over you can check whether it was opened (check_status) or cancel it (revoke_secret).",
|
|
802
|
-
"Give the link only to the person it is for: opening it uses it up. If you set a passphrase, send it by a different channel than the link."
|
|
1098
|
+
"Give the link only to the person it is for: opening it uses it up. If you set a passphrase, send it by a different channel than the link.",
|
|
1099
|
+
// Read at connect time, before any tool call can say it. An agent with no
|
|
1100
|
+
// key should start building, not stop to ask a person to sign up, and since
|
|
1101
|
+
// 1.2.0 it need not even fetch a key: `connectionFrom` mints one.
|
|
1102
|
+
"With no X_TKN_API_KEY set, this server gets a sandbox key itself on the first tool call: no signup, and it creates real secrets for 24 hours, long enough to build and test the integration.",
|
|
1103
|
+
"To keep a sandbox past its 24 hours, call claim_sandbox and give the link it returns to the person who should keep it.",
|
|
1104
|
+
`Its key still ends on schedule after a claim. For a key that stays, a person signs up at ${SIGNUP_URL} for a free Developer key.`
|
|
803
1105
|
].join(" ");
|
|
804
1106
|
var durationField = z.number().int().min(0);
|
|
805
1107
|
var expiresIn = z.object({
|
|
@@ -817,7 +1119,8 @@ var statusShape = {
|
|
|
817
1119
|
expiresAt: z.string().nullable(),
|
|
818
1120
|
createdAt: z.string().nullable()
|
|
819
1121
|
};
|
|
820
|
-
function createXtknMcpServer(
|
|
1122
|
+
function createXtknMcpServer(options) {
|
|
1123
|
+
const connection = "connection" in options ? options.connection : keyConnection(options.client);
|
|
821
1124
|
const server = new McpServer({ name: "x-tkn", title: "X-TKN", version: VERSION }, { instructions: INSTRUCTIONS });
|
|
822
1125
|
server.registerTool(
|
|
823
1126
|
"create_secret",
|
|
@@ -826,7 +1129,9 @@ function createXtknMcpServer({ client }) {
|
|
|
826
1129
|
description: "Turn a secret (a password, API key, recovery code or private note) into a link a person opens once. The secret is encrypted on this machine before anything is sent, and X-TKN never holds the key. The link stops working once opened, or at its expiry. You cannot read the secret back afterwards: this server has no read tool. Returns the link and a code for check_status and revoke_secret. Up to 64 KB on the free Developer plan, 256 KB on Team and Enterprise.",
|
|
827
1130
|
inputSchema: {
|
|
828
1131
|
secret: z.string().min(1).describe("The secret to hand over. It is encrypted before it leaves this machine."),
|
|
829
|
-
expiresIn: expiresIn.optional().describe(
|
|
1132
|
+
expiresIn: expiresIn.optional().describe(
|
|
1133
|
+
"How long the link works if nobody opens it. Defaults to the plan's: 7 days on Developer, 30 on Team. On a sandbox key it cannot run past the sandbox, which ends 24 hours after it was created, and defaults to that end."
|
|
1134
|
+
),
|
|
830
1135
|
passphrase: z.string().min(8).max(256).optional().describe(
|
|
831
1136
|
"Make the recipient type this before the secret opens. Send it by a different channel than the link. X-TKN stores a hash of it, which gates the link and plays no part in the encryption. Team plan and above."
|
|
832
1137
|
),
|
|
@@ -845,18 +1150,18 @@ function createXtknMcpServer({ client }) {
|
|
|
845
1150
|
},
|
|
846
1151
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
847
1152
|
},
|
|
848
|
-
(args) => run(async () => created(await createSecret(client, args)))
|
|
1153
|
+
(args) => run(connection, async (client) => created(await createSecret(client, args)))
|
|
849
1154
|
);
|
|
850
1155
|
server.registerTool(
|
|
851
1156
|
"check_status",
|
|
852
1157
|
{
|
|
853
1158
|
title: "Check a secret link",
|
|
854
|
-
description: "Whether a secret link is still waiting, has been opened, expired, or was revoked. Never returns the secret.",
|
|
1159
|
+
description: "Whether a secret link is still waiting, has been opened, expired, or was revoked. Never returns the secret. Each call is one metered operation, and a sandbox has 100 in all, so call this no more often than every 30 seconds.",
|
|
855
1160
|
inputSchema: { code },
|
|
856
1161
|
outputSchema: statusShape,
|
|
857
1162
|
annotations: { readOnlyHint: true, openWorldHint: true }
|
|
858
1163
|
},
|
|
859
|
-
({ code: code2 }) => run(async () => status(await checkStatus(client, code2)))
|
|
1164
|
+
({ code: code2 }) => run(connection, async (client) => status(await checkStatus(client, code2)))
|
|
860
1165
|
);
|
|
861
1166
|
server.registerTool(
|
|
862
1167
|
"revoke_secret",
|
|
@@ -867,16 +1172,28 @@ function createXtknMcpServer({ client }) {
|
|
|
867
1172
|
outputSchema: statusShape,
|
|
868
1173
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }
|
|
869
1174
|
},
|
|
870
|
-
({ code: code2 }) => run(async () => status(await revokeSecret(client, code2)))
|
|
1175
|
+
({ code: code2 }) => run(connection, async (client) => status(await revokeSecret(client, code2)))
|
|
1176
|
+
);
|
|
1177
|
+
server.registerTool(
|
|
1178
|
+
"claim_sandbox",
|
|
1179
|
+
{
|
|
1180
|
+
title: "Get a link that keeps this sandbox",
|
|
1181
|
+
description: "Get a one-time link a person opens to claim the sandbox this server's key belongs to, so the account outlives its 24 hours and keeps its secrets. Give the link only to the person who should keep it. It works once, for 30 minutes or until the sandbox ends if that is sooner, and calling this again voids the earlier link. The sandbox key itself still ends on schedule: a claimed account goes on with a Developer key created for it. Not metered. On a key that is not an unclaimed sandbox it says so and claims nothing.",
|
|
1182
|
+
outputSchema: {
|
|
1183
|
+
claimUrl: z.string().describe("The one-time link for the person who should keep the sandbox."),
|
|
1184
|
+
expiresAt: z.string().describe("When the link stops working if nobody has used it.")
|
|
1185
|
+
},
|
|
1186
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
1187
|
+
},
|
|
1188
|
+
() => run(connection, async (client) => claimLink(await claimSandbox(client)))
|
|
871
1189
|
);
|
|
872
1190
|
return server;
|
|
873
1191
|
}
|
|
874
|
-
async function run(work) {
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
}
|
|
1192
|
+
async function run(connection, work) {
|
|
1193
|
+
const called = await connection.call(work);
|
|
1194
|
+
const result = called.ok ? called.value : { content: [{ type: "text", text: describeError(called.error, called.origin) }], isError: true };
|
|
1195
|
+
if (called.notes.length === 0) return result;
|
|
1196
|
+
return { ...result, content: [...result.content, { type: "text", text: called.notes.join("\n") }] };
|
|
880
1197
|
}
|
|
881
1198
|
function created(secret) {
|
|
882
1199
|
const lines = [
|
|
@@ -891,6 +1208,19 @@ function created(secret) {
|
|
|
891
1208
|
lines.push(`Code for check_status and revoke_secret: ${secret.code}`);
|
|
892
1209
|
return { content: [{ type: "text", text: lines.join("\n") }], structuredContent: { ...secret } };
|
|
893
1210
|
}
|
|
1211
|
+
function claimLink(link) {
|
|
1212
|
+
const lines = [
|
|
1213
|
+
`Claim link: ${link.claimUrl}`,
|
|
1214
|
+
"Give it to the person who should keep this sandbox, and to nobody else: anyone holding it can claim the sandbox.",
|
|
1215
|
+
`It works once, until ${link.expiresAt}: 30 minutes, or the sandbox's end if that is sooner. Calling claim_sandbox again voids it.`,
|
|
1216
|
+
"Claiming keeps the sandbox's secrets.",
|
|
1217
|
+
CLAIMED_KEY_STILL_ENDS
|
|
1218
|
+
];
|
|
1219
|
+
return {
|
|
1220
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
1221
|
+
structuredContent: { claimUrl: link.claimUrl, expiresAt: link.expiresAt }
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
894
1224
|
var STATUS_TEXT = {
|
|
895
1225
|
waiting: "Waiting: not opened yet.",
|
|
896
1226
|
opened: "Opened: the recipient has it, and the link no longer works.",
|
|
@@ -911,8 +1241,8 @@ async function main(argv) {
|
|
|
911
1241
|
`);
|
|
912
1242
|
return;
|
|
913
1243
|
}
|
|
914
|
-
if (!apiKeyFrom(process.env)) console.error(
|
|
915
|
-
const server = createXtknMcpServer({
|
|
1244
|
+
if (!apiKeyFrom(process.env)) console.error(sandboxStartupMessage(sandboxCachePath(process.env)));
|
|
1245
|
+
const server = createXtknMcpServer({ connection: connectionFrom(process.env) });
|
|
916
1246
|
await server.connect(new StdioServerTransport());
|
|
917
1247
|
}
|
|
918
1248
|
main(process.argv.slice(2)).catch((err) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fennecstudio/x-tkn-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"mcpName": "io.github.fennecstudio/x-tkn-mcp",
|
|
5
5
|
"description": "MCP server for X-TKN: hand a secret to a person as a one-time link, encrypted on your machine. No read tool.",
|
|
6
6
|
"keywords": [
|