@fennecstudio/x-tkn-mcp 1.0.0 → 1.2.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.
Files changed (3) hide show
  1. package/README.md +33 -9
  2. package/dist/index.js +306 -42
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -22,18 +22,41 @@ recipient can read it.
22
22
 
23
23
  ## Setup
24
24
 
25
- You need Node 20 or later and an X-TKN API key. Developer keys are free: sign up
26
- at [x-tkn.com](https://x-tkn.com) and create one in the account console.
27
- Sandbox keys (from `POST /accounts/sandbox`) cannot create secrets.
25
+ You need Node 20 or later. An X-TKN API key is optional.
26
+
27
+ **With no key**, the server starts a sandbox on its first tool call, with no
28
+ signup. Nothing is minted at startup. The key is saved in
29
+ `$XDG_CONFIG_HOME/x-tkn/sandbox.json` (`~/.config/x-tkn/sandbox.json` by
30
+ default), the directory 0700 and the file 0600, and later starts reuse it until
31
+ it expires. The key never appears in a tool result or in the server's log.
32
+ Servers that start together share one sandbox: while one mints, it holds a
33
+ `sandbox.json.lock` beside the file, and the others wait for it and use its key.
34
+
35
+ - A sandbox lasts 24 hours, and so does every secret it creates:
36
+ `create_secret` refuses an `expiresIn` that runs past the sandbox's end.
37
+ Every result on a sandbox key ends with a line giving that end.
38
+ - When it ends, the next call starts a new sandbox and says so. Codes from the
39
+ old one no longer work with `check_status` or `revoke_secret`.
40
+ - A person can keep a sandbox by claiming it at `POST /accounts/claim` with the
41
+ key from that file. A sandbox has to be claimed before it can be upgraded.
42
+ The sandbox key still ends on schedule after a claim, so a claimed account
43
+ goes on with a Developer key created for it and set here.
44
+ - Once a sandbox has spent its operations, the server does not start another,
45
+ because that would lose the old one's codes. Claim it, or set a key.
46
+
47
+ **For a key that stays**, Developer keys are free: sign up at
48
+ [x-tkn.com](https://x-tkn.com) and create one in the account console. A key in
49
+ `X_TKN_API_KEY` always wins: then nothing is minted and nothing is written.
28
50
 
29
51
  **Claude Code**
30
52
 
31
53
  ```bash
32
- claude mcp add x-tkn -e X_TKN_API_KEY=your-key -- npx -y @fennecstudio/x-tkn-mcp
54
+ claude mcp add x-tkn -- npx -y @fennecstudio/x-tkn-mcp # a sandbox
55
+ claude mcp add x-tkn -e X_TKN_API_KEY=your-key -- npx -y @fennecstudio/x-tkn-mcp # your key
33
56
  ```
34
57
 
35
58
  **Claude Desktop, Cursor, Windsurf and other clients** take the same block in
36
- their MCP config file:
59
+ their MCP config file. Leave `env` out for a sandbox:
37
60
 
38
61
  ```json
39
62
  {
@@ -47,9 +70,6 @@ their MCP config file:
47
70
  }
48
71
  ```
49
72
 
50
- Without a key the server still starts, and every tool call answers with how to
51
- get one.
52
-
53
73
  ## Tools
54
74
 
55
75
  ### `create_secret`
@@ -59,7 +79,7 @@ Encrypts a secret and returns a link that opens it once.
59
79
  | Argument | Required | What it does |
60
80
  | ---------------- | -------- | -------------------------------------------------------------------------------------------------------------- |
61
81
  | `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. |
82
+ | `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
83
  | `passphrase` | no | The recipient has to type this before the secret opens. 8 to 256 characters. Team and above. |
64
84
  | `recipientEmail` | no | The recipient has to enter a 6-digit code sent to this address. Team and above. Not with `passphrase`. |
65
85
  | `note` | no | For your own records in the X-TKN console. Stored unencrypted and never shown to the recipient. |
@@ -74,6 +94,10 @@ open the secret.
74
94
  Takes the `code`. Returns `waiting`, `opened`, `expired` or `revoked`, with
75
95
  `openedAt` once it has been opened. It never returns the secret.
76
96
 
97
+ Each call is one metered operation, and a sandbox has 100 in all. Call it no
98
+ more often than every 30 seconds: at one call every 5 seconds, a link nobody
99
+ opens spends a whole sandbox in under nine minutes.
100
+
77
101
  ### `revoke_secret`
78
102
 
79
103
  Takes the `code`. Stops the link working, immediately and for good. A secret
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, 24 hours, and no
215
- * `secret` tokens. {@link XTkn.claim} turns it into an ordinary Developer
216
- * account with 10,000 operations a month and no expiry.
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}
220
+ * turns it into an ordinary Developer account with 10,000 operations a month
221
+ * 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
@@ -587,24 +594,23 @@ function unwrap(parsed) {
587
594
  return parsed;
588
595
  }
589
596
 
590
- // src/version.ts
591
- var VERSION = "1.0.0";
592
-
593
- // src/env.ts
594
- var USER_AGENT = `x-tkn-mcp/${VERSION}`;
595
- function apiKeyFrom(env) {
596
- for (const name of API_KEY_ENV_VARS) {
597
- if (env[name]) return env[name];
598
- }
599
- return void 0;
600
- }
601
- function clientFrom(env) {
602
- return new XTkn({ apiKey: apiKeyFrom(env), headers: { "User-Agent": USER_AGENT } });
597
+ // src/connection.ts
598
+ function keyConnection(client) {
599
+ return {
600
+ async call(work) {
601
+ try {
602
+ return { ok: true, value: await work(client), notes: [] };
603
+ } catch (error) {
604
+ return { ok: false, error, origin: "key", notes: [] };
605
+ }
606
+ }
607
+ };
603
608
  }
604
609
 
605
- // src/server.ts
606
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
607
- import { z } from "zod";
610
+ // src/sandbox.ts
611
+ import { randomBytes } from "node:crypto";
612
+ import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
613
+ import { dirname } from "node:path";
608
614
 
609
615
  // ../js/src/vendor/token-envelope.ts
610
616
  var PREFIX_V1 = "xtkn.v1.";
@@ -723,8 +729,22 @@ function buildSecretLink(code2, key, options = {}) {
723
729
  // src/tools.ts
724
730
  var SECRET_TYPE = "secret";
725
731
  var SIGNUP_URL = "https://x-tkn.com";
726
- var NO_KEY_MESSAGE = `No X-TKN API key. Set ${API_KEY_ENV_VARS[0]} in this MCP server's environment. Developer keys are free: sign up at ${SIGNUP_URL} and create one in the account console. Sandbox keys cannot create secrets.`;
727
- var SANDBOX_KEY_MESSAGE = `This is a sandbox key, and sandbox accounts cannot create secrets. Set ${API_KEY_ENV_VARS[0]} to a free Developer key instead: sign up at ${SIGNUP_URL} and create one in the account console.`;
732
+ var SANDBOX_CLAIM_ENDPOINT = "POST /accounts/claim";
733
+ var SANDBOX_EXPIRY_REFUSAL = "Sandbox accounts cannot mint tokens that outlive them.";
734
+ 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.`;
735
+ 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, a person can claim the account at ${SANDBOX_CLAIM_ENDPOINT}, or set ${API_KEY_ENV_VARS[0]} to a free Developer key from ${SIGNUP_URL}.`;
736
+ function sandboxStartupMessage(path) {
737
+ 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}.`;
738
+ }
739
+ function sandboxLine(sandbox, path) {
740
+ const ends = `Sandbox key: it and its secrets end at ${sandbox.expiresAt}.`;
741
+ if (sandbox.unsaved) {
742
+ return `${ends} It could not be saved to ${path} (${sandbox.unsaved}), so nobody can claim it, and this server loses it when it stops.`;
743
+ }
744
+ return `${ends} To keep it, a person claims it at ${SANDBOX_CLAIM_ENDPOINT} with the key saved in ${path}. A sandbox has to be claimed before it can be upgraded.`;
745
+ }
746
+ 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]}.`;
747
+ 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
748
  async function createSecret(client, args) {
729
749
  if (args.passphrase !== void 0 && args.recipientEmail !== void 0) {
730
750
  throw new XTknConfigError("Set passphrase or recipientEmail, not both. A secret takes one recipient check.");
@@ -771,16 +791,24 @@ function statusOf(token) {
771
791
  createdAt: token.createdAt ?? null
772
792
  };
773
793
  }
774
- function describeError(err) {
794
+ function describeError(err, origin = "key") {
795
+ if (origin === "mint" && err instanceof XTknRateLimitError) return SANDBOX_MINT_REFUSED_MESSAGE;
796
+ if (origin === "sandbox" && err instanceof XTknRateLimitError) {
797
+ return `X-TKN is refusing more requests on this sandbox key (${err.message}). To go on, a person can claim the sandbox at ${SANDBOX_CLAIM_ENDPOINT}, which lifts its cap, or set ${API_KEY_ENV_VARS[0]} to a free Developer key from ${SIGNUP_URL}.`;
798
+ }
799
+ if (origin === "sandbox" && err instanceof XTknAuthError && err.status === 401) {
800
+ 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}.`;
801
+ }
775
802
  if (err instanceof XTknPaymentRequiredError) {
776
- if (err.upgradeMethod === "claim") return SANDBOX_KEY_MESSAGE;
777
803
  const plan = err.requiredPlan ? `the ${err.requiredPlan} plan` : "a paid plan";
804
+ if (err.upgradeMethod === "claim") {
805
+ const claim = err.claimEndpoint ?? SANDBOX_CLAIM_ENDPOINT;
806
+ return `${err.message} This needs ${plan}, and this is a sandbox key: a person has to claim the account at ${claim} before it can be upgraded.`;
807
+ }
778
808
  const where = err.upgradeUrl ? ` A person can upgrade here: ${err.upgradeUrl}` : "";
779
809
  return `${err.message} This needs ${plan}.${where}`;
780
810
  }
781
- if (err instanceof XTknRequestError && err.message.startsWith("Sandbox accounts cannot create secrets")) {
782
- return SANDBOX_KEY_MESSAGE;
783
- }
811
+ if (err instanceof XTknRequestError && err.message.startsWith(SANDBOX_EXPIRY_REFUSAL)) return SANDBOX_KEY_MESSAGE;
784
812
  if (err instanceof XTknConfigError && err.message.startsWith("No API key")) return NO_KEY_MESSAGE;
785
813
  if (err instanceof XTknAuthError && err.status === 401) {
786
814
  return `X-TKN refused the API key in ${API_KEY_ENV_VARS[0]} (${err.message}). It may be mistyped or revoked.`;
@@ -794,12 +822,246 @@ function describeError(err) {
794
822
  return "The tool failed unexpectedly.";
795
823
  }
796
824
 
825
+ // src/sandbox.ts
826
+ async function readSandboxCache(path, baseUrl) {
827
+ let parsed;
828
+ try {
829
+ parsed = JSON.parse(await readFile(path, "utf8"));
830
+ } catch {
831
+ return void 0;
832
+ }
833
+ if (!parsed || typeof parsed !== "object") return void 0;
834
+ const { apiKey, expiresAt, baseUrl: cachedBaseUrl } = parsed;
835
+ if (typeof apiKey !== "string" || apiKey === "") return void 0;
836
+ if (typeof expiresAt !== "string" || Number.isNaN(Date.parse(expiresAt))) return void 0;
837
+ if (cachedBaseUrl !== baseUrl) return void 0;
838
+ return { apiKey, expiresAt, baseUrl };
839
+ }
840
+ async function writeSandboxCache(path, sandbox) {
841
+ await prepareDir(dirname(path));
842
+ const temp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
843
+ try {
844
+ await writeFile(temp, `${JSON.stringify(sandbox, null, 2)}
845
+ `, { mode: 384, flag: "wx" });
846
+ await rename(temp, path);
847
+ } catch (err) {
848
+ await rm(temp, { force: true });
849
+ throw err;
850
+ }
851
+ }
852
+ async function prepareDir(dir) {
853
+ await mkdir(dir, { recursive: true, mode: 448 });
854
+ await chmod(dir, 448);
855
+ }
856
+ var LOCK_STALE_MS = DEFAULT_TIMEOUT_MS + 1e4;
857
+ var LOCK_POLL_MS = 25;
858
+ async function withCacheLock(path, work) {
859
+ const lock = `${path}.lock`;
860
+ const token = randomBytes(8).toString("hex");
861
+ if (!await acquireLock(path, lock, token)) return work();
862
+ try {
863
+ return await work();
864
+ } finally {
865
+ await releaseLock(lock, token);
866
+ }
867
+ }
868
+ async function acquireLock(path, lock, token) {
869
+ try {
870
+ await prepareDir(dirname(path));
871
+ for (; ; ) {
872
+ try {
873
+ await writeFile(lock, token, { mode: 384, flag: "wx" });
874
+ return true;
875
+ } catch (err) {
876
+ if (err.code !== "EEXIST") return false;
877
+ }
878
+ let age;
879
+ try {
880
+ age = Date.now() - (await stat(lock)).mtimeMs;
881
+ } catch (err) {
882
+ if (err.code === "ENOENT") continue;
883
+ return false;
884
+ }
885
+ if (age > LOCK_STALE_MS) await rm(lock, { force: true });
886
+ else await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS));
887
+ }
888
+ } catch {
889
+ return false;
890
+ }
891
+ }
892
+ async function releaseLock(lock, token) {
893
+ try {
894
+ if (await readFile(lock, "utf8") === token) await rm(lock, { force: true });
895
+ } catch {
896
+ }
897
+ }
898
+ var SandboxConnection = class {
899
+ path;
900
+ baseUrl;
901
+ clientOptions;
902
+ now;
903
+ held;
904
+ pending;
905
+ constructor(options) {
906
+ this.path = options.path;
907
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
908
+ this.clientOptions = { baseUrl: this.baseUrl, fetch: options.fetch, headers: options.headers };
909
+ this.now = options.now ?? Date.now;
910
+ }
911
+ async call(work) {
912
+ let acquired;
913
+ try {
914
+ acquired = await this.acquire();
915
+ } catch (error) {
916
+ return { ok: false, error, origin: "mint", notes: [] };
917
+ }
918
+ const { sandbox, fresh, renewed } = acquired;
919
+ try {
920
+ return { ok: true, value: await work(this.clientFor(sandbox)), notes: this.notes(sandbox, renewed) };
921
+ } catch (error) {
922
+ if (fresh || !isUnauthorized(error)) {
923
+ return { ok: false, error, origin: "sandbox", notes: this.notes(sandbox, renewed) };
924
+ }
925
+ }
926
+ let replacement;
927
+ try {
928
+ replacement = await this.replace(sandbox);
929
+ } catch (error) {
930
+ return { ok: false, error, origin: "mint", notes: [] };
931
+ }
932
+ try {
933
+ return { ok: true, value: await work(this.clientFor(replacement)), notes: this.notes(replacement, true) };
934
+ } catch (error) {
935
+ return { ok: false, error, origin: "sandbox", notes: this.notes(replacement, true) };
936
+ }
937
+ }
938
+ /**
939
+ * The sandbox for this call: the one held while it lasts, then the file's,
940
+ * then a new one.
941
+ *
942
+ * `fresh` is whether it was minted for this call. `renewed` is whether it
943
+ * replaced a sandbox this server or the file had, so the codes from that one
944
+ * are gone.
945
+ */
946
+ async acquire() {
947
+ const held = this.held;
948
+ if (held && !this.expired(held)) return { sandbox: held, fresh: false, renewed: false };
949
+ const sandbox = await this.single(() => this.load());
950
+ return {
951
+ sandbox,
952
+ fresh: sandbox.minted,
953
+ renewed: held ? sandbox.apiKey !== held.apiKey : sandbox.replaced
954
+ };
955
+ }
956
+ async load() {
957
+ const cached = await readSandboxCache(this.path, this.baseUrl);
958
+ if (cached && !this.expired(cached)) return this.adopt(cached, false);
959
+ return withCacheLock(this.path, async () => {
960
+ const again = await readSandboxCache(this.path, this.baseUrl);
961
+ if (again && !this.expired(again)) return this.adopt(again, cached !== void 0);
962
+ return this.mint(cached !== void 0 || again !== void 0);
963
+ });
964
+ }
965
+ /**
966
+ * A sandbox in place of `refused`, unless a concurrent call, or another
967
+ * server, already made one.
968
+ */
969
+ replace(refused) {
970
+ return this.single(async () => {
971
+ const held = this.held;
972
+ if (held && held.apiKey !== refused.apiKey && !this.expired(held)) return held;
973
+ return withCacheLock(this.path, async () => {
974
+ const cached = await readSandboxCache(this.path, this.baseUrl);
975
+ if (cached && cached.apiKey !== refused.apiKey && !this.expired(cached)) return this.adopt(cached, true);
976
+ return this.mint(true);
977
+ });
978
+ });
979
+ }
980
+ /** Hold a sandbox another server minted, which this one found in the file. */
981
+ adopt(cached, replaced) {
982
+ this.held = { ...cached, minted: false, replaced };
983
+ return this.held;
984
+ }
985
+ async mint(replaced) {
986
+ const { account } = await XTkn.createSandbox(this.clientOptions);
987
+ if (typeof account?.apiKey !== "string" || !account.apiKey || Number.isNaN(Date.parse(account.expiresAt))) {
988
+ throw new XTknError("X-TKN started a sandbox but returned no key or expiry for it", 0);
989
+ }
990
+ const sandbox = { apiKey: account.apiKey, expiresAt: account.expiresAt, baseUrl: this.baseUrl };
991
+ let unsaved;
992
+ try {
993
+ await writeSandboxCache(this.path, sandbox);
994
+ } catch (err) {
995
+ unsaved = err?.code ?? "write failed";
996
+ }
997
+ this.held = { ...sandbox, minted: true, replaced, unsaved };
998
+ return this.held;
999
+ }
1000
+ /** One mint or file read at a time. Concurrent first calls share the one sandbox. */
1001
+ single(start) {
1002
+ this.pending ??= start().finally(() => {
1003
+ this.pending = void 0;
1004
+ });
1005
+ return this.pending;
1006
+ }
1007
+ expired(sandbox) {
1008
+ return Date.parse(sandbox.expiresAt) <= this.now();
1009
+ }
1010
+ clientFor(sandbox) {
1011
+ return new XTkn({ ...this.clientOptions, apiKey: sandbox.apiKey });
1012
+ }
1013
+ notes(sandbox, renewed) {
1014
+ const line = sandboxLine(sandbox, this.path);
1015
+ return renewed ? [SANDBOX_RENEWED_MESSAGE, line] : [line];
1016
+ }
1017
+ };
1018
+ function isUnauthorized(err) {
1019
+ return err instanceof XTknAuthError && err.status === 401;
1020
+ }
1021
+
1022
+ // src/version.ts
1023
+ var VERSION = "1.2.0";
1024
+
1025
+ // src/env.ts
1026
+ var USER_AGENT = `x-tkn-mcp/${VERSION}`;
1027
+ function apiKeyFrom(env) {
1028
+ for (const name of API_KEY_ENV_VARS) {
1029
+ if (env[name]) return env[name];
1030
+ }
1031
+ return void 0;
1032
+ }
1033
+ function clientFrom(env, options = {}) {
1034
+ return new XTkn({ ...options, apiKey: apiKeyFrom(env), headers: { "User-Agent": USER_AGENT } });
1035
+ }
1036
+ function sandboxCachePath(env, home = homedir()) {
1037
+ const xdg = env.XDG_CONFIG_HOME;
1038
+ const configHome = xdg && isAbsolute(xdg) ? xdg : join(home, ".config");
1039
+ return join(configHome, "x-tkn", "sandbox.json");
1040
+ }
1041
+ function connectionFrom(env, options = {}) {
1042
+ const { home, now, ...client } = options;
1043
+ if (apiKeyFrom(env)) return keyConnection(clientFrom(env, client));
1044
+ return new SandboxConnection({
1045
+ ...client,
1046
+ path: sandboxCachePath(env, home),
1047
+ headers: { "User-Agent": USER_AGENT },
1048
+ now
1049
+ });
1050
+ }
1051
+
797
1052
  // src/server.ts
1053
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1054
+ import { z } from "zod";
798
1055
  var INSTRUCTIONS = [
799
1056
  "X-TKN hands a secret to a person as a one-time link.",
800
1057
  "create_secret encrypts the secret on this machine. X-TKN stores only ciphertext, and the key travels in the link after the #.",
801
1058
  "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."
1059
+ "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.",
1060
+ // Read at connect time, before any tool call can say it. An agent with no
1061
+ // key should start building, not stop to ask a person to sign up, and since
1062
+ // 1.2.0 it need not even fetch a key: `connectionFrom` mints one.
1063
+ "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.",
1064
+ `For a key that stays, a person can claim the sandbox at ${SANDBOX_CLAIM_ENDPOINT}, or sign up at ${SIGNUP_URL} for a free Developer key.`
803
1065
  ].join(" ");
804
1066
  var durationField = z.number().int().min(0);
805
1067
  var expiresIn = z.object({
@@ -817,7 +1079,8 @@ var statusShape = {
817
1079
  expiresAt: z.string().nullable(),
818
1080
  createdAt: z.string().nullable()
819
1081
  };
820
- function createXtknMcpServer({ client }) {
1082
+ function createXtknMcpServer(options) {
1083
+ const connection = "connection" in options ? options.connection : keyConnection(options.client);
821
1084
  const server = new McpServer({ name: "x-tkn", title: "X-TKN", version: VERSION }, { instructions: INSTRUCTIONS });
822
1085
  server.registerTool(
823
1086
  "create_secret",
@@ -826,7 +1089,9 @@ function createXtknMcpServer({ client }) {
826
1089
  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
1090
  inputSchema: {
828
1091
  secret: z.string().min(1).describe("The secret to hand over. It is encrypted before it leaves this machine."),
829
- expiresIn: expiresIn.optional().describe("How long the link works if nobody opens it. Defaults to the plan's: 7 days on Developer, 30 on Team."),
1092
+ expiresIn: expiresIn.optional().describe(
1093
+ "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."
1094
+ ),
830
1095
  passphrase: z.string().min(8).max(256).optional().describe(
831
1096
  "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
1097
  ),
@@ -845,18 +1110,18 @@ function createXtknMcpServer({ client }) {
845
1110
  },
846
1111
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
847
1112
  },
848
- (args) => run(async () => created(await createSecret(client, args)))
1113
+ (args) => run(connection, async (client) => created(await createSecret(client, args)))
849
1114
  );
850
1115
  server.registerTool(
851
1116
  "check_status",
852
1117
  {
853
1118
  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.",
1119
+ 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
1120
  inputSchema: { code },
856
1121
  outputSchema: statusShape,
857
1122
  annotations: { readOnlyHint: true, openWorldHint: true }
858
1123
  },
859
- ({ code: code2 }) => run(async () => status(await checkStatus(client, code2)))
1124
+ ({ code: code2 }) => run(connection, async (client) => status(await checkStatus(client, code2)))
860
1125
  );
861
1126
  server.registerTool(
862
1127
  "revoke_secret",
@@ -867,16 +1132,15 @@ function createXtknMcpServer({ client }) {
867
1132
  outputSchema: statusShape,
868
1133
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }
869
1134
  },
870
- ({ code: code2 }) => run(async () => status(await revokeSecret(client, code2)))
1135
+ ({ code: code2 }) => run(connection, async (client) => status(await revokeSecret(client, code2)))
871
1136
  );
872
1137
  return server;
873
1138
  }
874
- async function run(work) {
875
- try {
876
- return await work();
877
- } catch (err) {
878
- return { content: [{ type: "text", text: describeError(err) }], isError: true };
879
- }
1139
+ async function run(connection, work) {
1140
+ const called = await connection.call(work);
1141
+ const result = called.ok ? called.value : { content: [{ type: "text", text: describeError(called.error, called.origin) }], isError: true };
1142
+ if (called.notes.length === 0) return result;
1143
+ return { ...result, content: [...result.content, { type: "text", text: called.notes.join("\n") }] };
880
1144
  }
881
1145
  function created(secret) {
882
1146
  const lines = [
@@ -911,8 +1175,8 @@ async function main(argv) {
911
1175
  `);
912
1176
  return;
913
1177
  }
914
- if (!apiKeyFrom(process.env)) console.error(NO_KEY_MESSAGE);
915
- const server = createXtknMcpServer({ client: clientFrom(process.env) });
1178
+ if (!apiKeyFrom(process.env)) console.error(sandboxStartupMessage(sandboxCachePath(process.env)));
1179
+ const server = createXtknMcpServer({ connection: connectionFrom(process.env) });
916
1180
  await server.connect(new StdioServerTransport());
917
1181
  }
918
1182
  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.0.0",
3
+ "version": "1.2.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": [